diff --git a/config/settings.py b/config/settings.py
index 29acb1e2..6b59d565 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -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"
diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py
index afe475d8..e205d891 100644
--- a/core/download_orchestrator.py
+++ b/core/download_orchestrator.py
@@ -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
diff --git a/web_server.py b/web_server.py
index 276d2b56..f2cd2b9a 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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', '')
diff --git a/webui/index.html b/webui/index.html
index 8d3a0fe5..3e0c0f09 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3875,33 +3875,15 @@
-
-
-
-
- First source to try for downloads.
-
+
+ Drag to reorder source priority. Enable sources you want to use. Downloads try each enabled source in order.
-
diff --git a/webui/static/script.js b/webui/static/script.js
index 9ca9f776..1e6b9a2e 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -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 = `
+
+
+
+
+ ${src.icon
+ ? `

`
+ : `
${src.emoji}`
+ }
+
${src.name}
+
${priorityNum}
+
+ `;
+
+ 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'
diff --git a/webui/static/style.css b/webui/static/style.css
index f7a61f47..71eb2b4e 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -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;