update commit prev
This commit is contained in:
@@ -527,7 +527,7 @@ class CmdletIntrospection:
|
|||||||
|
|
||||||
if normalized_arg == "scrape":
|
if normalized_arg == "scrape":
|
||||||
try:
|
try:
|
||||||
from Provider.metadata_provider import list_metadata_providers
|
from plugins.metadata_provider import list_metadata_providers
|
||||||
|
|
||||||
meta_providers = list_metadata_providers(config) or {}
|
meta_providers = list_metadata_providers(config) or {}
|
||||||
if meta_providers:
|
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
|
detail=detail
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_cfg = config.get("provider"
|
provider_cfg = None
|
||||||
) if isinstance(config,
|
if isinstance(config, dict):
|
||||||
dict) else None
|
provider_cfg = config.get("plugin")
|
||||||
|
if not isinstance(provider_cfg, dict):
|
||||||
|
provider_cfg = config.get("provider")
|
||||||
if isinstance(provider_cfg, dict) and provider_cfg:
|
if isinstance(provider_cfg, dict) and provider_cfg:
|
||||||
for check in _collect_plugin_startup_checks(config):
|
for check in _collect_plugin_startup_checks(config):
|
||||||
_add_startup_check(
|
_add_startup_check(
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
"""Built-in plugin modules.
|
"""Legacy compatibility package.
|
||||||
|
|
||||||
Concrete built-in plugins live in this package.
|
Bundled runtime plugins now live under ``plugins/``. This package remains only
|
||||||
The public registry lives in ProviderCore.registry.
|
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 API.requests_client import get_requests_session
|
||||||
from ProviderCore.base import SearchResult
|
from ProviderCore.base import SearchResult
|
||||||
try:
|
try:
|
||||||
from Provider.Tidal import Tidal
|
from plugins.tidal import Tidal
|
||||||
except ImportError: # pragma: no cover - optional
|
except ImportError: # pragma: no cover - optional
|
||||||
Tidal = None
|
Tidal = None
|
||||||
from API.Tidal import (
|
from API.Tidal import (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Plugin core modules.
|
"""Plugin core modules.
|
||||||
|
|
||||||
This package contains the plugin framework (base types, registry, and shared
|
This package contains the plugin framework (base types, registry, and shared
|
||||||
helpers). Built-in plugins continue to live in the `Provider/` package for
|
helpers). Bundled plugins live in the `plugins/` package.
|
||||||
backward compatibility.
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
+28
-10
@@ -1,8 +1,8 @@
|
|||||||
"""Plugin registry.
|
"""Plugin registry.
|
||||||
|
|
||||||
Built-in plugin implementations live in the ``Provider`` package. External user
|
Bundled plugins live in the ``plugins`` package. Additional drop-in plugins can
|
||||||
plugins can be dropped into a repo-local ``plugins/`` directory or discovered
|
be discovered from external plugin directories configured via the environment or
|
||||||
via environment-configured plugin paths.
|
current working directory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -132,9 +132,23 @@ class ProviderRegistry:
|
|||||||
self._lookup: Dict[str, ProviderInfo] = {}
|
self._lookup: Dict[str, ProviderInfo] = {}
|
||||||
self._modules: set[str] = set()
|
self._modules: set[str] = set()
|
||||||
self._external_modules: set[str] = set()
|
self._external_modules: set[str] = set()
|
||||||
|
self._builtin_package_dirs: Tuple[Path, ...] = ()
|
||||||
self._discovered = False
|
self._discovered = False
|
||||||
self._external_dirs_scanned = 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:
|
def _normalize(self, value: Any) -> str:
|
||||||
return str(value or "").strip().lower()
|
return str(value or "").strip().lower()
|
||||||
|
|
||||||
@@ -241,6 +255,8 @@ class ProviderRegistry:
|
|||||||
self._external_dirs_scanned = True
|
self._external_dirs_scanned = True
|
||||||
|
|
||||||
for plugin_dir in _iter_external_plugin_dirs():
|
for plugin_dir in _iter_external_plugin_dirs():
|
||||||
|
if self._is_builtin_package_dir(plugin_dir):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
plugin_dir_str = str(plugin_dir)
|
plugin_dir_str = str(plugin_dir)
|
||||||
if plugin_dir_str and plugin_dir_str not in sys.path:
|
if plugin_dir_str and plugin_dir_str not in sys.path:
|
||||||
@@ -287,6 +303,14 @@ class ProviderRegistry:
|
|||||||
|
|
||||||
self._register_module(package)
|
self._register_module(package)
|
||||||
package_path = getattr(package, "__path__", None)
|
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:
|
if not package_path:
|
||||||
self._discover_external_plugins()
|
self._discover_external_plugins()
|
||||||
return
|
return
|
||||||
@@ -294,8 +318,6 @@ class ProviderRegistry:
|
|||||||
for finder, module_name, _ in pkgutil.iter_modules(package_path):
|
for finder, module_name, _ in pkgutil.iter_modules(package_path):
|
||||||
if module_name.startswith("_"):
|
if module_name.startswith("_"):
|
||||||
continue
|
continue
|
||||||
if module_name.strip().lower() == "hifi":
|
|
||||||
continue
|
|
||||||
module_path = f"{self.package_name}.{module_name}"
|
module_path = f"{self.package_name}.{module_name}"
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(module_path)
|
module = importlib.import_module(module_path)
|
||||||
@@ -318,10 +340,6 @@ class ProviderRegistry:
|
|||||||
if not name or not self.package_name:
|
if not name or not self.package_name:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Keep behavior consistent with full discovery (which skips hifi).
|
|
||||||
if name == "hifi":
|
|
||||||
return
|
|
||||||
|
|
||||||
candidates: List[str] = [name]
|
candidates: List[str] = [name]
|
||||||
if "-" in name:
|
if "-" in name:
|
||||||
candidates.append(name.replace("-", "_"))
|
candidates.append(name.replace("-", "_"))
|
||||||
@@ -386,7 +404,7 @@ class ProviderRegistry:
|
|||||||
_walk(sub)
|
_walk(sub)
|
||||||
_walk(Provider)
|
_walk(Provider)
|
||||||
|
|
||||||
REGISTRY = ProviderRegistry("Provider")
|
REGISTRY = ProviderRegistry("plugins")
|
||||||
PLUGIN_REGISTRY = REGISTRY
|
PLUGIN_REGISTRY = REGISTRY
|
||||||
PluginInfo = ProviderInfo
|
PluginInfo = ProviderInfo
|
||||||
PluginRegistry = ProviderRegistry
|
PluginRegistry = ProviderRegistry
|
||||||
|
|||||||
+82
-3
@@ -93,7 +93,10 @@ def clear_config_cache() -> None:
|
|||||||
|
|
||||||
def _log_config_load_summary(config: Dict[str, Any]) -> None:
|
def _log_config_load_summary(config: Dict[str, Any]) -> None:
|
||||||
try:
|
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 []
|
stores = list(config.get("store", {}).keys()) if isinstance(config.get("store"), dict) else []
|
||||||
mtime = None
|
mtime = None
|
||||||
try:
|
try:
|
||||||
@@ -101,7 +104,7 @@ def _log_config_load_summary(config: Dict[str, Any]) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
mtime = None
|
mtime = None
|
||||||
summary = (
|
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}"
|
f"stores={len(stores)} ({', '.join(stores[:10])}{'...' if len(stores)>10 else ''}), mtime={mtime}"
|
||||||
)
|
)
|
||||||
log(summary)
|
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]:
|
def get_provider_block(config: Dict[str, Any], name: str) -> Dict[str, Any]:
|
||||||
|
_normalize_plugin_config_aliases(config)
|
||||||
provider_cfg = config.get("provider")
|
provider_cfg = config.get("provider")
|
||||||
if not isinstance(provider_cfg, dict):
|
if not isinstance(provider_cfg, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -518,7 +522,7 @@ def resolve_cookies_path(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("resolve_cookies_path: failed to read tool.ytdlp cookies: %s", exc, exc_info=True)
|
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:
|
for value in values:
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
@@ -528,6 +532,10 @@ def resolve_cookies_path(
|
|||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return candidate
|
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"
|
default_path = base_dir / "cookies.txt"
|
||||||
if default_path.is_file():
|
if default_path.is_file():
|
||||||
return default_path
|
return default_path
|
||||||
@@ -547,6 +555,70 @@ def _normalize_provider_name(value: Any) -> Optional[str]:
|
|||||||
candidate = str(value or "").strip().lower()
|
candidate = str(value or "").strip().lower()
|
||||||
return candidate if candidate else None
|
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]:
|
def _extract_api_key(value: Any) -> Optional[str]:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for key in ("api_key", "API_KEY", "apikey", "APIKEY"):
|
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):
|
if not isinstance(config, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_normalize_plugin_config_aliases(config)
|
||||||
|
|
||||||
providers = config.get("provider")
|
providers = config.get("provider")
|
||||||
if not isinstance(providers, dict):
|
if not isinstance(providers, dict):
|
||||||
providers = {}
|
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]:
|
def _flatten_config_entries(config: Dict[str, Any]) -> Dict[Tuple[str, str, str, str], Any]:
|
||||||
entries: 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():
|
for key, value in config.items():
|
||||||
|
if key == 'plugin':
|
||||||
|
continue
|
||||||
if key in ('store', 'provider', 'tool') and isinstance(value, dict):
|
if key in ('store', 'provider', 'tool') and isinstance(value, dict):
|
||||||
for subtype, instances in value.items():
|
for subtype, instances in value.items():
|
||||||
if not isinstance(instances, dict):
|
if not isinstance(instances, dict):
|
||||||
@@ -655,6 +732,7 @@ def load_config(*, emit_summary: bool = True) -> Dict[str, Any]:
|
|||||||
# Load strictly from database
|
# Load strictly from database
|
||||||
db_config = get_config_all()
|
db_config = get_config_all()
|
||||||
if db_config:
|
if db_config:
|
||||||
|
_normalize_plugin_config_aliases(db_config)
|
||||||
_sync_alldebrid_api_key(db_config)
|
_sync_alldebrid_api_key(db_config)
|
||||||
_CONFIG_CACHE = db_config
|
_CONFIG_CACHE = db_config
|
||||||
_LAST_SAVED_CONFIG = deepcopy(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:
|
def save_config(config: Dict[str, Any]) -> int:
|
||||||
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
|
global _CONFIG_CACHE, _LAST_SAVED_CONFIG
|
||||||
|
_normalize_plugin_config_aliases(config)
|
||||||
_sync_alldebrid_api_key(config)
|
_sync_alldebrid_api_key(config)
|
||||||
|
|
||||||
# Acquire cross-process save lock to avoid concurrent saves from different
|
# 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}'")
|
return _call_schema(cls, f"store '{store_type}'")
|
||||||
|
|
||||||
|
|
||||||
def get_provider_schema(provider_name: str) -> List[ConfigField]:
|
def get_plugin_schema(plugin_name: str) -> List[ConfigField]:
|
||||||
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:
|
if plugin_class is None:
|
||||||
return []
|
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]:
|
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()
|
normalized_name = str(item_name or "").strip()
|
||||||
if normalized_type.startswith("store-"):
|
if normalized_type.startswith("store-"):
|
||||||
return get_store_schema(normalized_type.replace("store-", "", 1))
|
return get_store_schema(normalized_type.replace("store-", "", 1))
|
||||||
if normalized_type == "provider":
|
if normalized_type in {"provider", "plugin"}:
|
||||||
return get_provider_schema(normalized_name)
|
return get_plugin_schema(normalized_name)
|
||||||
if normalized_type == "tool":
|
if normalized_type == "tool":
|
||||||
return get_tool_schema(normalized_name)
|
return get_tool_schema(normalized_name)
|
||||||
return []
|
return []
|
||||||
@@ -126,25 +130,29 @@ def build_default_store_config(store_type: str, instance_name: str) -> Dict[str,
|
|||||||
return config
|
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] = {}
|
config: Dict[str, Any] = {}
|
||||||
schema = get_provider_schema(provider_name)
|
schema = get_plugin_schema(plugin_name)
|
||||||
if schema:
|
if schema:
|
||||||
for field in schema:
|
for field in schema:
|
||||||
config[field["key"]] = field.get("default", "")
|
config[field["key"]] = field.get("default", "")
|
||||||
return config
|
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:
|
if plugin_class is None:
|
||||||
return config
|
return config
|
||||||
try:
|
try:
|
||||||
for required_key in plugin_class.required_config_keys():
|
for required_key in plugin_class.required_config_keys():
|
||||||
config[str(required_key)] = ""
|
config[str(required_key)] = ""
|
||||||
except Exception:
|
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
|
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]:
|
def build_default_tool_config(tool_name: str) -> Dict[str, Any]:
|
||||||
config: Dict[str, Any] = {}
|
config: Dict[str, Any] = {}
|
||||||
for field in get_tool_schema(tool_name):
|
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:
|
if cls is not None:
|
||||||
for required_key in _required_keys_for(cls):
|
for required_key in _required_keys_for(cls):
|
||||||
_add_key(required_key)
|
_add_key(required_key)
|
||||||
elif normalized_type == "provider":
|
elif normalized_type in {"provider", "plugin"}:
|
||||||
plugin_class = get_plugin_class(normalized_name)
|
plugin_class = get_plugin_class(normalized_name)
|
||||||
if plugin_class is not None:
|
if plugin_class is not None:
|
||||||
try:
|
try:
|
||||||
for required_key in plugin_class.required_config_keys():
|
for required_key in plugin_class.required_config_keys():
|
||||||
_add_key(required_key)
|
_add_key(required_key)
|
||||||
except Exception:
|
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
|
return required_keys
|
||||||
|
|
||||||
@@ -199,14 +207,18 @@ def get_configurable_store_types() -> List[str]:
|
|||||||
return sorted(set(options))
|
return sorted(set(options))
|
||||||
|
|
||||||
|
|
||||||
def get_configurable_provider_types() -> List[str]:
|
def get_configurable_plugin_types() -> List[str]:
|
||||||
options: List[str] = []
|
options: List[str] = []
|
||||||
for provider_name in list_plugins().keys():
|
for plugin_name in list_plugins().keys():
|
||||||
if get_provider_schema(provider_name):
|
if get_plugin_schema(plugin_name):
|
||||||
options.append(str(provider_name))
|
options.append(str(plugin_name))
|
||||||
return sorted(set(options))
|
return sorted(set(options))
|
||||||
|
|
||||||
|
|
||||||
|
def get_configurable_provider_types() -> List[str]:
|
||||||
|
return get_configurable_plugin_types()
|
||||||
|
|
||||||
|
|
||||||
def get_configurable_tool_types() -> List[str]:
|
def get_configurable_tool_types() -> List[str]:
|
||||||
options: List[str] = []
|
options: List[str] = []
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -616,7 +616,10 @@ class PipelineHubApp(App):
|
|||||||
# Provide a visible startup summary of configured plugins/stores for debugging
|
# Provide a visible startup summary of configured plugins/stores for debugging
|
||||||
try:
|
try:
|
||||||
cfg = load_config() or {}
|
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 []
|
stores = list(cfg.get("store", {}).keys()) if isinstance(cfg.get("store"), dict) else []
|
||||||
prov_display = ", ".join(provs[:10]) + ("..." if len(provs) > 10 else "")
|
prov_display = ", ".join(provs[:10]) + ("..." if len(provs) > 10 else "")
|
||||||
store_display = ", ".join(stores[:10]) + ("..." if len(stores) > 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 SYS.logger import log, debug
|
||||||
|
|
||||||
from Provider.metadata_provider import (
|
from plugins.metadata_provider import (
|
||||||
get_default_subject_scrape_provider,
|
get_default_subject_scrape_provider,
|
||||||
get_metadata_provider,
|
get_metadata_provider,
|
||||||
get_metadata_provider_for_url,
|
get_metadata_provider_for_url,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def add_startup_check(
|
|||||||
row = table.add_row()
|
row = table.add_row()
|
||||||
row.add_column("STATUS", upper_text(status))
|
row.add_column("STATUS", upper_text(status))
|
||||||
row.add_column("NAME", upper_text(name))
|
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("STORE", upper_text(store or ""))
|
||||||
row.add_column("FILES", "" if files is None else str(files))
|
row.add_column("FILES", "" if files is None else str(files))
|
||||||
row.add_column("DETAIL", upper_text(detail or ""))
|
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:
|
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):
|
if not isinstance(provider_cfg, dict):
|
||||||
return False
|
return False
|
||||||
block = provider_cfg.get(str(name).strip().lower())
|
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:
|
def provider_display_name(key: str) -> str:
|
||||||
label = (key or "").strip()
|
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]:
|
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]]:
|
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:
|
if not isinstance(provider_cfg, dict) or not provider_cfg:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ from cmdnat._status_shared import (
|
|||||||
|
|
||||||
CMDLET = Cmdlet(
|
CMDLET = Cmdlet(
|
||||||
name=".status",
|
name=".status",
|
||||||
summary="Check and display service/provider status",
|
summary="Check and display service/plugin status",
|
||||||
usage=".status",
|
usage=".status",
|
||||||
arg=[],
|
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 `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.
|
- **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.
|
- **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)
|
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
|
```py
|
||||||
from SYS.result_table_adapters import get_provider
|
from SYS.result_table_adapters import get_provider
|
||||||
from Provider import example_provider
|
from plugins import example_provider
|
||||||
|
|
||||||
|
|
||||||
def test_example_provider_registration():
|
def test_example_provider_registration():
|
||||||
@@ -132,10 +132,10 @@ def test_example_provider_registration():
|
|||||||
|
|
||||||
## References & examples
|
## References & examples
|
||||||
|
|
||||||
- Read `Provider/example_provider.py` for a compact example of a strict adapter and dynamic columns.
|
- Read `plugins/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/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.
|
- 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
|
## 🎯 Purpose
|
||||||
This guide describes how to write, test, and register a plugin so the application can discover and use it as a pluggable component.
|
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
|
## 📦 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`.
|
- 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.*`.
|
- Plugin authors should import from `ProviderCore.*`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -146,19 +147,19 @@ pytest -q
|
|||||||
- [ ] Provide `URL` / `URL_DOMAINS` or `url_patterns()` for routing
|
- [ ] Provide `URL` / `URL_DOMAINS` or `url_patterns()` for routing
|
||||||
- [ ] Add `download()` or `download_url()` for piped/passed URL downloads
|
- [ ] Add `download()` or `download_url()` for piped/passed URL downloads
|
||||||
- [ ] Add tests under `tests/`
|
- [ ] 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
|
## 🔗 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.
|
- Check `API/` helpers for HTTP and debrid clients.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
If you'd like, I can:
|
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`).
|
- 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
|
What plugins must implement
|
||||||
- An adapter that yields `ResultModel` objects (breaking API).
|
- 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
|
Implementation notes
|
||||||
- `plugin-table` emits dicts like `{ 'title': ..., 'path': ..., 'metadata': ..., '_selection_args': [...] }`.
|
- `plugin-table` emits dicts like `{ 'title': ..., 'path': ..., 'metadata': ..., '_selection_args': [...] }`.
|
||||||
|
|||||||
+18
-6
@@ -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 repo root
|
||||||
- `plugins/` in the current working directory
|
- `plugins/` in the current working directory
|
||||||
- Any directory listed in `MM_PLUGIN_PATH`
|
- Any directory listed in `MM_PLUGIN_PATH`
|
||||||
- Any directory listed in `MEDEIA_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`.
|
- A plugin can be a single `.py` file or a package directory with `__init__.py`.
|
||||||
- Define a class that inherits from `ProviderCore.base.Provider`.
|
- Define a class that inherits from `ProviderCore.base.Provider`.
|
||||||
- Give it a stable name using `PLUGIN_NAME` or the class name.
|
- Give it a stable name using `PLUGIN_NAME` or the class name.
|
||||||
@@ -35,5 +49,3 @@ class MyPlugin(Provider):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
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 ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
|
||||||
from SYS.field_access import get_field
|
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 import pipeline as pipeline_context
|
||||||
from SYS.logger import debug, debug_panel, log
|
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]]:
|
def _download_via_openlibrary(self, url: str, output_dir: Path) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
from Provider.openlibrary import OpenLibrary
|
from plugins.openlibrary import OpenLibrary
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"[internetarchive] OpenLibrary borrow helper unavailable: {exc}", file=sys.stderr)
|
log(f"[internetarchive] OpenLibrary borrow helper unavailable: {exc}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
@@ -766,7 +766,7 @@ class InternetArchive(Provider):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from Provider.openlibrary import OpenLibrary
|
from plugins.openlibrary import OpenLibrary
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"[internetarchive] OpenLibrary auth helper unavailable: {exc}", file=sys.stderr)
|
log(f"[internetarchive] OpenLibrary auth helper unavailable: {exc}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ def _enrich_book_tags_from_isbn(isbn: str,
|
|||||||
|
|
||||||
# 2) isbnsearch metadata provider fallback.
|
# 2) isbnsearch metadata provider fallback.
|
||||||
try:
|
try:
|
||||||
from Provider.metadata_provider import get_metadata_provider
|
from plugins.metadata_provider import get_metadata_provider
|
||||||
|
|
||||||
provider = get_metadata_provider("isbnsearch",
|
provider = get_metadata_provider("isbnsearch",
|
||||||
config or {})
|
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.utils import sanitize_filename
|
||||||
from SYS.cli_syntax import get_field, get_free_text, parse_query
|
from SYS.cli_syntax import get_field, get_free_text, parse_query
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
from Provider.metadata_provider import (
|
from plugins.metadata_provider import (
|
||||||
archive_item_metadata_to_tags,
|
archive_item_metadata_to_tags,
|
||||||
fetch_archive_item_metadata,
|
fetch_archive_item_metadata,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from API.Tidal import (
|
|||||||
)
|
)
|
||||||
from ProviderCore.base import Provider, SearchResult
|
from ProviderCore.base import Provider, SearchResult
|
||||||
from SYS.field_access import get_field
|
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 import pipeline as pipeline_context
|
||||||
from SYS.logger import debug, log
|
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