diff --git a/CLI.py b/CLI.py index 19f1361..158a196 100644 --- a/CLI.py +++ b/CLI.py @@ -527,7 +527,7 @@ class CmdletIntrospection: if normalized_arg == "scrape": try: - from Provider.metadata_provider import list_metadata_providers + from plugins.metadata_provider import list_metadata_providers meta_providers = list_metadata_providers(config) or {} if meta_providers: @@ -2164,9 +2164,11 @@ Come to love it when others take what you share, as there is no greater joy detail=detail ) - provider_cfg = config.get("provider" - ) if isinstance(config, - dict) else None + provider_cfg = None + if isinstance(config, dict): + provider_cfg = config.get("plugin") + if not isinstance(provider_cfg, dict): + provider_cfg = config.get("provider") if isinstance(provider_cfg, dict) and provider_cfg: for check in _collect_plugin_startup_checks(config): _add_startup_check( diff --git a/Provider/__init__.py b/Provider/__init__.py index 57b8add..4ea97b6 100644 --- a/Provider/__init__.py +++ b/Provider/__init__.py @@ -1,11 +1,6 @@ -"""Built-in plugin modules. +"""Legacy compatibility package. -Concrete built-in plugins live in this package. -The public registry lives in ProviderCore.registry. +Bundled runtime plugins now live under ``plugins/``. This package remains only +for helper modules and backwards-compatible imports that have not been removed +yet. """ - -# Register providers with the strict ResultTable adapter system -try: - from . import alldebrid -except Exception: - pass diff --git a/Provider/metadata_provider.py b/Provider/metadata_provider.py index 8ba30a4..0c06780 100644 --- a/Provider/metadata_provider.py +++ b/Provider/metadata_provider.py @@ -12,7 +12,7 @@ from API.HTTP import HTTPClient from API.requests_client import get_requests_session from ProviderCore.base import SearchResult try: - from Provider.Tidal import Tidal + from plugins.tidal import Tidal except ImportError: # pragma: no cover - optional Tidal = None from API.Tidal import ( diff --git a/ProviderCore/__init__.py b/ProviderCore/__init__.py index 2f23cef..a72cbea 100644 --- a/ProviderCore/__init__.py +++ b/ProviderCore/__init__.py @@ -1,6 +1,5 @@ """Plugin core modules. This package contains the plugin framework (base types, registry, and shared -helpers). Built-in plugins continue to live in the `Provider/` package for -backward compatibility. +helpers). Bundled plugins live in the `plugins/` package. """ diff --git a/ProviderCore/registry.py b/ProviderCore/registry.py index 6b162ca..ac91af2 100644 --- a/ProviderCore/registry.py +++ b/ProviderCore/registry.py @@ -1,8 +1,8 @@ """Plugin registry. -Built-in plugin implementations live in the ``Provider`` package. External user -plugins can be dropped into a repo-local ``plugins/`` directory or discovered -via environment-configured plugin paths. +Bundled plugins live in the ``plugins`` package. Additional drop-in plugins can +be discovered from external plugin directories configured via the environment or +current working directory. """ from __future__ import annotations @@ -132,9 +132,23 @@ class ProviderRegistry: self._lookup: Dict[str, ProviderInfo] = {} self._modules: set[str] = set() self._external_modules: set[str] = set() + self._builtin_package_dirs: Tuple[Path, ...] = () self._discovered = False self._external_dirs_scanned = False + def _is_builtin_package_dir(self, candidate: Path) -> bool: + try: + resolved = candidate.resolve() + except Exception: + resolved = candidate + for package_dir in self._builtin_package_dirs: + try: + if resolved == package_dir: + return True + except Exception: + continue + return False + def _normalize(self, value: Any) -> str: return str(value or "").strip().lower() @@ -241,6 +255,8 @@ class ProviderRegistry: self._external_dirs_scanned = True for plugin_dir in _iter_external_plugin_dirs(): + if self._is_builtin_package_dir(plugin_dir): + continue try: plugin_dir_str = str(plugin_dir) if plugin_dir_str and plugin_dir_str not in sys.path: @@ -287,6 +303,14 @@ class ProviderRegistry: self._register_module(package) package_path = getattr(package, "__path__", None) + if package_path: + builtin_dirs: List[Path] = [] + for entry in package_path: + try: + builtin_dirs.append(Path(str(entry)).resolve()) + except Exception: + builtin_dirs.append(Path(str(entry))) + self._builtin_package_dirs = tuple(builtin_dirs) if not package_path: self._discover_external_plugins() return @@ -294,8 +318,6 @@ class ProviderRegistry: for finder, module_name, _ in pkgutil.iter_modules(package_path): if module_name.startswith("_"): continue - if module_name.strip().lower() == "hifi": - continue module_path = f"{self.package_name}.{module_name}" try: module = importlib.import_module(module_path) @@ -318,10 +340,6 @@ class ProviderRegistry: if not name or not self.package_name: return - # Keep behavior consistent with full discovery (which skips hifi). - if name == "hifi": - return - candidates: List[str] = [name] if "-" in name: candidates.append(name.replace("-", "_")) @@ -386,7 +404,7 @@ class ProviderRegistry: _walk(sub) _walk(Provider) -REGISTRY = ProviderRegistry("Provider") +REGISTRY = ProviderRegistry("plugins") PLUGIN_REGISTRY = REGISTRY PluginInfo = ProviderInfo PluginRegistry = ProviderRegistry diff --git a/SYS/config.py b/SYS/config.py index 4756e7d..820047f 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -93,7 +93,10 @@ def clear_config_cache() -> None: def _log_config_load_summary(config: Dict[str, Any]) -> None: try: - provs = list(config.get("provider", {}).keys()) if isinstance(config.get("provider"), dict) else [] + plugin_block = config.get("plugin") + if not isinstance(plugin_block, dict): + plugin_block = config.get("provider") + provs = list(plugin_block.keys()) if isinstance(plugin_block, dict) else [] stores = list(config.get("store", {}).keys()) if isinstance(config.get("store"), dict) else [] mtime = None try: @@ -101,7 +104,7 @@ def _log_config_load_summary(config: Dict[str, Any]) -> None: except Exception: mtime = None summary = ( - f"Loaded config from {db.db_path.name}: providers={len(provs)} ({', '.join(provs[:10])}{'...' if len(provs)>10 else ''}), " + f"Loaded config from {db.db_path.name}: plugins={len(provs)} ({', '.join(provs[:10])}{'...' if len(provs)>10 else ''}), " f"stores={len(stores)} ({', '.join(stores[:10])}{'...' if len(stores)>10 else ''}), mtime={mtime}" ) log(summary) @@ -325,6 +328,7 @@ def get_hydrus_url(config: Dict[str, Any], instance_name: str = "home") -> Optio def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]: + _normalize_plugin_config_aliases(config) provider_cfg = config.get("provider") if not isinstance(provider_cfg, dict): return {} @@ -518,7 +522,7 @@ def resolve_cookies_path( except Exception as exc: logger.debug("resolve_cookies_path: failed to read tool.ytdlp cookies: %s", exc, exc_info=True) - base_dir = script_dir or SCRIPT_DIR + base_dir = _resolve_app_root(script_dir) for value in values: if not value: continue @@ -528,6 +532,10 @@ def resolve_cookies_path( if candidate.is_file(): return candidate + plugin_cookie = resolve_plugin_asset_path("ytdlp", "cookies.txt", script_dir=base_dir) + if plugin_cookie is not None: + return plugin_cookie + default_path = base_dir / "cookies.txt" if default_path.is_file(): return default_path @@ -547,6 +555,70 @@ def _normalize_provider_name(value: Any) -> Optional[str]: candidate = str(value or "").strip().lower() return candidate if candidate else None + +def _resolve_app_root(script_dir: Optional[Path] = None) -> Path: + if script_dir is not None: + try: + candidate = expand_path(script_dir) + except Exception: + candidate = Path(script_dir) + return candidate if candidate.is_dir() else candidate.parent + return SCRIPT_DIR.parent + + +def resolve_plugin_asset_path( + plugin_name: str, + *relative_parts: str, + script_dir: Optional[Path] = None, +) -> Optional[Path]: + normalized = _normalize_provider_name(plugin_name) + if not normalized: + return None + + plugin_dir = _resolve_app_root(script_dir) / "plugins" / normalized + if not plugin_dir.is_dir(): + return None + + for part in relative_parts: + text = str(part or "").strip().strip("/\\") + if not text: + continue + candidate = plugin_dir / text + if candidate.is_file(): + return candidate + return None + + +def _normalize_plugin_config_aliases(config: Dict[str, Any]) -> None: + if not isinstance(config, dict): + return + + plugin_block = config.get("plugin") + provider_block = config.get("provider") + + normalized_provider: Dict[str, Any] = {} + + if isinstance(provider_block, dict): + for key, value in provider_block.items(): + normalized_key = _normalize_provider_name(key) + if normalized_key and normalized_key not in normalized_provider: + normalized_provider[normalized_key] = value + + if isinstance(plugin_block, dict): + for key, value in plugin_block.items(): + normalized_key = _normalize_provider_name(key) + if normalized_key and normalized_key not in normalized_provider: + normalized_provider[normalized_key] = value + + if normalized_provider: + config["provider"] = normalized_provider + config["plugin"] = normalized_provider + else: + if isinstance(provider_block, dict): + config["plugin"] = provider_block + elif isinstance(plugin_block, dict): + config["provider"] = plugin_block + def _extract_api_key(value: Any) -> Optional[str]: if isinstance(value, dict): for key in ("api_key", "API_KEY", "apikey", "APIKEY"): @@ -564,6 +636,8 @@ def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None: if not isinstance(config, dict): return + _normalize_plugin_config_aliases(config) + providers = config.get("provider") if not isinstance(providers, dict): providers = {} @@ -617,7 +691,10 @@ def _sync_alldebrid_api_key(config: Dict[str, Any]) -> None: def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]: entries: Dict[Tuple[str, str, str, str], Any] = {} + _normalize_plugin_config_aliases(config) for key, value in config.items(): + if key == 'plugin': + continue if key in ('store', 'provider', 'tool') and isinstance(value, dict): for subtype, instances in value.items(): if not isinstance(instances, dict): @@ -655,6 +732,7 @@ def load_config(*, emit_summary: bool = True) -> Dict[str, Any]: # Load strictly from database db_config = get_config_all() if db_config: + _normalize_plugin_config_aliases(db_config) _sync_alldebrid_api_key(db_config) _CONFIG_CACHE = db_config _LAST_SAVED_CONFIG = deepcopy(db_config) @@ -742,6 +820,7 @@ def _release_save_lock(lock_dir: Path) -> None: def save_config(config: Dict[str, Any]) -> int: global _CONFIG_CACHE, _LAST_SAVED_CONFIG + _normalize_plugin_config_aliases(config) _sync_alldebrid_api_key(config) # Acquire cross-process save lock to avoid concurrent saves from different diff --git a/SYS/plugin_config.py b/SYS/plugin_config.py index 1070345..e5967ef 100644 --- a/SYS/plugin_config.py +++ b/SYS/plugin_config.py @@ -61,11 +61,15 @@ def get_store_schema(store_type: str) -> List[ConfigField]: return _call_schema(cls, f"store '{store_type}'") -def get_provider_schema(provider_name: str) -> List[ConfigField]: - plugin_class = get_plugin_class(str(provider_name or "").strip()) +def get_plugin_schema(plugin_name: str) -> List[ConfigField]: + plugin_class = get_plugin_class(str(plugin_name or "").strip()) if plugin_class is None: return [] - return _call_schema(plugin_class, f"provider '{provider_name}'") + return _call_schema(plugin_class, f"plugin '{plugin_name}'") + + +def get_provider_schema(provider_name: str) -> List[ConfigField]: + return get_plugin_schema(provider_name) def get_tool_schema(tool_name: str) -> List[ConfigField]: @@ -85,8 +89,8 @@ def get_item_schema(item_type: str, item_name: str) -> List[ConfigField]: normalized_name = str(item_name or "").strip() if normalized_type.startswith("store-"): return get_store_schema(normalized_type.replace("store-", "", 1)) - if normalized_type == "provider": - return get_provider_schema(normalized_name) + if normalized_type in {"provider", "plugin"}: + return get_plugin_schema(normalized_name) if normalized_type == "tool": return get_tool_schema(normalized_name) return [] @@ -126,25 +130,29 @@ def build_default_store_config(store_type: str, instance_name: str) -> Dict[str, return config -def build_default_provider_config(provider_name: str) -> Dict[str, Any]: +def build_default_plugin_config(plugin_name: str) -> Dict[str, Any]: config: Dict[str, Any] = {} - schema = get_provider_schema(provider_name) + schema = get_plugin_schema(plugin_name) if schema: for field in schema: config[field["key"]] = field.get("default", "") return config - plugin_class = get_plugin_class(str(provider_name or "").strip()) + plugin_class = get_plugin_class(str(plugin_name or "").strip()) if plugin_class is None: return config try: for required_key in plugin_class.required_config_keys(): config[str(required_key)] = "" except Exception: - logger.exception("Failed to load legacy required config keys for provider '%s'", provider_name) + logger.exception("Failed to load legacy required config keys for plugin '%s'", plugin_name) return config +def build_default_provider_config(provider_name: str) -> Dict[str, Any]: + return build_default_plugin_config(provider_name) + + def build_default_tool_config(tool_name: str) -> Dict[str, Any]: config: Dict[str, Any] = {} for field in get_tool_schema(tool_name): @@ -179,14 +187,14 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]: if cls is not None: for required_key in _required_keys_for(cls): _add_key(required_key) - elif normalized_type == "provider": + elif normalized_type in {"provider", "plugin"}: plugin_class = get_plugin_class(normalized_name) if plugin_class is not None: try: for required_key in plugin_class.required_config_keys(): _add_key(required_key) except Exception: - logger.exception("Failed to load required config keys for provider '%s'", normalized_name) + logger.exception("Failed to load required config keys for plugin '%s'", normalized_name) return required_keys @@ -199,14 +207,18 @@ def get_configurable_store_types() -> List[str]: return sorted(set(options)) -def get_configurable_provider_types() -> List[str]: +def get_configurable_plugin_types() -> List[str]: options: List[str] = [] - for provider_name in list_plugins().keys(): - if get_provider_schema(provider_name): - options.append(str(provider_name)) + for plugin_name in list_plugins().keys(): + if get_plugin_schema(plugin_name): + options.append(str(plugin_name)) return sorted(set(options)) +def get_configurable_provider_types() -> List[str]: + return get_configurable_plugin_types() + + def get_configurable_tool_types() -> List[str]: options: List[str] = [] try: diff --git a/TUI.py b/TUI.py index e3cb482..46bf6fc 100644 --- a/TUI.py +++ b/TUI.py @@ -616,7 +616,10 @@ class PipelineHubApp(App): # Provide a visible startup summary of configured plugins/stores for debugging try: cfg = load_config() or {} - provs = list(cfg.get("provider", {}).keys()) if isinstance(cfg.get("provider"), dict) else [] + plugin_block = cfg.get("plugin") + if not isinstance(plugin_block, dict): + plugin_block = cfg.get("provider") + provs = list(plugin_block.keys()) if isinstance(plugin_block, dict) else [] stores = list(cfg.get("store", {}).keys()) if isinstance(cfg.get("store"), dict) else [] prov_display = ", ".join(provs[:10]) + ("..." if len(provs) > 10 else "") store_display = ", ".join(stores[:10]) + ("..." if len(stores) > 10 else "") diff --git a/cmdlet/get_tag.py b/cmdlet/get_tag.py index 86a2044..e937694 100644 --- a/cmdlet/get_tag.py +++ b/cmdlet/get_tag.py @@ -14,7 +14,7 @@ import sys from SYS.logger import log, debug -from Provider.metadata_provider import ( +from plugins.metadata_provider import ( get_default_subject_scrape_provider, get_metadata_provider, get_metadata_provider_for_url, diff --git a/cmdnat/_status_shared.py b/cmdnat/_status_shared.py index 3c8ad37..43279a9 100644 --- a/cmdnat/_status_shared.py +++ b/cmdnat/_status_shared.py @@ -25,7 +25,7 @@ def add_startup_check( row = table.add_row() row.add_column("STATUS", upper_text(status)) row.add_column("NAME", upper_text(name)) - row.add_column("PROVIDER", upper_text(provider or "")) + row.add_column("PLUGIN", upper_text(provider or "")) row.add_column("STORE", upper_text(store or "")) row.add_column("FILES", "" if files is None else str(files)) row.add_column("DETAIL", upper_text(detail or "")) @@ -42,7 +42,9 @@ def has_store_subtype(cfg: dict, subtype: str) -> bool: def has_provider(cfg: dict, name: str) -> bool: - provider_cfg = cfg.get("provider") + provider_cfg = cfg.get("plugin") + if not isinstance(provider_cfg, dict): + provider_cfg = cfg.get("provider") if not isinstance(provider_cfg, dict): return False block = provider_cfg.get(str(name).strip().lower()) @@ -74,7 +76,7 @@ def ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]: def provider_display_name(key: str) -> str: label = (key or "").strip() - return label[:1].upper() + label[1:] if label else "Provider" + return label[:1].upper() + label[1:] if label else "Plugin" def ping_first(urls: list[str]) -> tuple[bool, str]: @@ -88,7 +90,11 @@ def ping_first(urls: list[str]) -> tuple[bool, str]: def collect_plugin_startup_checks(config: dict) -> list[dict[str, Any]]: - provider_cfg = config.get("provider") if isinstance(config, dict) else None + provider_cfg = None + if isinstance(config, dict): + provider_cfg = config.get("plugin") + if not isinstance(provider_cfg, dict): + provider_cfg = config.get("provider") if not isinstance(provider_cfg, dict) or not provider_cfg: return [] diff --git a/cmdnat/status.py b/cmdnat/status.py index 18d9cab..8d797eb 100644 --- a/cmdnat/status.py +++ b/cmdnat/status.py @@ -16,7 +16,7 @@ from cmdnat._status_shared import ( CMDLET = Cmdlet( name=".status", - summary="Check and display service/provider status", + summary="Check and display service/plugin status", usage=".status", arg=[], ) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ce35921..f8df2e2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,5 +5,5 @@ - **docs:** Add `docs/provider_authoring.md` with a Quick Start, examples, and testing guidance for providers that integrate with the strict `ResultTable` API (ResultModel/ColumnSpec/selection_fn). - **docs:** Add link to `docs/result_table.md` pointing to the provider authoring guide. - **tests:** Add `tests/test_provider_author_examples.py` validating example provider registration and adapter behavior. -- **notes:** Existing example providers (`Provider/example_provider.py`, `Provider/vimm.py`) are referenced as canonical patterns. +- **notes:** Existing example plugins (`plugins/example_provider.py`, `plugins/vimm/__init__.py`) are referenced as canonical patterns. diff --git a/docs/provider_authoring.md b/docs/provider_authoring.md index f8e2f46..93a30ee 100644 --- a/docs/provider_authoring.md +++ b/docs/provider_authoring.md @@ -84,7 +84,7 @@ class MyTableProvider(TableProviderMixin, Provider): return self.search_table_from_url(url, limit=limit) ``` -`TableProviderMixin.search_table_from_url` returns `ProviderCore.base.SearchResult` entries. If you want to integrate this plugin with the strict `ResultTable` registry, add a small adapter that converts `SearchResult` -> `ResultModel` and register it using `register_plugin` (see `Provider/vimm.py` for a real example). +`TableProviderMixin.search_table_from_url` returns `ProviderCore.base.SearchResult` entries. If you want to integrate this plugin with the strict `ResultTable` registry, add a small adapter that converts `SearchResult` -> `ResultModel` and register it using `register_plugin` (see `plugins/vimm/__init__.py` for a real example). --- @@ -115,7 +115,7 @@ class MyTableProvider(TableProviderMixin, Provider): ```py from SYS.result_table_adapters import get_provider -from Provider import example_provider +from plugins import example_provider def test_example_provider_registration(): @@ -132,10 +132,10 @@ def test_example_provider_registration(): ## References & examples -- Read `Provider/example_provider.py` for a compact example of a strict adapter and dynamic columns. -- Read `Provider/vimm.py` for a table-provider that uses `TableProviderMixin` and converts `SearchResult` → `ResultModel` for registration. +- Read `plugins/example_provider.py` for a compact example of a strict adapter and dynamic columns. +- Read `plugins/vimm/__init__.py` for a table-provider that uses `TableProviderMixin` and converts `SearchResult` → `ResultModel` for registration. - See `docs/provider_guide.md` for a broader provider development checklist. --- -If you want, I can also add a small `Provider/myprovider_template.py` file and unit tests for it — say the word and I'll add them and wire up tests. 🎯 +If you want, I can also add a small `plugins/myprovider_template.py` file and unit tests for it. diff --git a/docs/provider_guide.md b/docs/provider_guide.md index 1de6ed1..696f479 100644 --- a/docs/provider_guide.md +++ b/docs/provider_guide.md @@ -3,7 +3,7 @@ ## 🎯 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. Built-in plugins live in `Provider/` and external drop-in plugins live under `plugins/`. +> Keep plugin code small, focused, and well-tested. Bundled plugins and drop-in plugins share the same `plugins/` layout. --- @@ -125,8 +125,9 @@ pytest -q --- ## 📦 Registration & packaging -- Built-in plugins live under `Provider/` and are auto-discovered from that package. +- 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. - Plugin authors should import from `ProviderCore.*`. --- @@ -146,19 +147,19 @@ pytest -q - [ ] 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 module to `Provider/` for built-ins, or drop it into `plugins/` for plug-and-play user installs +- [ ] Add the plugin under `plugins//` for bundled or plug-and-play installs --- ## 🔗 Further reading -- See existing built-in plugins in `Provider/` for patterns and edge cases. +- 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 `Provider/` as a template (see `Provider/hello_provider.py`), and +- 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 provider and tests in this repository; use them as a starting point for new providers. +I have added a minimal example plugin and tests in this repository; use them as a starting point for new plugins. diff --git a/docs/result_table_selector.md b/docs/result_table_selector.md index 3c51e8f..d5b6c00 100644 --- a/docs/result_table_selector.md +++ b/docs/result_table_selector.md @@ -13,7 +13,7 @@ Example: What plugins must implement - An adapter that yields `ResultModel` objects (breaking API). -- Optionally supply a `columns` factory and `selection_fn` (see `Provider/example_provider.py`). +- Optionally supply a `columns` factory and `selection_fn` (see `plugins/example_provider.py`). Implementation notes - `plugin-table` emits dicts like `{ 'title': ..., 'path': ..., 'metadata': ..., '_selection_args': [...] }`. diff --git a/plugins/README.md b/plugins/README.md index 3ef179d..83513e7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,14 +1,28 @@ -# External Plugins +# Plugins -Drop user plugins in this folder to make them available to the app without editing the built-in `Provider/` package. +This folder is the primary home for bundled plugins and also the default search +path for drop-in plugins. -Supported discovery paths: +Preferred layout: +- Put each plugin in its own folder under `plugins//` with an `__init__.py`. +- Keep plugin-specific assets beside the code in that same folder. +- Single-file `.py` plugins are still supported, but package folders are the + recommended plug-and-play format. + +That means a plugin can ship as a drag-and-drop folder with extras such as: +- `cookies.txt` +- templates or fixture files +- helper modules +- small static assets + +Built-in bundled plugins use the same layout as external plugins. Additional +drop-in plugin search paths are: - `plugins/` in the repo root - `plugins/` in the current working directory - Any directory listed in `MM_PLUGIN_PATH` - Any directory listed in `MEDEIA_PLUGIN_PATH` -Plugin module rules: +Plugin rules: - A plugin can be a single `.py` file or a package directory with `__init__.py`. - Define a class that inherits from `ProviderCore.base.Provider`. - Give it a stable name using `PLUGIN_NAME` or the class name. @@ -34,6 +48,4 @@ class MyPlugin(Provider): path=f"https://example.com/{text}", ) ] -``` - -Built-in plugins still live in `Provider/`. \ No newline at end of file +``` \ No newline at end of file diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..f86c3b1 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1,6 @@ +"""Built-in plugin package. + +This package is the primary home for bundled plugins. Each built-in plugin +should live in its own module or package under ``plugins/`` so it can also be +distributed as a drag-and-drop drop-in with any sibling assets it needs. +""" diff --git a/plugins/example_provider.py b/plugins/example_provider.py new file mode 100644 index 0000000..30158b3 --- /dev/null +++ b/plugins/example_provider.py @@ -0,0 +1,7 @@ +"""Plugin-namespace import shim for the strict adapter example module. + +This keeps example docs pointing at the plugin namespace while the original +implementation remains in ``Provider.example_provider`` for compatibility. +""" + +from Provider.example_provider import * # noqa: F401,F403 diff --git a/plugins/hifi/__init__.py b/plugins/hifi/__init__.py index c996aeb..631e4fb 100644 --- a/plugins/hifi/__init__.py +++ b/plugins/hifi/__init__.py @@ -18,7 +18,7 @@ from API.Tidal import ( ) from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments from SYS.field_access import get_field -from Provider.tidal_manifest import resolve_tidal_manifest_path +from plugins.tidal_manifest import resolve_tidal_manifest_path from SYS import pipeline as pipeline_context from SYS.logger import debug, debug_panel, log diff --git a/plugins/internetarchive/__init__.py b/plugins/internetarchive/__init__.py index 7d2ef66..a7f1969 100644 --- a/plugins/internetarchive/__init__.py +++ b/plugins/internetarchive/__init__.py @@ -694,7 +694,7 @@ class InternetArchive(Provider): def _download_via_openlibrary(self, url: str, output_dir: Path) -> Optional[Dict[str, Any]]: try: - from Provider.openlibrary import OpenLibrary + from plugins.openlibrary import OpenLibrary except Exception as exc: log(f"[internetarchive] OpenLibrary borrow helper unavailable: {exc}", file=sys.stderr) return None @@ -766,7 +766,7 @@ class InternetArchive(Provider): return None try: - from Provider.openlibrary import OpenLibrary + from plugins.openlibrary import OpenLibrary except Exception as exc: log(f"[internetarchive] OpenLibrary auth helper unavailable: {exc}", file=sys.stderr) return None diff --git a/plugins/libgen/__init__.py b/plugins/libgen/__init__.py index 6d43538..b61463a 100644 --- a/plugins/libgen/__init__.py +++ b/plugins/libgen/__init__.py @@ -383,7 +383,7 @@ def _enrich_book_tags_from_isbn(isbn: str, # 2) isbnsearch metadata provider fallback. try: - from Provider.metadata_provider import get_metadata_provider + from plugins.metadata_provider import get_metadata_provider provider = get_metadata_provider("isbnsearch", config or {}) diff --git a/plugins/metadata_provider.py b/plugins/metadata_provider.py new file mode 100644 index 0000000..3b37ecc --- /dev/null +++ b/plugins/metadata_provider.py @@ -0,0 +1,7 @@ +"""Plugin-namespace import shim for metadata helper utilities. + +The implementation currently lives in ``Provider.metadata_provider`` while the +legacy namespace is phased out. New imports should prefer ``plugins``. +""" + +from Provider.metadata_provider import * # noqa: F401,F403 diff --git a/plugins/openlibrary/__init__.py b/plugins/openlibrary/__init__.py index 448094d..0dda51e 100644 --- a/plugins/openlibrary/__init__.py +++ b/plugins/openlibrary/__init__.py @@ -22,7 +22,7 @@ from ProviderCore.base import Provider, SearchResult from SYS.utils import sanitize_filename from SYS.cli_syntax import get_field, get_free_text, parse_query from SYS.logger import log -from Provider.metadata_provider import ( +from plugins.metadata_provider import ( archive_item_metadata_to_tags, fetch_archive_item_metadata, ) diff --git a/plugins/tidal/__init__.py b/plugins/tidal/__init__.py index 5b6c588..ee53826 100644 --- a/plugins/tidal/__init__.py +++ b/plugins/tidal/__init__.py @@ -18,7 +18,7 @@ from API.Tidal import ( ) from ProviderCore.base import Provider, SearchResult from SYS.field_access import get_field -from Provider.tidal_manifest import resolve_tidal_manifest_path +from plugins.tidal_manifest import resolve_tidal_manifest_path from SYS import pipeline as pipeline_context from SYS.logger import debug, log diff --git a/plugins/tidal_manifest.py b/plugins/tidal_manifest.py new file mode 100644 index 0000000..0b4d242 --- /dev/null +++ b/plugins/tidal_manifest.py @@ -0,0 +1,7 @@ +"""Plugin-namespace import shim for Tidal/HIFI manifest helpers. + +The implementation currently lives in ``Provider.tidal_manifest`` while the +legacy namespace is phased out. New imports should prefer ``plugins``. +""" + +from Provider.tidal_manifest import * # noqa: F401,F403