f
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user