Hybrid Mode Redesign

This commit is contained in:
Broque Thomas 2026-03-11 18:10:35 -07:00
parent fb04d0f4bc
commit 70c32aa640
5 changed files with 92 additions and 43 deletions

View file

@ -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"

View file

@ -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}")

View file

@ -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:

View file

@ -3654,27 +3654,30 @@
<!-- Hybrid Mode Settings (shown only when hybrid is selected) -->
<div id="hybrid-settings-container" style="display: none;">
<div class="form-group">
<label>Primary Source (Hybrid Mode):</label>
<select id="hybrid-primary-source" class="form-select">
<option value="soulseek">Try Soulseek First</option>
<option value="youtube">Try YouTube First</option>
<option value="tidal">Try Tidal First</option>
<option value="qobuz">Try Qobuz First</option>
<label>Primary Source:</label>
<select id="hybrid-primary-source" class="form-select" onchange="updateHybridSecondaryOptions()">
<option value="soulseek">Soulseek</option>
<option value="youtube">YouTube</option>
<option value="tidal">Tidal</option>
<option value="qobuz">Qobuz</option>
</select>
<div class="setting-help-text">
Which source to try first when hybrid mode is enabled.
First source to try for downloads.
</div>
</div>
<div class="form-group">
<label>Fallback Source:</label>
<select id="hybrid-secondary-source" class="form-select">
<option value="soulseek">Soulseek</option>
<option value="youtube">YouTube</option>
<option value="tidal">Tidal</option>
<option value="qobuz">Qobuz</option>
</select>
<div class="setting-help-text">
If the primary source finds nothing, try this source next.
</div>
</div>
<div class="form-group">
<label>YouTube Min Confidence:</label>
<input type="number" id="youtube-min-confidence" min="0.5" max="1.0" step="0.05"
value="0.65" placeholder="0.65">
<div class="setting-help-text">
Minimum match confidence for YouTube downloads (0.5-1.0). Higher = stricter
matching.
</div>
</div>
</div>
<!-- Tidal Download Settings (shown only when tidal mode is selected) -->

View file

@ -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'