Two architectural cleanups on top of the download engine refactor. (1) Shared dataclasses move to neutral plugin package. TrackResult, AlbumResult, DownloadStatus, SearchResult lived in core/soulseek_client.py for historical reasons — every other plugin imported them from the soulseek module just to satisfy the contract, coupling 8 clients to a sibling source for type imports only. Moved them to the new core/download_plugins/types.py module and updated all 14 import sites across the deezer/hifi/lidarr/qobuz/soundcloud/tidal/ youtube clients, the engine, matching engine, redownload helper, and tests. Clean break, no backward-compat re-export. (2) web_server.py boots the orchestrator via the singleton factory. After construction it now calls set_download_orchestrator(...) so get_download_orchestrator() returns the same instance the global handle points at instead of lazily building a separate orchestrator. Matches the get_metadata_engine() pattern.
133 lines
5.2 KiB
Python
133 lines
5.2 KiB
Python
"""Canonical contract every download source plugin must satisfy.
|
|
|
|
`DownloadSourcePlugin` is a structural Protocol — any class that
|
|
implements these methods with matching signatures is automatically
|
|
treated as a download source. No inheritance required, no manual
|
|
registration required beyond the registry entry.
|
|
|
|
The protocol is intentionally narrow — only the methods the
|
|
orchestrator dispatches generically across all sources. Source-
|
|
specific extras (Soulseek's slskd internals, Lidarr's album-only
|
|
flow, etc.) stay on the underlying client and are accessed through
|
|
the registry's typed accessor.
|
|
|
|
This file is the FOUNDATION step. Existing client classes
|
|
(SoulseekClient, YouTubeClient, TidalDownloadClient, etc.) already
|
|
conform structurally — they grew the same shape independently
|
|
because every consumer site needed the same calls. This file just
|
|
makes the implicit contract explicit so:
|
|
|
|
- Type checkers can flag drift if a new source forgets a method.
|
|
- The orchestrator can iterate plugins generically instead of
|
|
hardcoding `[self.soulseek, self.youtube, ...]` everywhere.
|
|
- Future PRs can move shared logic INTO the contract (a base
|
|
class with default implementations) without changing the
|
|
signature surface every consumer already depends on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, List, Optional, Protocol, Tuple, runtime_checkable
|
|
|
|
# Soulseek client owns the canonical TrackResult / AlbumResult /
|
|
# DownloadStatus dataclasses — every other source already imports
|
|
# from there. We only need them for type annotations on this
|
|
# protocol; using TYPE_CHECKING avoids a circular import once the
|
|
# clients themselves inherit from DownloadSourcePlugin (Cin's
|
|
# review feedback — clients explicitly declare conformance instead
|
|
# of relying on structural typing).
|
|
if TYPE_CHECKING:
|
|
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
|
|
|
|
|
@runtime_checkable
|
|
class DownloadSourcePlugin(Protocol):
|
|
"""Structural contract for a download source.
|
|
|
|
`runtime_checkable` lets `isinstance(client, DownloadSourcePlugin)`
|
|
work for the conformance test, but it ONLY checks method names —
|
|
not signatures or async-ness. The conformance test in
|
|
``tests/test_download_plugin_conformance.py`` does the deeper
|
|
signature check.
|
|
"""
|
|
|
|
# ------------------------------------------------------------------
|
|
# Configuration / lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
def is_configured(self) -> bool:
|
|
"""Return True iff this source has the credentials / settings
|
|
it needs to function. Used by the orchestrator to skip
|
|
unconfigured sources during hybrid fallback."""
|
|
...
|
|
|
|
async def check_connection(self) -> bool:
|
|
"""Probe the source's API / endpoint. Return True if the
|
|
source is reachable. May make a live network call."""
|
|
...
|
|
|
|
# ------------------------------------------------------------------
|
|
# Search
|
|
# ------------------------------------------------------------------
|
|
|
|
async def search(
|
|
self,
|
|
query: str,
|
|
timeout: Optional[int] = None,
|
|
progress_callback=None,
|
|
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
|
"""Search the source for tracks (and albums where supported).
|
|
|
|
Returns a tuple of (track_results, album_results). Either
|
|
list may be empty. Sources that don't expose album-level
|
|
search return ``[]`` as the second element.
|
|
"""
|
|
...
|
|
|
|
# ------------------------------------------------------------------
|
|
# Download
|
|
# ------------------------------------------------------------------
|
|
|
|
async def download(
|
|
self,
|
|
username: str,
|
|
filename: str,
|
|
file_size: int = 0,
|
|
) -> Optional[str]:
|
|
"""Kick off a download. Returns a download_id string the
|
|
caller can poll via ``get_download_status``. Returns ``None``
|
|
if the source can't / won't handle this download.
|
|
|
|
``username`` is the source-name string for streaming sources
|
|
(e.g. ``'youtube'``, ``'tidal'``) and the actual slskd peer
|
|
username for Soulseek. ``filename`` is source-specific —
|
|
Soulseek file path, YouTube ``video_id||title``, Tidal /
|
|
Qobuz ``track_id||display``, etc.
|
|
"""
|
|
...
|
|
|
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
|
"""Return live status of all downloads currently tracked by
|
|
this source. The orchestrator concatenates results from
|
|
every plugin to build the global download list."""
|
|
...
|
|
|
|
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
|
"""Return status for a single download or ``None`` if this
|
|
source doesn't know about that download_id."""
|
|
...
|
|
|
|
async def cancel_download(
|
|
self,
|
|
download_id: str,
|
|
username: Optional[str] = None,
|
|
remove: bool = False,
|
|
) -> bool:
|
|
"""Cancel an active download. ``remove=True`` also drops
|
|
the row from the source's active-downloads tracking."""
|
|
...
|
|
|
|
async def clear_all_completed_downloads(self) -> bool:
|
|
"""Drop completed downloads from active tracking. Sources
|
|
that don't keep completed history return True with no-op."""
|
|
...
|