This commit is contained in:
2026-05-16 15:26:08 -07:00
parent 5048729b0c
commit 02d84f423e
10 changed files with 488 additions and 470 deletions
+72 -73
View File
@@ -1,62 +1,69 @@
# Plugin Development Guide
## 🎯 Purpose
This guide describes how to write, test, and register a plugin so the application can discover and use it as a pluggable component.
## Purpose
This guide describes how to write, test, and register a plugin so the
application can discover and use it as a pluggable component.
> Keep plugin code small, focused, and well-tested. Bundled plugins and drop-in plugins share the same `plugins/` layout.
Note: this file keeps its historical `provider_guide` name, but the public
model is plugin-first. Some runtime classes still use `Provider` naming
internally.
Keep plugin code small, focused, and well-tested. Bundled plugins and drop-in
plugins share the same `plugins/` layout.
---
## 🔧 Anatomy of a Plugin
A plugin is a Python class that extends `ProviderCore.base.Provider` and implements a few key methods and attributes.
## Anatomy of a plugin
A plugin is a Python class that currently extends the internal base class
`ProviderCore.base.Provider` and implements a few key methods and attributes.
Minimum expectations:
- `class MyPlugin(Provider):` subclass the base plugin class
- `URL` / `URL_DOMAINS` or `url_patterns()` — to let the registry route URLs
- `validate(self) -> bool` return True when the plugin is configured and usable
- `search(self, query, limit=50, filters=None, **kwargs)` return a list of `SearchResult`
- `class MyPlugin(Provider):` subclasses the current internal base plugin class.
- `URL`, `URL_DOMAINS`, or `url_patterns()` let the registry route URLs.
- `validate(self) -> bool` returns `True` when the plugin is configured and usable.
- `search(self, query, limit=50, filters=None, **kwargs)` returns a list of `SearchResult` items.
Optional but common:
- `download(self, result: SearchResult, output_dir: Path) -> Optional[Path]` — download a plugin result
- `selector(self, selected_items, *, ctx, stage_is_last=True, **kwargs) -> bool` — handle `@N` selections
- `download_url(self, url, output_dir, progress_cb=None)` — direct URL-handling helper
- `download(self, result: SearchResult, output_dir: Path) -> Optional[Path]`
- `selector(self, selected_items, *, ctx, stage_is_last=True, **kwargs) -> bool`
- `download_url(self, url, output_dir, progress_cb=None)`
---
## 🧩 SearchResult
## SearchResult
Use `ProviderCore.base.SearchResult` to describe results returned by `search()`.
Important fields:
- `table` (str) — provider table name
- `title` (str) short human title
- `path` (str) canonical URL / link the provider/dl may use
- `media_kind` (str) `file`, `folder`, `book`, etc.
- `columns` (list[tuple[str,str]]) extra key/value pairs to display
- `full_metadata` (dict) — provider-specific metadata for downstream stages
- `annotations` / `tag` simple metadata for filtering
- `table` (str): plugin table name
- `title` (str): short human title
- `path` (str): canonical URL or link the plugin or downloader may use
- `media_kind` (str): `file`, `folder`, `book`, and similar values
- `columns` (list[tuple[str, str]]): extra key/value pairs to display
- `full_metadata` (dict): plugin-specific metadata for downstream stages
- `annotations` or `tag`: simple metadata for filtering
Return a list of `SearchResult(...)` objects or simple dicts convertible with `.to_dict()`.
---
## Implementing search()
## Implementing `search()`
- Parse and sanitize `query` and `filters`.
- Return no more than `limit` results.
- Use `columns` to provide table columns (TITLE, Seeds, Size, etc.).
- Keep `search()` fast and predictable (apply reasonable timeouts).
- Use `columns` to provide table columns such as `TITLE`, `Seeds`, or `Size`.
- Keep `search()` fast and predictable by using reasonable timeouts.
Example:
```python
from ProviderCore.base import Provider, SearchResult
class HelloProvider(Provider):
class HelloPlugin(Provider):
def search(self, query, limit=50, filters=None, **kwargs):
q = (query or "").strip()
if not q:
return []
results = []
# Build up results
results.append(
results = [
SearchResult(
table="hello",
title=f"Hit for {q}",
@@ -64,102 +71,94 @@ class HelloProvider(Provider):
columns=[("Info", "example")],
full_metadata={"source": "hello"},
)
)
]
return results[:max(0, int(limit))]
```
---
## ⬇️ Implementing download() and download_url()
## Implementing `download()` and `download_url()`
- Prefer plugin `download(self, result, output_dir)` for piped plugin items.
- For plugin-provided URLs, implement `download_url` to allow `download-file` to route downloads through plugins.
- For plugin-provided URLs, implement `download_url` so `download-file` can route downloads through the plugin.
- Use the repo `_download_direct_file` helper for HTTP downloads when possible.
Example download():
Example download method:
```python
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
# Validate config
url = getattr(result, "path", None)
if not url or not url.startswith("http"):
return None
# use existing helpers to fetch the file
return _download_direct_file(url, output_dir)
```
---
## 🧭 URL routing
## URL routing
Plugins can declare:
- `URL = ("magnet:",)` or similar prefix list
- `URL = ("magnet:",)` or similar prefix lists
- `URL_DOMAINS = ("example.com",)` to match hosts
- Or override `@classmethod def url_patterns(cls):` to combine static and dynamic patterns
- `@classmethod def url_patterns(cls):` to combine static and dynamic patterns
The registry uses these to match `download-file <url>` or to pick which plugin should handle the URL.
The registry uses these declarations to match `download-file <url>` and to pick
which plugin should handle a URL.
---
## 🛠 Selector (handling `@N` picks)
- Implement `selector(self, selected_items, *, ctx, stage_is_last=True)` to present a sub-table or to enqueue downloads.
- Use `ctx.set_last_result_table()` and `ctx.set_current_stage_table()` to display follow-ups.
- Return `True` when you handled the selection and the pipeline should pause or proceed accordingly.
## Selector behavior and `@N`
- Implement `selector(self, selected_items, *, ctx, stage_is_last=True)` to present a sub-table or enqueue downloads.
- Use `ctx.set_last_result_table()` and `ctx.set_current_stage_table()` to display follow-up tables.
- Return `True` when the selector handled the selection and the pipeline should stop expanding that row.
---
## 🧪 Testing plugins
- Keep tests small and local. Create `tests/test_provider_<name>.py` or another tracked test target.
- Test `search()` with mock HTTP responses (use `requests-mock` or similar).
## Testing plugins
- Keep tests small and local.
- Create `tests/test_plugin_<name>.py` or follow the existing repo naming when extending older tests.
- Test `search()` with mock HTTP responses.
- Test `download()` using a temp directory and a small file server or by mocking `_download_direct_file`.
- Test `selector()` by constructing a fake result and `ctx` object.
Example PowerShell commands to run tests (repo root):
Example PowerShell commands from the repo root:
```powershell
# Run a single test file
pytest tests/test_provider_hello.py -q
# Run all tests
pytest tests/test_plugin_hello.py -q
pytest -q
```
---
## 📦 Registration & packaging
## Registration and packaging
- Bundled plugins live under `plugins/` and are auto-discovered from that package.
- External user plugins can be dropped into `plugins/` or any directory listed in `MM_PLUGIN_PATH` / `MEDEIA_PLUGIN_PATH`.
- Package directories are preferred so plugin-specific files can travel with the plugin.
- External plugins can be dropped into `plugins/` or any directory listed in `MM_PLUGIN_PATH` or `MEDEIA_PLUGIN_PATH`.
- Package directories are preferred so plugin-specific files travel with the plugin.
- Plugin authors should import from `ProviderCore.*`.
If a plugin supports multiple configured endpoints or accounts, the user-facing
concept is a plugin instance. Some stored config still lives under legacy key
paths such as `provider.<plugin>.<instance>`.
---
## 💡 Best practices & tips
- Use `debug()` / `log()` appropriately; avoid noisy stderr output in normal runs.
## Best practices
- Use `debug()` and `log()` appropriately; avoid noisy stderr output in normal runs.
- Prefer returning `SearchResult` objects to provide consistent UX.
- Keep `search()` tolerant (timeouts, malformed responses) and avoid raising for expected network problems.
- Keep `search()` tolerant of timeouts and malformed responses.
- Use `full_metadata` to pass non-display data to `download()` and `selector()`.
- Respect the `limit` parameter in `search()`.
---
## 🧾 Example provider checklist
- [ ] Implement `search()` and return `SearchResult` items
- [ ] Implement `validate()` to check essential config (API keys, credentials)
- [ ] Provide `URL` / `URL_DOMAINS` or `url_patterns()` for routing
- [ ] Add `download()` or `download_url()` for piped/passed URL downloads
- [ ] Add tests under `tests/`
- [ ] Add the plugin under `plugins/<name>/` for bundled or plug-and-play installs
## Example plugin checklist
- [ ] Implement `search()` and return `SearchResult` items.
- [ ] Implement `validate()` to check essential config such as API keys or credentials.
- [ ] Provide `URL`, `URL_DOMAINS`, or `url_patterns()` for routing.
- [ ] Add `download()` or `download_url()` for piped or passed URL downloads.
- [ ] Add tests under `tests/`.
- [ ] Add the plugin under `plugins/<name>/` for bundled or plug-and-play installs.
---
## 🔗 Further reading
## Further reading
- See existing bundled plugins in `plugins/` for patterns and edge cases.
- Check `API/` helpers for HTTP and debrid clients.
---
If you'd like, I can:
- Add an example plugin file under `plugins/` as a template (see `plugins/hello/__init__.py`), and
- Create unit tests for it (see `tests/test_provider_hello.py`).
I have added a minimal example plugin and tests in this repository; use them as a starting point for new plugins.
- Check `API/` helpers for HTTP and debrid clients used by plugins.