Cin-1: Make DownloadSourcePlugin inheritance explicit on every client

Cin's review feedback: the plugin contract was discoverable only
from the registry, not from the client files themselves. Reading
`youtube_client.py` cold gave no signal that the class participates
in the DownloadSourcePlugin contract.

Every download client class now inherits DownloadSourcePlugin
explicitly:
- SoulseekClient(DownloadSourcePlugin)
- YouTubeClient(DownloadSourcePlugin)
- TidalDownloadClient(DownloadSourcePlugin)
- QobuzClient(DownloadSourcePlugin)
- HiFiClient(DownloadSourcePlugin)
- DeezerDownloadClient(DownloadSourcePlugin)
- SoundcloudClient(DownloadSourcePlugin)
- LidarrDownloadClient(DownloadSourcePlugin)

Adjustments:
- core/download_plugins/base.py — moved TrackResult/AlbumResult/
  DownloadStatus imports under TYPE_CHECKING since they're only
  used in type annotations. Without this, clients inheriting the
  contract create a circular import.
- core/download_plugins/__init__.py — drops DownloadPluginRegistry
  re-export. Importing the package no longer triggers the registry's
  eager client imports (which would also be circular for clients
  that import from the package). Callers that need the registry
  import it directly: `from core.download_plugins.registry import
  DownloadPluginRegistry`.

Suite still green (335 download tests).
This commit is contained in:
Broque Thomas 2026-05-04 22:19:52 -07:00
parent 0ee092979e
commit ea654f664e
10 changed files with 53 additions and 15 deletions

View file

@ -79,7 +79,10 @@ def _decrypt_chunk(chunk: bytes, key: bytes) -> bytes:
) from exc ) from exc
class DeezerDownloadClient: from core.download_plugins.base import DownloadSourcePlugin
class DeezerDownloadClient(DownloadSourcePlugin):
"""Deezer download client using ARL token authentication.""" """Deezer download client using ARL token authentication."""
def __init__(self, download_path: str = None): def __init__(self, download_path: str = None):

View file

@ -17,9 +17,18 @@ See `core/download_plugins/base.py` for the protocol contract and
""" """
from core.download_plugins.base import DownloadSourcePlugin from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.registry import DownloadPluginRegistry
# NOTE: DownloadPluginRegistry is intentionally NOT re-exported here.
# Importing the registry triggers eager imports of every client class
# (see registry.py for why eager — test fixtures inject mock modules
# at collection time and we need real bindings before that). Clients
# inherit from DownloadSourcePlugin (Cin's review feedback — visible
# contract conformance), so importing the package via ``from
# core.download_plugins import DownloadSourcePlugin`` from a client
# file would create a circular import if registry came along for the
# ride. Callers that need the registry import it directly:
# from core.download_plugins.registry import DownloadPluginRegistry
__all__ = [ __all__ = [
"DownloadSourcePlugin", "DownloadSourcePlugin",
"DownloadPluginRegistry",
] ]

View file

@ -27,12 +27,17 @@ makes the implicit contract explicit so:
from __future__ import annotations from __future__ import annotations
from typing import List, Optional, Protocol, Tuple, runtime_checkable from typing import TYPE_CHECKING, List, Optional, Protocol, Tuple, runtime_checkable
# Soulseek client owns the canonical TrackResult / AlbumResult / DownloadStatus # Soulseek client owns the canonical TrackResult / AlbumResult /
# dataclasses — every other source already imports from there. Keep it that way # DownloadStatus dataclasses — every other source already imports
# until a future PR lifts those into a neutral location. # from there. We only need them for type annotations on this
from core.soulseek_client import AlbumResult, DownloadStatus, TrackResult # 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.soulseek_client import AlbumResult, DownloadStatus, TrackResult
@runtime_checkable @runtime_checkable

View file

@ -86,7 +86,10 @@ DEFAULT_INSTANCES = [
] ]
class HiFiClient: from core.download_plugins.base import DownloadSourcePlugin
class HiFiClient(DownloadSourcePlugin):
""" """
HiFi API client for searching and downloading lossless music. HiFi API client for searching and downloading lossless music.
Uses public hifi-api instances (Tidal backend) no auth required. Uses public hifi-api instances (Tidal backend) no auth required.

View file

@ -33,7 +33,10 @@ from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("lidarr_client") logger = get_logger("lidarr_client")
class LidarrDownloadClient: from core.download_plugins.base import DownloadSourcePlugin
class LidarrDownloadClient(DownloadSourcePlugin):
"""Lidarr download client — uses Lidarr as a download source for Usenet/torrent content. """Lidarr download client — uses Lidarr as a download source for Usenet/torrent content.
Implements the same interface as SoulseekClient, QobuzClient, TidalDownloadClient Implements the same interface as SoulseekClient, QobuzClient, TidalDownloadClient

View file

@ -104,7 +104,10 @@ QOBUZ_QUALITY_MAP = {
} }
class QobuzClient: from core.download_plugins.base import DownloadSourcePlugin
class QobuzClient(DownloadSourcePlugin):
""" """
Qobuz download client using Qobuz REST API. Qobuz download client using Qobuz REST API.
Provides search, matching, and download capabilities as a drop-in alternative to Soulseek/YouTube/Tidal. Provides search, matching, and download capabilities as a drop-in alternative to Soulseek/YouTube/Tidal.

View file

@ -188,7 +188,10 @@ class DownloadStatus:
time_remaining: Optional[int] = None time_remaining: Optional[int] = None
file_path: Optional[str] = None file_path: Optional[str] = None
class SoulseekClient: from core.download_plugins.base import DownloadSourcePlugin
class SoulseekClient(DownloadSourcePlugin):
def __init__(self): def __init__(self):
self.base_url: Optional[str] = None self.base_url: Optional[str] = None
self.api_key: Optional[str] = None self.api_key: Optional[str] = None

View file

@ -82,7 +82,10 @@ def _sanitize_filename(name: str) -> str:
return cleaned or 'soundcloud_track' return cleaned or 'soundcloud_track'
class SoundcloudClient: from core.download_plugins.base import DownloadSourcePlugin
class SoundcloudClient(DownloadSourcePlugin):
"""SoundCloud download client built on yt-dlp's SoundCloud extractor. """SoundCloud download client built on yt-dlp's SoundCloud extractor.
Mirrors the public surface of TidalDownloadClient / QobuzClient so the Mirrors the public surface of TidalDownloadClient / QobuzClient so the

View file

@ -92,7 +92,10 @@ HLS_QUALITY_MAP = {
HLS_MAP_TAG_RE = re.compile(r'#EXT-X-MAP:.*URI="([^"]+)"') HLS_MAP_TAG_RE = re.compile(r'#EXT-X-MAP:.*URI="([^"]+)"')
class TidalDownloadClient: from core.download_plugins.base import DownloadSourcePlugin
class TidalDownloadClient(DownloadSourcePlugin):
""" """
Tidal download client using tidalapi. Tidal download client using tidalapi.
Provides search, matching, and download capabilities as a drop-in alternative to YouTube/Soulseek. Provides search, matching, and download capabilities as a drop-in alternative to YouTube/Soulseek.

View file

@ -91,7 +91,10 @@ class YouTubeSearchResult:
self.parsed_artist = self.channel self.parsed_artist = self.channel
class YouTubeClient: from core.download_plugins.base import DownloadSourcePlugin
class YouTubeClient(DownloadSourcePlugin):
""" """
YouTube download client using yt-dlp. YouTube download client using yt-dlp.
Provides search, matching, and download capabilities with full Spotify metadata integration. Provides search, matching, and download capabilities with full Spotify metadata integration.