Hybrid Mode Redesign
This commit is contained in:
parent
fb04d0f4bc
commit
70c32aa640
5 changed files with 92 additions and 43 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue