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": {
|
"download_source": {
|
||||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hybrid"
|
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hybrid"
|
||||||
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
|
"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": {
|
"tidal_download": {
|
||||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ logger = get_logger("download_orchestrator")
|
||||||
|
|
||||||
class DownloadOrchestrator:
|
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.
|
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.
|
Routes requests to the appropriate client(s) based on configured mode.
|
||||||
|
|
@ -42,17 +42,17 @@ class DownloadOrchestrator:
|
||||||
# Load mode from config
|
# Load mode from config
|
||||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||||
self.hybrid_primary = config_manager.get('download_source.hybrid_primary', '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}")
|
logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}")
|
||||||
if self.mode == 'hybrid':
|
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):
|
def reload_settings(self):
|
||||||
"""Reload settings from config (call after settings change)"""
|
"""Reload settings from config (call after settings change)"""
|
||||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||||
self.hybrid_primary = config_manager.get('download_source.hybrid_primary', '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.)
|
# Reload underlying client configs (SLSKD URL, API key, etc.)
|
||||||
self.soulseek._setup_client()
|
self.soulseek._setup_client()
|
||||||
|
|
@ -134,7 +134,6 @@ class DownloadOrchestrator:
|
||||||
return await self.qobuz.search(query, timeout, progress_callback)
|
return await self.qobuz.search(query, timeout, progress_callback)
|
||||||
|
|
||||||
elif self.mode == 'hybrid':
|
elif self.mode == 'hybrid':
|
||||||
# Build ordered client list: primary first, then remaining as fallbacks
|
|
||||||
clients = {
|
clients = {
|
||||||
'soulseek': self.soulseek,
|
'soulseek': self.soulseek,
|
||||||
'youtube': self.youtube,
|
'youtube': self.youtube,
|
||||||
|
|
@ -142,7 +141,12 @@ class DownloadOrchestrator:
|
||||||
'qobuz': self.qobuz,
|
'qobuz': self.qobuz,
|
||||||
}
|
}
|
||||||
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
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
|
# Try primary source first
|
||||||
logger.info(f"🔍 Hybrid search - trying {primary} first: {query}")
|
logger.info(f"🔍 Hybrid search - trying {primary} first: {query}")
|
||||||
|
|
@ -151,16 +155,15 @@ class DownloadOrchestrator:
|
||||||
logger.info(f"✅ {primary} found {len(tracks)} tracks")
|
logger.info(f"✅ {primary} found {len(tracks)} tracks")
|
||||||
return (tracks, albums)
|
return (tracks, albums)
|
||||||
|
|
||||||
# Try fallbacks in order
|
# Try secondary fallback
|
||||||
for fallback_name in fallbacks:
|
logger.info(f"🔄 {primary} found nothing, trying {secondary} fallback")
|
||||||
logger.info(f"🔄 {primary} found nothing, trying {fallback_name} fallback")
|
tracks, albums = await clients[secondary].search(query, timeout, progress_callback)
|
||||||
tracks, albums = await clients[fallback_name].search(query, timeout, progress_callback)
|
if tracks:
|
||||||
if tracks:
|
logger.info(f"✅ {secondary} found {len(tracks)} tracks")
|
||||||
logger.info(f"✅ {fallback_name} found {len(tracks)} tracks")
|
return (tracks, albums)
|
||||||
return (tracks, albums)
|
|
||||||
|
|
||||||
# Nothing found from any source
|
# Nothing found from either source
|
||||||
logger.warning(f"❌ Hybrid search exhausted all sources for: {query}")
|
logger.warning(f"❌ Hybrid search: both {primary} and {secondary} found nothing for: {query}")
|
||||||
return ([], [])
|
return ([], [])
|
||||||
|
|
||||||
# Fallback: empty results
|
# Fallback: empty results
|
||||||
|
|
@ -190,9 +193,13 @@ class DownloadOrchestrator:
|
||||||
logger.warning(f"No results found for: {query}")
|
logger.warning(f"No results found for: {query}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 2. Filter using Soulseek's quality preferences
|
# 2. Filter using Soulseek's quality preferences (Soulseek only)
|
||||||
# (Works for both YouTube and Soulseek TrackResult objects)
|
# Streaming sources (YouTube/Tidal/Qobuz) handle quality internally
|
||||||
filtered_results = self.soulseek.filter_results_by_quality_preference(tracks)
|
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:
|
if not filtered_results:
|
||||||
logger.warning(f"No suitable quality results found for: {query}")
|
logger.warning(f"No suitable quality results found for: {query}")
|
||||||
|
|
|
||||||
|
|
@ -811,12 +811,15 @@ class QobuzClient:
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check for shutdown
|
# Check for shutdown or cancellation
|
||||||
if self.shutdown_check and self.shutdown_check():
|
cancelled = False
|
||||||
logger.info("Server shutting down, aborting Qobuz download mid-stream")
|
with self._download_lock:
|
||||||
f.close()
|
if download_id in self.active_downloads:
|
||||||
out_path.unlink(missing_ok=True)
|
cancelled = self.active_downloads[download_id].get('state') == 'Cancelled'
|
||||||
return None
|
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)
|
f.write(chunk)
|
||||||
downloaded += len(chunk)
|
downloaded += len(chunk)
|
||||||
|
|
@ -840,6 +843,15 @@ class QobuzClient:
|
||||||
self.active_downloads[download_id]['speed'] = int(speed)
|
self.active_downloads[download_id]['speed'] = int(speed)
|
||||||
self.active_downloads[download_id]['time_remaining'] = time_remaining
|
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)
|
# Validate file size (Qobuz streams are DRM-free so this is mainly for network errors)
|
||||||
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
|
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
|
||||||
if downloaded < MIN_AUDIO_SIZE:
|
if downloaded < MIN_AUDIO_SIZE:
|
||||||
|
|
|
||||||
|
|
@ -3654,27 +3654,30 @@
|
||||||
<!-- Hybrid Mode Settings (shown only when hybrid is selected) -->
|
<!-- Hybrid Mode Settings (shown only when hybrid is selected) -->
|
||||||
<div id="hybrid-settings-container" style="display: none;">
|
<div id="hybrid-settings-container" style="display: none;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Primary Source (Hybrid Mode):</label>
|
<label>Primary Source:</label>
|
||||||
<select id="hybrid-primary-source" class="form-select">
|
<select id="hybrid-primary-source" class="form-select" onchange="updateHybridSecondaryOptions()">
|
||||||
<option value="soulseek">Try Soulseek First</option>
|
<option value="soulseek">Soulseek</option>
|
||||||
<option value="youtube">Try YouTube First</option>
|
<option value="youtube">YouTube</option>
|
||||||
<option value="tidal">Try Tidal First</option>
|
<option value="tidal">Tidal</option>
|
||||||
<option value="qobuz">Try Qobuz First</option>
|
<option value="qobuz">Qobuz</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="setting-help-text">
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Tidal Download Settings (shown only when tidal mode is selected) -->
|
<!-- Tidal Download Settings (shown only when tidal mode is selected) -->
|
||||||
|
|
|
||||||
|
|
@ -4547,7 +4547,8 @@ async function loadSettingsData() {
|
||||||
// Populate Download Source settings
|
// Populate Download Source settings
|
||||||
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
|
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('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('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||||
document.getElementById('qobuz-quality').value = settings.qobuz?.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
|
// QUALITY PROFILE FUNCTIONS
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -5267,7 +5294,7 @@ async function saveSettings(quiet = false) {
|
||||||
download_source: {
|
download_source: {
|
||||||
mode: document.getElementById('download-source-mode').value,
|
mode: document.getElementById('download-source-mode').value,
|
||||||
hybrid_primary: document.getElementById('hybrid-primary-source').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: {
|
tidal_download: {
|
||||||
quality: document.getElementById('tidal-download-quality').value || 'lossless'
|
quality: document.getElementById('tidal-download-quality').value || 'lossless'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue