Lift shared download dataclasses + boot via singleton factory

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.
This commit is contained in:
Broque Thomas 2026-05-05 09:08:39 -07:00
parent adecb7e8a8
commit d17365296a
16 changed files with 228 additions and 192 deletions

View file

@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple
import requests
from core.soulseek_client import AlbumResult, DownloadStatus, TrackResult
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
from utils.logging_config import get_logger
logger = get_logger("deezer_download")

View file

@ -27,7 +27,7 @@ from utils.logging_config import get_logger
from config.settings import config_manager
from core.download_engine import DownloadEngine
from core.download_plugins.registry import DownloadPluginRegistry, build_default_registry
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("download_orchestrator")

View file

@ -37,7 +37,7 @@ from typing import TYPE_CHECKING, List, Optional, Protocol, Tuple, runtime_check
# review feedback — clients explicitly declare conformance instead
# of relying on structural typing).
if TYPE_CHECKING:
from core.soulseek_client import AlbumResult, DownloadStatus, TrackResult
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
@runtime_checkable

View file

@ -0,0 +1,199 @@
"""Shared dataclasses for the download-plugin contract.
Every download source returns the same shape for search hits and
download status the four classes here are the canonical types
that the ``DownloadSourcePlugin`` Protocol exchanges. Living in
this neutral module (rather than ``core/soulseek_client.py`` where
they grew up by accident) means a new plugin doesn't have to import
from a sibling source just to satisfy the contract.
Move history: extracted from ``core.soulseek_client`` so plugins
import from a neutral package per Cin's contract-first standard.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from core.imports.filename import parse_filename_metadata
@dataclass
class SearchResult:
"""Base class for search results"""
username: str
filename: str
size: int
bitrate: Optional[int]
duration: Optional[int] # Duration in milliseconds (converted from slskd's seconds)
quality: str
free_upload_slots: int
upload_speed: int
queue_length: int
result_type: str = "track" # "track" or "album"
@property
def quality_score(self) -> float:
quality_weights = {
'flac': 1.0,
'mp3': 0.8,
'ogg': 0.7,
'aac': 0.6,
'wma': 0.5
}
base_score = quality_weights.get(self.quality.lower(), 0.3)
if self.bitrate:
if self.bitrate >= 320:
base_score += 0.2
elif self.bitrate >= 256:
base_score += 0.1
elif self.bitrate < 128:
base_score -= 0.2
# Free upload slots
if self.free_upload_slots == 0:
base_score -= 0.15
elif self.free_upload_slots > 0:
base_score += 0.05
# Upload speed in bytes/sec (tiered)
if self.upload_speed >= 5_000_000: # ~5 MB/s / 40 Mbps
base_score += 0.15
elif self.upload_speed >= 1_000_000: # ~1 MB/s / 8 Mbps
base_score += 0.10
elif self.upload_speed >= 500_000: # ~500 KB/s / 4 Mbps
base_score += 0.05
elif self.upload_speed < 100_000: # ~100 KB/s / 800 kbps
base_score -= 0.05
# Queue length (graduated penalty)
if self.queue_length > 50:
base_score -= 0.25
elif self.queue_length > 20:
base_score -= 0.15
elif self.queue_length > 10:
base_score -= 0.10
return min(base_score, 1.0)
@dataclass
class TrackResult(SearchResult):
"""Individual track search result"""
artist: Optional[str] = None
title: Optional[str] = None
album: Optional[str] = None
track_number: Optional[int] = None
_source_metadata: Optional[Dict[str, Any]] = None
def __post_init__(self):
self.result_type = "track"
# Try to extract metadata from filename if not provided
if not self.title or not self.artist:
self._parse_filename_metadata()
def _parse_filename_metadata(self):
"""Extract artist, title, album from filename patterns"""
parsed = parse_filename_metadata(self.filename)
if not self.artist and parsed.get("artist"):
self.artist = parsed["artist"]
if not self.title and parsed.get("title"):
self.title = parsed["title"]
if not self.album and parsed.get("album"):
self.album = parsed["album"]
if self.track_number is None:
track_number = parsed.get("track_number")
if track_number is not None:
self.track_number = track_number
@dataclass
class AlbumResult:
"""Album/folder search result containing multiple tracks"""
username: str
album_path: str # Directory path
album_title: str
artist: Optional[str]
track_count: int
total_size: int
tracks: List[TrackResult]
dominant_quality: str # Most common quality in album
year: Optional[str] = None
free_upload_slots: int = 0
upload_speed: int = 0
queue_length: int = 0
result_type: str = "album"
@property
def quality_score(self) -> float:
"""Calculate album quality score based on dominant quality and track count"""
quality_weights = {
'flac': 1.0,
'mp3': 0.8,
'ogg': 0.7,
'aac': 0.6,
'wma': 0.5
}
base_score = quality_weights.get(self.dominant_quality.lower(), 0.3)
# Bonus for complete albums (typically 8-15 tracks)
if 8 <= self.track_count <= 20:
base_score += 0.1
elif self.track_count > 20:
base_score += 0.05
# Free upload slots
if self.free_upload_slots == 0:
base_score -= 0.15
elif self.free_upload_slots > 0:
base_score += 0.05
# Upload speed in bytes/sec (tiered)
if self.upload_speed >= 5_000_000: # ~5 MB/s / 40 Mbps
base_score += 0.15
elif self.upload_speed >= 1_000_000: # ~1 MB/s / 8 Mbps
base_score += 0.10
elif self.upload_speed >= 500_000: # ~500 KB/s / 4 Mbps
base_score += 0.05
elif self.upload_speed < 100_000: # ~100 KB/s / 800 kbps
base_score -= 0.05
# Queue length (graduated penalty)
if self.queue_length > 50:
base_score -= 0.25
elif self.queue_length > 20:
base_score -= 0.15
elif self.queue_length > 10:
base_score -= 0.10
return min(base_score, 1.0)
@property
def size_mb(self) -> int:
"""Album size in MB"""
return self.total_size // (1024 * 1024)
@property
def average_track_size_mb(self) -> float:
"""Average track size in MB"""
if self.track_count > 0:
return self.size_mb / self.track_count
return 0
@dataclass
class DownloadStatus:
id: str
filename: str
username: str
state: str
progress: float
size: int
transferred: int
speed: int
time_remaining: Optional[int] = None
file_path: Optional[str] = None

View file

@ -33,7 +33,7 @@ import requests as http_requests
from utils.logging_config import get_logger
from config.settings import config_manager
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("hifi_client")

View file

@ -208,7 +208,7 @@ def redownload_start(track_id):
# Build a TrackResult-like candidate and submit to download
def _run_redownload():
try:
from core.soulseek_client import TrackResult
from core.download_plugins.types import TrackResult
from core.itunes_client import Track as MetaTrack
tr = TrackResult(
username=candidate['username'],

View file

@ -28,7 +28,7 @@ from utils.logging_config import get_logger
from config.settings import config_manager
# Import Soulseek data structures for drop-in replacement compatibility
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("lidarr_client")

View file

@ -8,7 +8,7 @@ from config.settings import config_manager
from core.spotify_client import Track as SpotifyTrack
from core.plex_client import PlexTrackInfo
from core.soulseek_client import TrackResult, AlbumResult
from core.download_plugins.types import TrackResult, AlbumResult
logger = get_logger("matching_engine")

View file

@ -28,7 +28,7 @@ from utils.logging_config import get_logger
from config.settings import config_manager
# Import Soulseek data structures for drop-in replacement compatibility
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("qobuz_client")

View file

@ -9,187 +9,19 @@ from pathlib import Path
from utils.logging_config import get_logger
from config.settings import config_manager
from core.imports.filename import parse_filename_metadata
# Shared download-result dataclasses + plugin contract live in the
# neutral plugin package — every source uses the same types, so they
# belong there rather than this soulseek-specific module.
from core.download_plugins.types import (
AlbumResult,
DownloadStatus,
SearchResult,
TrackResult,
)
from core.download_plugins.base import DownloadSourcePlugin
logger = get_logger("soulseek_client")
@dataclass
class SearchResult:
"""Base class for search results"""
username: str
filename: str
size: int
bitrate: Optional[int]
duration: Optional[int] # Duration in milliseconds (converted from slskd's seconds)
quality: str
free_upload_slots: int
upload_speed: int
queue_length: int
result_type: str = "track" # "track" or "album"
@property
def quality_score(self) -> float:
quality_weights = {
'flac': 1.0,
'mp3': 0.8,
'ogg': 0.7,
'aac': 0.6,
'wma': 0.5
}
base_score = quality_weights.get(self.quality.lower(), 0.3)
if self.bitrate:
if self.bitrate >= 320:
base_score += 0.2
elif self.bitrate >= 256:
base_score += 0.1
elif self.bitrate < 128:
base_score -= 0.2
# Free upload slots
if self.free_upload_slots == 0:
base_score -= 0.15
elif self.free_upload_slots > 0:
base_score += 0.05
# Upload speed in bytes/sec (tiered)
if self.upload_speed >= 5_000_000: # ~5 MB/s / 40 Mbps
base_score += 0.15
elif self.upload_speed >= 1_000_000: # ~1 MB/s / 8 Mbps
base_score += 0.10
elif self.upload_speed >= 500_000: # ~500 KB/s / 4 Mbps
base_score += 0.05
elif self.upload_speed < 100_000: # ~100 KB/s / 800 kbps
base_score -= 0.05
# Queue length (graduated penalty)
if self.queue_length > 50:
base_score -= 0.25
elif self.queue_length > 20:
base_score -= 0.15
elif self.queue_length > 10:
base_score -= 0.10
return min(base_score, 1.0)
@dataclass
class TrackResult(SearchResult):
"""Individual track search result"""
artist: Optional[str] = None
title: Optional[str] = None
album: Optional[str] = None
track_number: Optional[int] = None
_source_metadata: Optional[Dict[str, Any]] = None
def __post_init__(self):
self.result_type = "track"
# Try to extract metadata from filename if not provided
if not self.title or not self.artist:
self._parse_filename_metadata()
def _parse_filename_metadata(self):
"""Extract artist, title, album from filename patterns"""
parsed = parse_filename_metadata(self.filename)
if not self.artist and parsed.get("artist"):
self.artist = parsed["artist"]
if not self.title and parsed.get("title"):
self.title = parsed["title"]
if not self.album and parsed.get("album"):
self.album = parsed["album"]
if self.track_number is None:
track_number = parsed.get("track_number")
if track_number is not None:
self.track_number = track_number
@dataclass
class AlbumResult:
"""Album/folder search result containing multiple tracks"""
username: str
album_path: str # Directory path
album_title: str
artist: Optional[str]
track_count: int
total_size: int
tracks: List[TrackResult]
dominant_quality: str # Most common quality in album
year: Optional[str] = None
free_upload_slots: int = 0
upload_speed: int = 0
queue_length: int = 0
result_type: str = "album"
@property
def quality_score(self) -> float:
"""Calculate album quality score based on dominant quality and track count"""
quality_weights = {
'flac': 1.0,
'mp3': 0.8,
'ogg': 0.7,
'aac': 0.6,
'wma': 0.5
}
base_score = quality_weights.get(self.dominant_quality.lower(), 0.3)
# Bonus for complete albums (typically 8-15 tracks)
if 8 <= self.track_count <= 20:
base_score += 0.1
elif self.track_count > 20:
base_score += 0.05
# Free upload slots
if self.free_upload_slots == 0:
base_score -= 0.15
elif self.free_upload_slots > 0:
base_score += 0.05
# Upload speed in bytes/sec (tiered)
if self.upload_speed >= 5_000_000: # ~5 MB/s / 40 Mbps
base_score += 0.15
elif self.upload_speed >= 1_000_000: # ~1 MB/s / 8 Mbps
base_score += 0.10
elif self.upload_speed >= 500_000: # ~500 KB/s / 4 Mbps
base_score += 0.05
elif self.upload_speed < 100_000: # ~100 KB/s / 800 kbps
base_score -= 0.05
# Queue length (graduated penalty)
if self.queue_length > 50:
base_score -= 0.25
elif self.queue_length > 20:
base_score -= 0.15
elif self.queue_length > 10:
base_score -= 0.10
return min(base_score, 1.0)
@property
def size_mb(self) -> int:
"""Album size in MB"""
return self.total_size // (1024 * 1024)
@property
def average_track_size_mb(self) -> float:
"""Average track size in MB"""
if self.track_count > 0:
return self.size_mb / self.track_count
return 0
@dataclass
class DownloadStatus:
id: str
filename: str
username: str
state: str
progress: float
size: int
transferred: int
speed: int
time_remaining: Optional[int] = None
file_path: Optional[str] = None
from core.download_plugins.base import DownloadSourcePlugin
class SoulseekClient(DownloadSourcePlugin):
def __init__(self):

View file

@ -40,7 +40,7 @@ from config.settings import config_manager
# Standard data structures shared across all download clients so downstream
# matching/post-processing stays source-agnostic.
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("soundcloud_client")

View file

@ -32,7 +32,7 @@ from utils.logging_config import get_logger
from config.settings import config_manager
# Import Soulseek data structures for drop-in replacement compatibility
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("tidal_download_client")

View file

@ -33,7 +33,7 @@ from core.matching_engine import MusicMatchingEngine
from core.spotify_client import Track as SpotifyTrack
# Import Soulseek data structures for drop-in replacement compatibility
from core.soulseek_client import SearchResult, TrackResult, AlbumResult, DownloadStatus
from core.download_plugins.types import SearchResult, TrackResult, AlbumResult, DownloadStatus
logger = get_logger("youtube_client")

View file

@ -30,7 +30,7 @@ import pytest
from core import soundcloud_client
from core.soundcloud_client import SoundcloudClient, _sanitize_filename
from core.soulseek_client import AlbumResult, DownloadStatus, TrackResult
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
# ---------------------------------------------------------------------------

View file

@ -88,7 +88,7 @@ from plexapi.myplex import MyPlexAccount, MyPlexPinLogin
from core.jellyfin_client import JellyfinClient
from core.navidrome_client import NavidromeClient
from core.soulseek_client import SoulseekClient
from core.download_orchestrator import DownloadOrchestrator
from core.download_orchestrator import DownloadOrchestrator, set_download_orchestrator
from core.tidal_client import TidalClient # Added import for Tidal
from core.matching_engine import MusicMatchingEngine
from core.database_update_worker import DatabaseUpdateWorker
@ -605,6 +605,10 @@ except Exception as e:
try:
download_orchestrator = DownloadOrchestrator()
# Install as the process-wide singleton so callers reaching for
# get_download_orchestrator() see the same instance web_server.py
# constructs at boot. Matches Cin's metadata engine pattern.
set_download_orchestrator(download_orchestrator)
logger.info(" Download orchestrator initialized")
except Exception as e:
logger.error(f" Download orchestrator failed to initialize: {e}")
@ -7798,7 +7802,7 @@ def download_selected_candidate(task_id):
batch['active_count'] = batch.get('active_count', 0) + 1
# Build a TrackResult-like candidate object
from core.soulseek_client import TrackResult
from core.download_plugins.types import TrackResult
candidate = TrackResult(
username=username,
filename=filename,

View file

@ -3432,6 +3432,7 @@ const WHATS_NEW = {
'2.4.2': [
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.2 dev cycle' },
{ title: 'Internal: Move Shared Download Dataclasses + Singleton Boot Path', desc: 'internal — two architectural cleanups on top of the download engine refactor. (1) `TrackResult`, `AlbumResult`, `DownloadStatus`, `SearchResult` lived in `core/soulseek_client.py` for historical reasons (they grew up there as the soulseek-only types and got exported when other download sources were added). every plugin imported these from the soulseek module just to satisfy the contract — coupling 8 clients to a sibling source for type imports only. moved them to `core/download_plugins/types.py` (the neutral plugin package) and updated all 14 import sites across deezer/hifi/lidarr/qobuz/soundcloud/tidal/youtube clients + the engine + matching engine + redownload + tests. clean break, no backward-compat re-export. (2) `web_server.py` now boots the orchestrator via `set_download_orchestrator(DownloadOrchestrator())` so the singleton factory + boot path share state — `get_download_orchestrator()` returns the same instance the global handle points at instead of lazily building a separate one. matches cin\'s `get_metadata_engine()` pattern.' },
{ title: 'Internal: Rename `soulseek_client` Global → `download_orchestrator`', desc: 'internal — followup cleanup. the global handle in web_server.py was named `soulseek_client` for historical reasons (the orchestrator was originally just the soulseek client and grew downstream sources around it), but the type has long been `DownloadOrchestrator` not `SoulseekClient`. renamed the global + every parameter/attribute that carried the legacy name across web_server.py, api/, core/downloads/*, core/search/*, core/streaming/*, services/sync_service.py, and the test fixtures (`MasterDeps.soulseek_client` → `download_orchestrator`, `init(soulseek_client_obj)` → `init(download_orchestrator_obj)`, etc). module path `core.soulseek_client` and class `SoulseekClient` (the actual soulseek-only client) are unchanged — only the orchestrator handle renamed. ~250 references touched, suite green.' },
{ title: 'Internal: Drop Backward-Compat Per-Source Attrs', desc: 'internal — followup to cin\'s download engine review. removed the `orchestrator.soulseek` / `.youtube` / `.tidal` / `.qobuz` / `.hifi` / `.deezer_dl` / `.lidarr` / `.soundcloud` attribute aliases that were preserved for backward compat. external callers (core/downloads/, core/search/, web_server.py) all migrated to the generic `orchestrator.client(\'<source>\')` accessor — alias-aware (legacy `deezer_dl` resolves to canonical `deezer`), single source of truth via the registry. the orchestrator\'s own internal `self.soulseek` / `self.deezer_dl` reaches also routed through `client()` so the only place that knows about per-source identity is the registry. test fakes updated to expose `client(name)` instead of stuffing attributes; conformance test pinned to the new accessor contract. zero behavior change — just cleaner shape.' },
{ title: 'Internal: Download Engine Review Followup', desc: 'internal — three correctness fixes on top of the download engine refactor, all flagged in cin\'s pr review. (1) `engine.cancel_download(source_hint=\'deezer_dl\')` was silently routing deezer cancels to soulseek because the legacy alias never made it to the engine\'s plugin map — only the registry knew about it. fix: aliases now flow through `register_plugin` and `get_plugin` / `cancel_download` resolve them to the canonical name. (2) `_resolve_source_chain` filtered hybrid_order against canonical registry names only, so any user with `deezer_dl` in their config quietly dropped deezer from hybrid mode. fix: orchestrator normalizes through `registry.get_spec()` first. (3) the worker\'s terminal write was a read-then-write split — a cancel landing between the snapshot and the update could be overwritten back to errored / completed. fix: new atomic `update_record_unless_state` on the engine holds `state_lock` across the check + write; both `_mark_terminal` AND the success path use it now. also added generic `client(name)` / `configured_clients()` / `reload_instances(name?)` accessors on the orchestrator + a `get/set_download_orchestrator()` singleton matching cin\'s `get_metadata_engine()` shape, and migrated 30 external `soulseek_client.<source>` reaches in web_server.py to `client("<source>")`. 18 new tests pin every fix.' },