You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/download_plugins/registry.py

199 lines
8.9 KiB

"""Plugin registry — single source of truth for which download
sources exist, what their canonical names are, and which client
class implements each.
Replaces the orchestrator's hardcoded ``[self.soulseek,
self.youtube, self.tidal, ...]`` lists and ``source_map`` dicts
that historically had to be touched in 6+ places to add a source.
With the registry:
- One ``register()`` call adds a source to every dispatch path.
- Iteration helpers replace hand-maintained lists.
- The orchestrator stays oblivious to source-specific quirks.
- Adding Usenet (planned) becomes a one-line registry entry plus
the new client class — no orchestrator changes.
This is the foundation step. Subsequent commits move shared logic
(thread workers, search query normalization, post-processing
context building) out of the orchestrator and the per-source
clients into helpers the registry exposes.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, Dict, Iterator, List, Optional, Tuple
from utils.logging_config import get_logger
from core.download_plugins.base import DownloadSourcePlugin
# Eager client imports — keep the import-order behavior the orchestrator
# had before this refactor. Some integration tests inject mock modules
# into ``sys.modules`` at collection time (see
# ``tests/test_tidal_search_shortening.py``); making the registry's
# factories lazy-import would cause tidalapi-dependent code to bind to
# whichever ``tidalapi`` object happens to be in ``sys.modules`` at the
# moment ``DownloadOrchestrator()`` is constructed — which is later
# than the legacy module-top imports here. Importing everything at
# registry-load time pins the bindings the same way the legacy
# orchestrator did.
from core.amazon_download_client import AmazonDownloadClient
from core.deezer_download_client import DeezerDownloadClient
from core.download_plugins.torrent import TorrentDownloadPlugin
from core.download_plugins.usenet import UsenetDownloadPlugin
from core.hifi_client import HiFiClient
from core.lidarr_download_client import LidarrDownloadClient
from core.qobuz_client import QobuzClient
from core.soulseek_client import SoulseekClient
from core.soundcloud_client import SoundcloudClient
from core.tidal_download_client import TidalDownloadClient
from core.youtube_client import YouTubeClient
logger = get_logger("download_plugins.registry")
@dataclass(frozen=True)
class PluginSpec:
"""Static descriptor for a download source. The ``factory`` is
a zero-arg callable that builds the client instance — kept as a
callable rather than a class so each source can do its own
setup (e.g. SoulseekClient calls ``_setup_client`` after init,
Deezer reads ARL from config). ``aliases`` lets the registry
accept multiple historical names (e.g. ``deezer_dl`` is the
legacy alias for ``deezer``)."""
name: str
factory: Callable[[], DownloadSourcePlugin]
display_name: str
aliases: Tuple[str, ...] = field(default_factory=tuple)
class DownloadPluginRegistry:
"""Holds the live plugin instances + name → instance lookup.
Construction is two-phase:
1. Specs are registered (cheap — just stores callable refs).
2. ``initialize()`` calls each factory once and stores the
resulting client. Failures are caught and logged so one
broken source doesn't take down the orchestrator (mirrors
the existing ``_safe_init`` behavior).
Iteration helpers (``all_plugins``, ``configured_plugins``)
replace the hand-maintained lists scattered across the
orchestrator's ``get_all_downloads``, ``cancel_all_downloads``,
etc. so adding a source touches the registry alone.
"""
def __init__(self) -> None:
self._specs: Dict[str, PluginSpec] = {}
self._instances: Dict[str, Optional[DownloadSourcePlugin]] = {}
self._init_failures: List[str] = []
def register(self, spec: PluginSpec) -> None:
"""Register a plugin spec under its canonical name + each alias.
Aliases all resolve to the same instance after ``initialize``."""
if spec.name in self._specs:
raise ValueError(f"Plugin already registered: {spec.name}")
self._specs[spec.name] = spec
def initialize(self) -> None:
"""Build every registered plugin's instance. Failures captured
in ``init_failures`` and the slot is set to None so the
orchestrator can skip unavailable sources without crashing."""
for spec in self._specs.values():
try:
instance = spec.factory()
self._instances[spec.name] = instance
except Exception as exc:
logger.error("%s download client failed to initialize: %s", spec.display_name, exc)
self._init_failures.append(spec.display_name)
self._instances[spec.name] = None
@property
def init_failures(self) -> List[str]:
return list(self._init_failures)
def get(self, name: str) -> Optional[DownloadSourcePlugin]:
"""Resolve a name (or alias) to its plugin instance, or
None if the source failed to initialize / isn't registered."""
if not name:
return None
# Direct hit
if name in self._instances:
return self._instances[name]
# Alias lookup
for spec in self._specs.values():
if name in spec.aliases:
return self._instances.get(spec.name)
return None
def get_spec(self, name: str) -> Optional[PluginSpec]:
if name in self._specs:
return self._specs[name]
for spec in self._specs.values():
if name in spec.aliases:
return spec
return None
def display_name(self, name: str) -> str:
spec = self.get_spec(name)
return spec.display_name if spec else name
def names(self) -> List[str]:
"""Canonical names of every registered source (regardless of
whether it initialized successfully)."""
return list(self._specs.keys())
def all_plugins(self) -> Iterator[Tuple[str, DownloadSourcePlugin]]:
"""Yield (name, plugin) for every successfully-initialized
plugin. Replaces the orchestrator's hand-maintained client
lists in get_all_downloads / cancel_all_downloads / etc."""
for name, instance in self._instances.items():
if instance is not None:
yield name, instance
def configured_plugins(self) -> Iterator[Tuple[str, DownloadSourcePlugin]]:
"""Yield (name, plugin) for every initialized AND configured
plugin. Useful for hybrid mode and any operation that should
skip sources the user hasn't set up."""
for name, instance in self.all_plugins():
try:
if instance.is_configured():
yield name, instance
except Exception:
continue
def build_default_registry() -> DownloadPluginRegistry:
"""Construct the registry with SoulSync's eight built-in download
sources. Called once during orchestrator init.
Adding a new source (Usenet, etc.) means adding one ``register``
call here — no orchestrator changes required.
The factory itself is just the class constructor — clients are
imported eagerly at the top of this module so they bind to the
real third-party libs (tidalapi, etc.) at import time, not at
factory-call time. See the import-block comment above for why.
"""
registry = DownloadPluginRegistry()
registry.register(PluginSpec(name='amazon', factory=AmazonDownloadClient, display_name='Amazon Music'))
registry.register(PluginSpec(name='soulseek', factory=SoulseekClient, display_name='Soulseek'))
registry.register(PluginSpec(name='youtube', factory=YouTubeClient, display_name='YouTube'))
registry.register(PluginSpec(name='tidal', factory=TidalDownloadClient, display_name='Tidal'))
registry.register(PluginSpec(name='qobuz', factory=QobuzClient, display_name='Qobuz'))
registry.register(PluginSpec(name='hifi', factory=HiFiClient, display_name='HiFi'))
# 'deezer_dl' is the legacy name used in config + per-source dispatch
# strings (e.g. orchestrator's ``source_map``). Canonical name is
# ``deezer`` so future-facing code reads naturally.
registry.register(PluginSpec(name='deezer', factory=DeezerDownloadClient, display_name='Deezer',
aliases=('deezer_dl',)))
registry.register(PluginSpec(name='lidarr', factory=LidarrDownloadClient, display_name='Lidarr'))
registry.register(PluginSpec(name='soundcloud',factory=SoundcloudClient, display_name='SoundCloud'))
registry.register(PluginSpec(name='torrent', factory=TorrentDownloadPlugin, display_name='Torrent (Prowlarr)'))
registry.register(PluginSpec(name='usenet', factory=UsenetDownloadPlugin, display_name='Usenet (Prowlarr)'))
return registry