diff --git a/config/settings.py b/config/settings.py index 7d872b1d..0886bbad 100644 --- a/config/settings.py +++ b/config/settings.py @@ -190,7 +190,7 @@ class ConfigManager: "download_source": { "mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hybrid" "hybrid_primary": "soulseek", # Which source to try first in hybrid mode - "youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches + "hybrid_secondary": "youtube", # Fallback source if primary finds nothing }, "tidal_download": { "quality": "lossless", # Options: "low", "high", "lossless", "hires" diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index eb2807c6..c4b6081c 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -26,7 +26,7 @@ logger = get_logger("download_orchestrator") class DownloadOrchestrator: """ - Orchestrates downloads between Soulseek and YouTube based on user preferences. + Orchestrates downloads between Soulseek, YouTube, Tidal, and Qobuz based on user preferences. Acts as a drop-in replacement for SoulseekClient by exposing the same async interface. Routes requests to the appropriate client(s) based on configured mode. @@ -42,17 +42,17 @@ class DownloadOrchestrator: # Load mode from config self.mode = config_manager.get('download_source.mode', 'soulseek') self.hybrid_primary = config_manager.get('download_source.hybrid_primary', 'soulseek') - self.youtube_min_confidence = config_manager.get('download_source.youtube_min_confidence', 0.65) + self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube') logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}") if self.mode == 'hybrid': - logger.info(f" Primary source: {self.hybrid_primary}") + logger.info(f" Primary: {self.hybrid_primary}, Fallback: {self.hybrid_secondary}") def reload_settings(self): """Reload settings from config (call after settings change)""" self.mode = config_manager.get('download_source.mode', 'soulseek') self.hybrid_primary = config_manager.get('download_source.hybrid_primary', 'soulseek') - self.youtube_min_confidence = config_manager.get('download_source.youtube_min_confidence', 0.65) + self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube') # Reload underlying client configs (SLSKD URL, API key, etc.) self.soulseek._setup_client() @@ -134,7 +134,6 @@ class DownloadOrchestrator: return await self.qobuz.search(query, timeout, progress_callback) elif self.mode == 'hybrid': - # Build ordered client list: primary first, then remaining as fallbacks clients = { 'soulseek': self.soulseek, 'youtube': self.youtube, @@ -142,7 +141,12 @@ class DownloadOrchestrator: 'qobuz': self.qobuz, } primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek' - fallbacks = [name for name in clients if name != primary] + secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek' + + # Ensure secondary differs from primary + if secondary == primary: + # Pick first available source that isn't primary + secondary = next((name for name in clients if name != primary), 'soulseek') # Try primary source first logger.info(f"🔍 Hybrid search - trying {primary} first: {query}") @@ -151,16 +155,15 @@ class DownloadOrchestrator: logger.info(f"✅ {primary} found {len(tracks)} tracks") return (tracks, albums) - # Try fallbacks in order - for fallback_name in fallbacks: - logger.info(f"🔄 {primary} found nothing, trying {fallback_name} fallback") - tracks, albums = await clients[fallback_name].search(query, timeout, progress_callback) - if tracks: - logger.info(f"✅ {fallback_name} found {len(tracks)} tracks") - return (tracks, albums) + # Try secondary fallback + logger.info(f"🔄 {primary} found nothing, trying {secondary} fallback") + tracks, albums = await clients[secondary].search(query, timeout, progress_callback) + if tracks: + logger.info(f"✅ {secondary} found {len(tracks)} tracks") + return (tracks, albums) - # Nothing found from any source - logger.warning(f"❌ Hybrid search exhausted all sources for: {query}") + # Nothing found from either source + logger.warning(f"❌ Hybrid search: both {primary} and {secondary} found nothing for: {query}") return ([], []) # Fallback: empty results @@ -190,9 +193,13 @@ class DownloadOrchestrator: logger.warning(f"No results found for: {query}") return None - # 2. Filter using Soulseek's quality preferences - # (Works for both YouTube and Soulseek TrackResult objects) - filtered_results = self.soulseek.filter_results_by_quality_preference(tracks) + # 2. Filter using Soulseek's quality preferences (Soulseek only) + # Streaming sources (YouTube/Tidal/Qobuz) handle quality internally + is_streaming = tracks[0].username in ('youtube', 'tidal', 'qobuz') if tracks else False + if is_streaming: + filtered_results = tracks + else: + filtered_results = self.soulseek.filter_results_by_quality_preference(tracks) if not filtered_results: logger.warning(f"No suitable quality results found for: {query}") diff --git a/core/qobuz_client.py b/core/qobuz_client.py index f59ca3a6..e91e77b2 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -811,12 +811,15 @@ class QobuzClient: if not chunk: continue - # Check for shutdown - if self.shutdown_check and self.shutdown_check(): - logger.info("Server shutting down, aborting Qobuz download mid-stream") - f.close() - out_path.unlink(missing_ok=True) - return None + # Check for shutdown or cancellation + cancelled = False + with self._download_lock: + if download_id in self.active_downloads: + cancelled = self.active_downloads[download_id].get('state') == 'Cancelled' + if cancelled or (self.shutdown_check and self.shutdown_check()): + reason = "cancelled" if cancelled else "server shutting down" + logger.info(f"Aborting Qobuz download mid-stream: {reason}") + break f.write(chunk) downloaded += len(chunk) @@ -840,6 +843,15 @@ class QobuzClient: self.active_downloads[download_id]['speed'] = int(speed) self.active_downloads[download_id]['time_remaining'] = time_remaining + # If download was aborted (shutdown/cancel), clean up partial file + abort_check = False + with self._download_lock: + if download_id in self.active_downloads: + abort_check = self.active_downloads[download_id].get('state') == 'Cancelled' + if abort_check or (self.shutdown_check and self.shutdown_check()): + out_path.unlink(missing_ok=True) + return None + # Validate file size (Qobuz streams are DRM-free so this is mainly for network errors) MIN_AUDIO_SIZE = 100 * 1024 # 100KB if downloaded < MIN_AUDIO_SIZE: diff --git a/webui/index.html b/webui/index.html index ebe42ec0..b4fff039 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3654,27 +3654,30 @@
diff --git a/webui/static/script.js b/webui/static/script.js index d6ab0436..6b06272a 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -4547,7 +4547,8 @@ async function loadSettingsData() { // Populate Download Source settings document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; document.getElementById('hybrid-primary-source').value = settings.download_source?.hybrid_primary || 'soulseek'; - document.getElementById('youtube-min-confidence').value = settings.download_source?.youtube_min_confidence || 0.65; + document.getElementById('hybrid-secondary-source').value = settings.download_source?.hybrid_secondary || 'youtube'; + updateHybridSecondaryOptions(); document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless'; document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless'; @@ -4754,6 +4755,32 @@ function updateDownloadSourceUI() { } } +function updateHybridSecondaryOptions() { + const primary = document.getElementById('hybrid-primary-source').value; + const secondary = document.getElementById('hybrid-secondary-source'); + const currentValue = secondary.value; + const allSources = [ + { value: 'soulseek', label: 'Soulseek' }, + { value: 'youtube', label: 'YouTube' }, + { value: 'tidal', label: 'Tidal' }, + { value: 'qobuz', label: 'Qobuz' }, + ]; + + secondary.innerHTML = ''; + for (const source of allSources) { + if (source.value === primary) continue; + const opt = document.createElement('option'); + opt.value = source.value; + opt.textContent = source.label; + secondary.appendChild(opt); + } + + // Restore previous selection if still valid, otherwise pick first available + if (currentValue !== primary) { + secondary.value = currentValue; + } +} + // =============================== // QUALITY PROFILE FUNCTIONS // =============================== @@ -5267,7 +5294,7 @@ async function saveSettings(quiet = false) { download_source: { mode: document.getElementById('download-source-mode').value, hybrid_primary: document.getElementById('hybrid-primary-source').value, - youtube_min_confidence: parseFloat(document.getElementById('youtube-min-confidence').value) || 0.65 + hybrid_secondary: document.getElementById('hybrid-secondary-source').value, }, tidal_download: { quality: document.getElementById('tidal-download-quality').value || 'lossless'