This commit is contained in:
2026-02-11 18:16:07 -08:00
parent cc715e1fef
commit 1d0de1118b
27 changed files with 1167 additions and 1075 deletions

View File

@@ -141,6 +141,10 @@ class Provider(ABC):
# Used for dynamically generating config panels (e.g., missing credentials).
REQUIRED_CONFIG_KEYS: Sequence[str] = ()
# Some providers implement `upload()` but are not intended to be used as
# generic "file host" providers via `add-file -provider ...`.
EXPOSE_AS_FILE_PROVIDER: bool = True
def __init__(self, config: Optional[Dict[str, Any]] = None):
self.config = config or {}
# Prioritize explicit NAME property for the instance name
@@ -233,6 +237,35 @@ class Provider(ABC):
normalized = str(query or "").strip()
return normalized, {}
def postprocess_search_results(
self,
*,
query: str,
results: List[SearchResult],
filters: Optional[Dict[str, Any]] = None,
limit: int = 50,
table_type: str = "",
table_meta: Optional[Dict[str, Any]] = None,
) -> Tuple[List[SearchResult], Optional[str], Optional[Dict[str, Any]]]:
"""Optional hook for provider-specific result transforms.
Cmdlets should avoid hardcoding provider quirks. Providers can override
this to:
- expand/replace result sets (e.g., artist -> albums)
- override the table type
- override table metadata
Returns:
(results, table_type_override, table_meta_override)
"""
_ = query
_ = filters
_ = limit
_ = table_type
_ = table_meta
return results, None, None
# Standard lifecycle/auth hook.
def login(self, **_kwargs: Any) -> bool:
return True

View File

