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:
parent
adecb7e8a8
commit
d17365296a
16 changed files with 228 additions and 192 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
199
core/download_plugins/types.py
Normal file
199
core/download_plugins/types.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue