Redesign hybrid mode with N-source priority ordering

Replace fixed primary/secondary hybrid dropdowns with an ordered list
of all 5 download sources. Users enable/disable each source and reorder
with up/down arrows to set download priority. Sources are tried in
order until one returns results.

- New hybrid_order config field (backward compat with legacy primary/secondary)
- Download orchestrator loops ordered list with per-source error handling
- Sortable source list UI with icons, toggle switches, priority numbers
- Source-specific settings shown for all enabled hybrid sources
- Seamless migration from legacy 2-source to N-source format
This commit is contained in:
Broque Thomas 2026-03-19 18:51:52 -07:00
parent 3293a476fc
commit fc4e16337a
6 changed files with 306 additions and 58 deletions

View file

@ -366,8 +366,9 @@ class ConfigManager:
},
"download_source": {
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid"
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
"hybrid_secondary": "youtube", # Fallback source if primary finds nothing
"hybrid_primary": "soulseek", # Legacy: primary source for hybrid mode
"hybrid_secondary": "youtube", # Legacy: fallback source for hybrid mode
"hybrid_order": [], # Ordered list of sources for hybrid mode (overrides primary/secondary)
},
"tidal_download": {
"quality": "lossless", # Options: "low", "high", "lossless", "hires"

View file

@ -46,16 +46,21 @@ class DownloadOrchestrator:
self.mode = config_manager.get('download_source.mode', 'soulseek')
self.hybrid_primary = config_manager.get('download_source.hybrid_primary', 'soulseek')
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
self.hybrid_order = config_manager.get('download_source.hybrid_order', [])
logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}")
if self.mode == 'hybrid':
logger.info(f" Primary: {self.hybrid_primary}, Fallback: {self.hybrid_secondary}")
if self.hybrid_order:
logger.info(f" Source priority: {''.join(self.hybrid_order)}")
else:
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.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
self.hybrid_order = config_manager.get('download_source.hybrid_order', [])
# Reload underlying client configs (SLSKD URL, API key, etc.)
self.soulseek._setup_client()
@ -80,7 +85,9 @@ class DownloadOrchestrator:
elif self.mode == 'hifi':
return self.hifi.is_configured()
elif self.mode == 'hybrid':
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured() or self.hifi.is_configured()
clients = {'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, 'hifi': self.hifi}
sources = self.hybrid_order if self.hybrid_order else [self.hybrid_primary, self.hybrid_secondary]
return any(clients[s].is_configured() for s in sources if s in clients)
return False
@ -153,30 +160,39 @@ class DownloadOrchestrator:
'qobuz': self.qobuz,
'hifi': self.hifi,
}
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
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')
# Build ordered source list: prefer hybrid_order, fall back to legacy primary/secondary
if self.hybrid_order:
source_order = [s for s in self.hybrid_order if s in clients]
else:
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek'
if secondary == primary:
secondary = next((name for name in clients if name != primary), 'soulseek')
source_order = [primary, secondary]
# Try primary source first
logger.info(f"🔍 Hybrid search - trying {primary} first: {query}")
tracks, albums = await clients[primary].search(query, timeout, progress_callback)
if tracks:
logger.info(f"{primary} found {len(tracks)} tracks")
return (tracks, albums)
if not source_order:
source_order = ['soulseek']
# 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)
logger.info(f"🔍 Hybrid search ({''.join(source_order)}): {query}")
# Nothing found from either source
logger.warning(f"❌ Hybrid search: both {primary} and {secondary} found nothing for: {query}")
# Try each source in priority order
for i, source_name in enumerate(source_order):
try:
if i == 0:
logger.info(f"🔍 Trying {source_name} (priority {i+1}): {query}")
else:
logger.info(f"🔄 Trying {source_name} (priority {i+1}): {query}")
tracks, albums = await clients[source_name].search(query, timeout, progress_callback)
if tracks:
logger.info(f"{source_name} found {len(tracks)} tracks")
return (tracks, albums)
except Exception as e:
logger.warning(f"⚠️ {source_name} search failed: {e}")
# Nothing found from any source
logger.warning(f"❌ Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}")
return ([], [])
# Fallback: empty results

View file

@ -6997,7 +6997,9 @@ def stream_enhanced_search_track():
search_queries = []
import re
if download_mode in ('youtube', 'tidal', 'qobuz', 'hifi') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz', 'hifi')):
_hybrid_order = config_manager.get('download_source.hybrid_order', [])
_hybrid_first = _hybrid_order[0] if _hybrid_order else config_manager.get('download_source.hybrid_primary', 'soulseek')
if download_mode in ('youtube', 'tidal', 'qobuz', 'hifi') or (download_mode == 'hybrid' and _hybrid_first in ('youtube', 'tidal', 'qobuz', 'hifi')):
# YouTube/Tidal mode: Include artist for better context
# Primary query: Artist + Track
if artist_name and track_name:
@ -21876,8 +21878,10 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
preflight_source = None
preflight_tracks = None
dl_source_mode = config_manager.get('download_source.mode', 'soulseek')
_dl_hybrid_order = config_manager.get('download_source.hybrid_order', [])
_dl_hybrid_first = _dl_hybrid_order[0] if _dl_hybrid_order else config_manager.get('download_source.hybrid_primary', 'soulseek')
soulseek_is_source = dl_source_mode == 'soulseek' or (
dl_source_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary', 'soulseek') == 'soulseek'
dl_source_mode == 'hybrid' and _dl_hybrid_first == 'soulseek'
)
if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source:
artist_name = batch_artist_context.get('name', '')

View file

@ -3875,33 +3875,15 @@
<!-- Hybrid Mode Settings (shown only when hybrid is selected) -->
<div id="hybrid-settings-container" style="display: none;">
<div class="form-group">
<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>
<option value="hifi">HiFi</option>
</select>
<div class="setting-help-text">
First source to try for downloads.
</div>
<div class="setting-help-text">
Drag to reorder source priority. Enable sources you want to use. Downloads try each enabled source in order.
</div>
<div class="form-group">
<label>Fallback Source:</label>
<select id="hybrid-secondary-source" class="form-select" onchange="updateDownloadSourceUI()">
<option value="soulseek">Soulseek</option>
<option value="youtube">YouTube</option>
<option value="tidal">Tidal</option>
<option value="qobuz">Qobuz</option>
<option value="hifi">HiFi</option>
</select>
<div class="setting-help-text">
If the primary source finds nothing, try this source next.
</div>
<div class="hybrid-source-list" id="hybrid-source-list">
<!-- Populated by JS -->
</div>
<!-- Hidden selects for backward compatibility with saveSettings -->
<select id="hybrid-primary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option></select>
<select id="hybrid-secondary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option></select>
</div>
<!-- Soulseek Settings (shown when soulseek is active source) -->

View file

@ -4877,6 +4877,143 @@ function toggleStgService(el) {
if (service) service.classList.toggle('expanded');
}
// ── Hybrid source priority list (drag-and-drop) ──
const HYBRID_SOURCES = [
{ id: 'soulseek', name: 'Soulseek', icon: 'https://raw.githubusercontent.com/slskd/slskd/master/docs/icon.png', emoji: '🎵' },
{ id: 'youtube', name: 'YouTube', icon: 'https://www.svgrepo.com/show/13671/youtube.svg', emoji: '▶️' },
{ id: 'tidal', name: 'Tidal', icon: 'https://www.svgrepo.com/show/519734/tidal.svg', emoji: '🌊' },
{ id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' },
{ id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' },
];
let _hybridSourceOrder = ['soulseek', 'youtube'];
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false };
let _hybridVisualOrder = null; // Full visual order including disabled sources
function buildHybridSourceList() {
const container = document.getElementById('hybrid-source-list');
if (!container) return;
container.innerHTML = '';
// Build visual order: use persisted visual order, or enabled first + disabled at bottom
if (!_hybridVisualOrder) {
_hybridVisualOrder = [..._hybridSourceOrder];
for (const src of HYBRID_SOURCES) {
if (!_hybridVisualOrder.includes(src.id)) _hybridVisualOrder.push(src.id);
}
}
const allIds = _hybridVisualOrder;
allIds.forEach((srcId, idx) => {
const src = HYBRID_SOURCES.find(s => s.id === srcId);
if (!src) return;
const enabled = _hybridSourceEnabled[srcId] !== false;
const isInOrder = _hybridSourceOrder.includes(srcId);
const priorityNum = isInOrder && enabled ? _hybridSourceOrder.indexOf(srcId) + 1 : '';
const item = document.createElement('div');
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
item.draggable = true;
item.dataset.sourceId = srcId;
item.innerHTML = `
<span class="hybrid-source-arrows">
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', -1)" title="Move up"></button>
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', 1)" title="Move down"></button>
</span>
${src.icon
? `<img class="hybrid-source-icon" src="${src.icon}" alt="${src.name}" onerror="this.outerHTML='<span class=\\'hybrid-source-icon emoji-icon\\'>${src.emoji}</span>'">`
: `<span class="hybrid-source-icon emoji-icon">${src.emoji}</span>`
}
<span class="hybrid-source-name">${src.name}</span>
<span class="hybrid-source-priority">${priorityNum}</span>
<label class="hybrid-source-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleHybridSource('${srcId}', this.checked)">
<span class="toggle-track"></span>
</label>
`;
container.appendChild(item);
});
// Sync hidden selects for backward compat
_syncHybridHiddenSelects();
}
function moveHybridSource(srcId, direction) {
if (!_hybridVisualOrder) return;
const idx = _hybridVisualOrder.indexOf(srcId);
if (idx < 0) return;
const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= _hybridVisualOrder.length) return;
// Swap in visual order
[_hybridVisualOrder[idx], _hybridVisualOrder[newIdx]] = [_hybridVisualOrder[newIdx], _hybridVisualOrder[idx]];
// Rebuild enabled order from visual order
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
buildHybridSourceList();
updateDownloadSourceUI();
}
function toggleHybridSource(srcId, enabled) {
_hybridSourceEnabled[srcId] = enabled;
// Rebuild enabled order from visual order so priority matches position
if (_hybridVisualOrder) {
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
}
buildHybridSourceList();
updateDownloadSourceUI();
}
function _syncHybridOrderFromDOM() {
const container = document.getElementById('hybrid-source-list');
if (!container) return;
const items = container.querySelectorAll('.hybrid-source-item');
const newOrder = [];
items.forEach(item => {
const id = item.dataset.sourceId;
if (_hybridSourceEnabled[id] !== false) {
newOrder.push(id);
}
});
_hybridSourceOrder = newOrder;
}
function _syncHybridHiddenSelects() {
// Keep hidden selects in sync for backward compat with saveSettings
const primary = document.getElementById('hybrid-primary-source');
const secondary = document.getElementById('hybrid-secondary-source');
if (primary && _hybridSourceOrder.length > 0) primary.value = _hybridSourceOrder[0];
if (secondary && _hybridSourceOrder.length > 1) secondary.value = _hybridSourceOrder[1];
}
function getHybridOrder() {
return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false);
}
function loadHybridSourceOrder(settings) {
const order = settings.download_source?.hybrid_order;
if (order && Array.isArray(order) && order.length > 0) {
_hybridSourceOrder = order;
_hybridSourceEnabled = {};
for (const src of HYBRID_SOURCES) {
_hybridSourceEnabled[src.id] = order.includes(src.id);
}
} else {
// Legacy: fall back to primary/secondary
const primary = settings.download_source?.hybrid_primary || 'soulseek';
const secondary = settings.download_source?.hybrid_secondary || 'youtube';
_hybridSourceOrder = [primary, secondary];
_hybridSourceEnabled = {};
for (const src of HYBRID_SOURCES) {
_hybridSourceEnabled[src.id] = src.id === primary || src.id === secondary;
}
}
_hybridVisualOrder = null; // Reset so buildHybridSourceList rebuilds it
buildHybridSourceList();
}
async function loadSettingsData() {
try {
const response = await fetch(API.settings);
@ -4972,9 +5109,7 @@ 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('hybrid-secondary-source').value = settings.download_source?.hybrid_secondary || 'youtube';
updateHybridSecondaryOptions();
loadHybridSourceOrder(settings);
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless';
@ -5225,10 +5360,10 @@ function updateDownloadSourceUI() {
// Determine which sources are active
let activeSources = new Set();
if (mode === 'hybrid') {
const primary = document.getElementById('hybrid-primary-source').value;
const secondary = document.getElementById('hybrid-secondary-source').value;
activeSources.add(primary);
activeSources.add(secondary);
const order = getHybridOrder();
for (const src of order) activeSources.add(src);
// Fallback: if no sources enabled, at least show soulseek
if (activeSources.size === 0) activeSources.add('soulseek');
} else {
activeSources.add(mode);
}
@ -5888,6 +6023,7 @@ async function saveSettings(quiet = false) {
mode: document.getElementById('download-source-mode').value,
hybrid_primary: document.getElementById('hybrid-primary-source').value,
hybrid_secondary: document.getElementById('hybrid-secondary-source').value,
hybrid_order: getHybridOrder(),
},
tidal_download: {
quality: document.getElementById('tidal-download-quality').value || 'lossless'

View file

@ -43657,6 +43657,115 @@ tr.tag-diff-same {
max-width: 340px;
}
/* ── Hybrid source priority list (drag and drop) ── */
.hybrid-source-list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 0;
}
.hybrid-source-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
transition: all 0.2s;
user-select: none;
}
.hybrid-source-item:hover { border-color: rgba(255, 255, 255, 0.12); }
.hybrid-source-item.disabled {
opacity: 0.35;
}
.hybrid-source-arrows {
display: flex;
flex-direction: column;
gap: 1px;
flex-shrink: 0;
}
.hybrid-arrow-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
font-size: 0.6em;
cursor: pointer;
padding: 1px 4px;
line-height: 1;
border-radius: 3px;
transition: all 0.15s;
}
.hybrid-arrow-btn:hover {
color: var(--accent-color, #1db954);
background: rgba(255, 255, 255, 0.06);
}
.hybrid-source-icon {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 4px;
object-fit: contain;
}
.hybrid-source-icon.emoji-icon {
font-size: 1.2em;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.hybrid-source-name {
flex: 1;
font-size: 0.9em;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.hybrid-source-priority {
font-size: 0.75em;
color: rgba(255, 255, 255, 0.3);
font-weight: 600;
min-width: 18px;
text-align: center;
}
.hybrid-source-toggle {
position: relative;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.hybrid-source-toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.hybrid-source-toggle .toggle-track {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.12);
border-radius: 10px;
cursor: pointer;
transition: background 0.25s;
}
.hybrid-source-toggle .toggle-track::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 0.25s;
}
.hybrid-source-toggle input:checked + .toggle-track {
background: var(--accent-color, #1db954);
}
.hybrid-source-toggle input:checked + .toggle-track::after {
transform: translateX(16px);
}
/* ── Accent color picker ── */
#settings-page .accent-color-selector {
display: flex;