soulsync/core/download_plugins/registry.py
Broque Thomas 080b1aa1b4 feat(downloads): wire torrent + usenet as live download sources
The payoff for the previous five commits. Two new download
sources slot into the existing DownloadSourcePlugin contract,
backed by Prowlarr (search) + the torrent or usenet client
adapter (transfer) + archive_pipeline (post-extract walk). They
appear in the Download Source dropdown next to Soulseek / Tidal /
Lidarr / etc. and also participate in hybrid mode.

Pipeline (both plugins, mirror shape):
1. search(query) → ProwlarrClient.search filtered to the right
   protocol, projected into TrackResult / AlbumResult shapes the
   existing search UI already speaks. Filename field encodes the
   indexer's download URL (or magnet URI for torrents) so
   download() can recover it later.
2. download() → decodes URL, hands it to the active adapter
   (qBittorrent / Transmission / Deluge for torrent; SABnzbd /
   NZBGet for usenet), spawns a background poll thread that
   tracks progress + reports the adapter-reported save_path.
3. On 'seeding' / 'completed' → archive_pipeline walks the save
   directory, extracts any archives the downloader didn't
   already unpack, picks the first audio file as the canonical
   file_path. Matches the Lidarr client's single-track-pick
   contract — picking which specific track to import happens in
   post-processing.

- core/download_plugins/torrent.py: TorrentDownloadPlugin +
  module-level helpers (_decode_filename, _guess_quality_from_title,
  _parse_indexer_id_filter, _adapter_state_to_display, _row_to_status).
  Uses get_active_torrent_adapter() so a settings change to the
  client type takes effect without restart.
- core/download_plugins/usenet.py: UsenetDownloadPlugin —
  parallel shape, reuses the torrent module's helpers. Different
  enough states (no seeding, no magnet) to warrant its own class
  but cheap to keep in lockstep.
- core/download_plugins/registry.py: register 'torrent' and
  'usenet' plugins. Per the registry docstring this is the only
  wiring point needed — the orchestrator picks them up
  automatically via the iteration helpers.
- webui/index.html: 'Torrent Only (via Prowlarr)' + 'Usenet Only
  (via Prowlarr)' added to the Download Source dropdown. New
  redirect card (#prowlarr-source-redirect) explains that the
  actual config lives on the Indexers & Downloaders tab —
  shown whenever torrent or usenet is in the active source set.
- webui/static/settings.js: HYBRID_SOURCES gets two new entries
  so hybrid mode can pick them up. updateDownloadSourceUI now
  toggles the redirect card based on active sources.
- tests/test_torrent_usenet_plugins.py: 23 tests covering pure
  helpers (filename encode/decode round-trip incl. magnet URIs,
  quality guesser, state mapping), search projection logic
  (protocol filter, drops without URLs, magnet-preferred-over-URL,
  filename encoding, neutralised soulseek-specific score fields),
  is_configured (both prowlarr + adapter required), finalize
  (picks first audio file, errors on empty dir / missing save_path),
  clear/get_all lifecycle, DownloadSourcePlugin protocol
  conformance, and registry membership.
2026-05-20 17:22:19 -07:00

198 lines
8.9 KiB
Python

"""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