@@ -7,6 +7,7 @@ for those plugins.
from __future__ import annotations
from functools import lru_cache
import importlib
import pkgutil
import sys
@@ -18,7 +19,18 @@ from urllib.parse import urlparse
from SYS.logger import log, debug
from ProviderCore.base import FileProvider, Provider, SearchProvider, SearchResult
from Provider.soulseek import download_soulseek_file
def download_soulseek_file(*args: Any, **kwargs: Any) -> Any:
"""Lazy proxy for the soulseek downloader.
Importing the provider modules can be expensive; keeping this lazy avoids
paying that cost at registry import time.
"""
from Provider.soulseek import download_soulseek_file as _download
return _download(*args, **kwargs)
@dataclass(frozen=True)
@@ -36,7 +48,11 @@ class ProviderInfo:
@property
def supports_upload(self) -> bool:
return self.provider_class.upload is not Provider.upload
try:
exposed = bool(getattr(self.provider_class, "EXPOSE_AS_FILE_PROVIDER", True))
except Exception:
exposed = True
return exposed and (self.provider_class.upload is not Provider.upload)
class ProviderRegistry:
@@ -136,8 +152,8 @@ class ProviderRegistry:
return
self._modules.add(module_name)
for attr in dir(module):
candidate = getattr(module, attr)
# Iterate module dict directly (faster than dir()+getattr()).
for candidate in vars(module).values():
if not isinstance(candidate, type):
continue
if not issubclass(candidate, Provider):
@@ -182,11 +198,64 @@ class ProviderRegistry:
continue
self._register_module(module)
# Pick up any Provider subclasses loaded via other mechanisms.
self._sync_subclasses()
def _try_import_for_name(self, normalized_name: str) -> None:
"""Best-effort import for a single provider module.
This avoids importing every provider module when the caller only needs
one provider (common for CLI usage).
"""
name = str(normalized_name or "").strip().lower()
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("-", "_"))
if "." in name:
candidates.append(name.split(".", 1)[0])
for mod_name in candidates:
if not mod_name:
continue
module_path = f"{self.package_name}.{mod_name}"
if module_path in self._modules:
continue
try:
module = importlib.import_module(module_path)
except Exception:
continue
self._register_module(module)
# Pick up subclasses in case the module registers indirectly.
self._sync_subclasses()
return
def get(self, name: str) -> Optional[ProviderInfo]:
self.discover()
if not name:
return None
return self._lookup.get(self._normalize(name))
normalized = self._normalize(name)
info = self._lookup.get(normalized)
if info is not None:
return info
# If we haven't done a full discovery yet, try importing just the
# module that matches the requested name.
if not self._discovered:
self._try_import_for_name(normalized)
info = self._lookup.get(normalized)
if info is not None:
return info
# Fall back to full package scan.
self.discover()
return self._lookup.get(normalized)
def iter_providers(self) -> Iterable[ProviderInfo]:
self.discover()
@@ -210,8 +279,14 @@ class ProviderRegistry:
_walk(Provider)
REGISTRY = ProviderRegistry("Provider")
REGISTRY.discover()
REGISTRY._sync_subclasses()
@lru_cache(maxsize=512)
def _provider_url_patterns(provider_class: Type[Provider]) -> Sequence[str]:
try:
return list(provider_class.url_patterns())
except Exception:
return []
def register_provider(
@@ -268,14 +343,67 @@ def _supports_search(provider: Provider) -> bool:
def _supports_upload(provider: Provider) -> bool:
return provider.__class__.upload is not Provider.upload
def _provider_url_patterns(provider_class: Type[Provider]) -> Sequence[str]:
try:
return list(provider_class.url_patterns())
exposed = bool(getattr(provider.__class__, "EXPOSE_AS_FILE_PROVIDER", True))
except Exception:
return []
exposed = True
return exposed and (provider.__class__.upload is not Provider.upload)
def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]:
if entry is None:
return None
if isinstance(entry, dict):
value = entry.get("value")
text = entry.get("text") or entry.get("label") or value
aliases = entry.get("alias") or entry.get("aliases") or []
value_str = str(value) if value is not None else (str(text) if text is not None else None)
text_str = str(text) if text is not None else value_str
if not value_str or not text_str:
return None
alias_list = [str(a) for a in aliases if a is not None]
return {"value": value_str, "text": text_str, "aliases": alias_list}
return {"value": str(entry), "text": str(entry), "aliases": []}
def _collect_inline_choice_mapping(provider: Provider) -> Dict[str, List[Dict[str, Any]]]:
mapping: Dict[str, List[Dict[str, Any]]] = {}
base = getattr(provider, "QUERY_ARG_CHOICES", None)
if not isinstance(base, dict):
base = getattr(provider, "INLINE_QUERY_FIELD_CHOICES", None)
def _merge_from(obj: Any) -> None:
if not isinstance(obj, dict):
return
for key, value in obj.items():
normalized: List[Dict[str, Any]] = []
seq = value
try:
if callable(seq):
seq = seq()
except Exception:
seq = value
if isinstance(seq, dict):
seq = seq.get("choices") or seq.get("values") or seq
if isinstance(seq, (list, tuple, set)):
for entry in seq:
n = _normalize_choice_entry(entry)
if n:
normalized.append(n)
if normalized:
mapping[str(key).strip().lower()] = normalized
_merge_from(base)
try:
fn = getattr(provider, "inline_query_field_choices", None)
if callable(fn):
_merge_from(fn())
except Exception:
pass
return mapping
def get_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
@@ -422,7 +550,6 @@ def provider_inline_query_choices(
Providers can expose a mapping via ``QUERY_ARG_CHOICES`` (preferred) or
``INLINE_QUERY_FIELD_CHOICES`` / ``inline_query_field_choices()``. The helper
keeps completion logic simple and reusable.
This helper keeps completion logic simple and reusable.
"""
pname = str(provider_name or "").strip().lower()
@@ -436,73 +563,8 @@ def provider_inline_query_choices(
if provider is None:
return []
def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]:
if entry is None:
return None
if isinstance(entry, dict):
value = entry.get("value")
text = entry.get("text") or entry.get("label") or value
aliases = entry.get("alias") or entry.get("aliases") or []
value_str = str(value) if value is not None else (str(text) if text is not None else None)
text_str = str(text) if text is not None else value_str
if not value_str or not text_str:
return None
alias_list = [str(a) for a in aliases if a is not None]
return {"value": value_str, "text": text_str, "aliases": alias_list}
# string/other primitives
return {"value": str(entry), "text": str(entry), "aliases": []}
def _collect_mapping(p) -> Dict[str, List[Dict[str, Any]]]:
mapping: Dict[str, List[Dict[str, Any]]] = {}
base = getattr(p, "QUERY_ARG_CHOICES", None)
if not isinstance(base, dict):
base = getattr(p, "INLINE_QUERY_FIELD_CHOICES", None)
if isinstance(base, dict):
for k, v in base.items():
normalized: List[Dict[str, Any]] = []
seq = v
try:
if callable(seq):
seq = seq()
except Exception:
seq = v
if isinstance(seq, dict):
seq = seq.get("choices") or seq.get("values") or seq
if isinstance(seq, (list, tuple, set)):
for entry in seq:
n = _normalize_choice_entry(entry)
if n:
normalized.append(n)
if normalized:
mapping[str(k).strip().lower()] = normalized
try:
fn = getattr(p, "inline_query_field_choices", None)
if callable(fn):
extra = fn()
if isinstance(extra, dict):
for k, v in extra.items():
normalized: List[Dict[str, Any]] = []
seq = v
try:
if callable(seq):
seq = seq()
except Exception:
seq = v
if isinstance(seq, dict):
seq = seq.get("choices") or seq.get("values") or seq
if isinstance(seq, (list, tuple, set)):
for entry in seq:
n = _normalize_choice_entry(entry)
if n:
normalized.append(n)
if normalized:
mapping[str(k).strip().lower()] = normalized
except Exception:
pass
return mapping
try:
mapping = _collect_mapping(provider)
mapping = _collect_inline_choice_mapping(provider)
if not mapping:
return []
@@ -556,7 +618,7 @@ def resolve_inline_filters(
if not inline_args:
return filters
mapping = _collect_mapping(provider)
mapping = _collect_inline_choice_mapping(provider)
transforms = field_transforms or {}
for raw_key, raw_val in inline_args.items():