diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index 81b0840b..1d7dccc7 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -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") diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 45c16d4e..e8231e09 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -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") diff --git a/core/download_plugins/base.py b/core/download_plugins/base.py index 5523beeb..b51683b7 100644 --- a/core/download_plugins/base.py +++ b/core/download_plugins/base.py @@ -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 diff --git a/core/download_plugins/types.py b/core/download_plugins/types.py new file mode 100644 index 00000000..46e250c3 --- /dev/null +++ b/core/download_plugins/types.py @@ -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 diff --git a/core/hifi_client.py b/core/hifi_client.py index 9a7dfe4c..cf383b52 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -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") diff --git a/core/library/redownload.py b/core/library/redownload.py index cf1bb182..08d5cd70 100644 --- a/core/library/redownload.py +++ b/core/library/redownload.py @@ -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'], diff --git a/core/lidarr_download_client.py b/core/lidarr_download_client.py index 0d92cdd9..a0542bc0 100644 --- a/core/lidarr_download_client.py +++ b/core/lidarr_download_client.py @@ -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") diff --git a/core/matching_engine.py b/core/matching_engine.py index da1fb9bc..2522d920 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -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") diff --git a/core/qobuz_client.py b/core/qobuz_client.py index 0b021983..48ceeb08 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -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") diff --git a/core/soulseek_client.py b/core/soulseek_client.py index bd76b4f9..f33b3d28 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -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): diff --git a/core/soundcloud_client.py b/core/soundcloud_client.py index 0d640985..b942a292 100644 --- a/core/soundcloud_client.py +++ b/core/soundcloud_client.py @@ -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") diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index c2ac6760..aab53290 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -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") diff --git a/core/youtube_client.py b/core/youtube_client.py index b3cd7270..08993bab 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -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") diff --git a/tests/test_soundcloud_client.py b/tests/test_soundcloud_client.py index 1c904724..cb258f87 100644 --- a/tests/test_soundcloud_client.py +++ b/tests/test_soundcloud_client.py @@ -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 # --------------------------------------------------------------------------- diff --git a/web_server.py b/web_server.py index a9ad01f3..74a332e7 100644 --- a/web_server.py +++ b/web_server.py @@ -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, diff --git a/webui/static/helper.js b/webui/static/helper.js index f148ebff..a2964435 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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(\'\')` 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.` reaches in web_server.py to `client("")`. 18 new tests pin every fix.' },