update commit prev
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
+28
-10
@@ -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
|
||||
|
||||
+82
-3
@@ -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
|
||||
|
||||
+27
-15
@@ -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:
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
+1
-1
@@ -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=[],
|
||||
)
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<name>/` 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.
|
||||
|
||||
@@ -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': [...] }`.
|
||||
|
||||
+19
-7
@@ -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/<name>/` 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/`.
|
||||
```
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user