diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py
index e4399115..df2d9c18 100644
--- a/tests/test_credentials_endpoints.py
+++ b/tests/test_credentials_endpoints.py
@@ -149,3 +149,35 @@ def test_select_rejects_wrong_service_or_missing_set(client):
# Unsupported service rejected.
assert client.post('/api/profiles/me/services/select',
json={'service': 'itunes', 'credential_id': None}).status_code == 400
+
+
+# ── Quick-switch: active source/server/download (admin=global, non-admin read-only) ──
+
+def test_active_sources_read_shape(client):
+ a = client.get('/api/profiles/me/active-sources').get_json()
+ assert a['success'] and a['editable'] is True # default session = admin
+ assert a['metadata']['active'] and len(a['metadata']['options']) == 6
+ assert len(a['server']['options']) == 4
+ assert 'mode' in a['download'] and isinstance(a['download']['hybrid_order'], list)
+
+
+def test_admin_sets_global_active_sources(client):
+ assert client.post('/api/profiles/active-sources', json={'metadata_source': 'itunes'}).get_json()['success']
+ assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'itunes'
+ # hybrid + order round-trips
+ client.post('/api/profiles/active-sources', json={'download_mode': 'hybrid', 'hybrid_order': ['hifi', 'soulseek']})
+ dl = client.get('/api/profiles/me/active-sources').get_json()['download']
+ assert dl['mode'] == 'hybrid' and dl['hybrid_order'] == ['hifi', 'soulseek']
+
+
+def test_active_sources_rejects_bad_values(client):
+ assert client.post('/api/profiles/active-sources', json={'metadata_source': 'nope'}).status_code == 400
+ assert client.post('/api/profiles/active-sources', json={'media_server': 'nope'}).status_code == 400
+ assert client.post('/api/profiles/active-sources', json={'download_mode': 'nope'}).status_code == 400
+
+
+def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile):
+ with client.session_transaction() as sess:
+ sess['profile_id'] = nonadmin_profile
+ assert client.get('/api/profiles/me/active-sources').get_json()['editable'] is False
+ assert client.post('/api/profiles/active-sources', json={'metadata_source': 'deezer'}).status_code == 403
diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py
index 7224abdd..ba08b4df 100644
--- a/tests/test_script_split_integrity.py
+++ b/tests/test_script_split_integrity.py
@@ -53,7 +53,7 @@ SPLIT_MODULES = [
# Other JS files that exist in static/ but are NOT part of the split
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js",
"enrichment-manager.js", "origin-history.js", "blocklist.js",
- "watchlist-history.js", "credential-sets.js"}
+ "watchlist-history.js", "credential-sets.js", "service-switch.js"}
# Pre-existing duplicate helper functions that lived in the original monolith.
# In a plain
+
diff --git a/webui/static/credential-sets.js b/webui/static/credential-sets.js
index deb09e9c..2745df7d 100644
--- a/webui/static/credential-sets.js
+++ b/webui/static/credential-sets.js
@@ -148,83 +148,3 @@ async function deleteCredentialSet(id, serviceName) {
}
} catch (e) { /* no-op */ }
}
-
-// ── Quick-switch modal (sidebar Service Status → switch active account) ──────
-
-function openServiceSwitchModal() {
- let overlay = document.getElementById('service-switch-overlay');
- if (!overlay) {
- overlay = document.createElement('div');
- overlay.id = 'service-switch-overlay';
- overlay.className = 'modal-overlay cred-switch-overlay';
- overlay.innerHTML = `
-
-
-
-
Quick Switch
-
Choose which account each service uses for your profile. Set up by the admin.
-
-
-
-
-
`;
- overlay.addEventListener('click', (e) => { if (e.target === overlay) closeServiceSwitchModal(); });
- document.body.appendChild(overlay);
- }
- overlay.classList.remove('hidden');
- _loadServiceSwitch();
-}
-
-function closeServiceSwitchModal() {
- const o = document.getElementById('service-switch-overlay');
- if (o) o.classList.add('hidden');
-}
-
-async function _loadServiceSwitch() {
- const body = document.getElementById('cred-switch-body');
- if (body) body.innerHTML = 'Loading…
';
- try {
- const res = await fetch('/api/profiles/me/services');
- const data = await res.json();
- _renderServiceSwitch(body, (data && data.services) || {});
- } catch (e) {
- if (body) body.innerHTML = 'Could not load.
';
- }
-}
-
-function _renderServiceSwitch(body, services) {
- // Only show services the admin has actually set up alternate accounts for.
- const rows = CRED_SERVICES.filter(svc => (services[svc.id]?.options || []).length).map(svc => {
- const info = services[svc.id];
- const selected = info.selected_id;
- const pills = [
- ``,
- ...info.options.map(o => `
- `),
- ].join('');
- return `
-
-
${svc.name}
-
${pills}
-
`;
- }).join('');
- body.innerHTML = rows ||
- 'No alternate accounts have been set up by the admin yet.
';
-}
-
-async function selectServiceCredential(service, credentialId) {
- try {
- const res = await fetch('/api/profiles/me/services/select', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ service, credential_id: credentialId }),
- });
- const data = await res.json();
- if (data.success) {
- _loadServiceSwitch(); // re-render with the new active pill
- } else if (typeof showToast === 'function') {
- showToast(data.error || 'Switch failed', 'error');
- }
- } catch (e) { /* no-op */ }
-}
diff --git a/webui/static/service-switch.js b/webui/static/service-switch.js
new file mode 100644
index 00000000..9a33bfb0
--- /dev/null
+++ b/webui/static/service-switch.js
@@ -0,0 +1,276 @@
+/*
+ * Quick-switch modal — active Metadata / Server / Download source selection.
+ * Opens from the sidebar Service Status panel; styled after the Manage Workers
+ * hub (topbar + rail + panel, brand-logo cards).
+ *
+ * Admin writes the GLOBAL active source/server/download (same as Settings).
+ * Non-admins see it read-only for now (per-profile override is a later layer):
+ * the backend reports `editable`, and the UI disables changes when false.
+ *
+ * Backend: GET /api/profiles/me/active-sources, POST /api/profiles/active-sources.
+ */
+
+const _SS_TABS = [
+ { id: 'metadata', name: 'Metadata', emoji: '🎼' },
+ { id: 'server', name: 'Server', emoji: '🖥️' },
+ { id: 'download', name: 'Download', emoji: '⬇️' },
+];
+
+// Brand logos. Metadata pulls from SOURCE_LABELS (shared-helpers.js) when
+// available; server + download have their own small maps.
+const _SS_SERVER_INFO = {
+ plex: { name: 'Plex', logo: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg' },
+ jellyfin: { name: 'Jellyfin', logo: 'https://jellyfin.org/images/logo.svg' },
+ navidrome: { name: 'Navidrome', logo: 'https://www.navidrome.org/images/navidrome-logo-200x150.png' },
+ soulsync: { name: 'SoulSync', logo: '/static/trans2.png' },
+};
+const _SS_META_FALLBACK = {
+ spotify_free: { text: 'Spotify (no auth)', icon: '🆓', logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png' },
+};
+
+let _ssState = { tab: 'metadata', data: null };
+
+function _ssEsc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+function _ssMetaInfo(id) {
+ if (typeof SOURCE_LABELS !== 'undefined' && SOURCE_LABELS[id]) return SOURCE_LABELS[id];
+ if (_SS_META_FALLBACK[id]) return _SS_META_FALLBACK[id];
+ return { text: id, icon: '🎵' };
+}
+
+function _ssDownloadInfo(id) {
+ if (typeof HYBRID_SOURCES !== 'undefined') {
+ const h = HYBRID_SOURCES.find(s => s.id === id);
+ if (h) return { name: h.name, logo: h.icon, emoji: h.emoji };
+ }
+ return { name: id, emoji: '⬇️' };
+}
+
+function openServiceSwitchModal(tab) {
+ _ssState.tab = _SS_TABS.some(t => t.id === tab) ? tab : 'metadata';
+ let overlay = document.getElementById('service-switch-overlay');
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.id = 'service-switch-overlay';
+ overlay.className = 'modal-overlay ss-overlay hidden';
+ overlay.onclick = (e) => { if (e.target === overlay) closeServiceSwitchModal(); };
+ overlay.innerHTML = `
+
+
+
+
+
Active Sources
+
What this profile uses for metadata, library, and downloads
+
+
+
+
+
`;
+ document.body.appendChild(overlay);
+ }
+ overlay.classList.remove('hidden', 'ss-closing');
+ const modal = overlay.querySelector('.ss-modal');
+ if (modal) { modal.classList.remove('ss-in'); void modal.offsetWidth; modal.classList.add('ss-in'); }
+ document.addEventListener('keydown', _ssOnKeydown);
+ _ssLoad();
+}
+
+function closeServiceSwitchModal() {
+ const o = document.getElementById('service-switch-overlay');
+ if (o) o.classList.add('hidden');
+ document.removeEventListener('keydown', _ssOnKeydown);
+}
+
+function _ssOnKeydown(e) { if (e.key === 'Escape') closeServiceSwitchModal(); }
+
+async function _ssLoad() {
+ _ssRenderRail();
+ const panel = document.getElementById('ss-panel');
+ if (panel) panel.innerHTML = 'Loading…
';
+ try {
+ const res = await fetch('/api/profiles/me/active-sources');
+ _ssState.data = await res.json();
+ } catch (e) {
+ _ssState.data = null;
+ }
+ _ssRenderPanel();
+}
+
+function _ssRenderRail() {
+ const rail = document.getElementById('ss-rail');
+ if (!rail) return;
+ rail.innerHTML = _SS_TABS.map(t => `
+ `).join('');
+}
+
+function switchServiceSwitchTab(tab) {
+ _ssState.tab = tab;
+ _ssRenderRail();
+ _ssRenderPanel();
+}
+
+function _ssCard({ logo, emoji, label, active, available, onclick, badge }) {
+ const dim = available === false ? ' ss-card--locked' : '';
+ const act = active ? ' active' : '';
+ const media = logo
+ ? `
`
+ : `${emoji || '🎵'}`;
+ return `
+ `;
+}
+
+function _ssRenderPanel() {
+ const panel = document.getElementById('ss-panel');
+ const d = _ssState.data;
+ if (!panel) return;
+ if (!d || !d.success) { panel.innerHTML = 'Could not load active sources.
'; return; }
+ const editable = !!d.editable;
+ const sub = document.getElementById('ss-topbar-sub');
+ if (sub) sub.textContent = editable
+ ? 'What this profile uses for metadata, library, and downloads'
+ : 'Set by the admin — view only for now';
+
+ if (_ssState.tab === 'metadata') {
+ const cards = d.metadata.options.map(o => {
+ const info = _ssMetaInfo(o.id);
+ return _ssCard({
+ logo: info.logo, emoji: info.icon, label: info.text || o.id,
+ active: d.metadata.active === o.id, available: o.available,
+ onclick: (editable && o.available) ? `setActiveSource('metadata','${o.id}')` : null,
+ });
+ }).join('');
+ panel.innerHTML = `Metadata source
${cards}
`;
+ } else if (_ssState.tab === 'server') {
+ const cards = d.server.options.map(o => {
+ const info = _SS_SERVER_INFO[o.id] || { name: o.id };
+ return _ssCard({
+ logo: info.logo, emoji: '🖥️', label: info.name,
+ active: d.server.active === o.id, available: o.available,
+ onclick: (editable && o.available) ? `setActiveSource('server','${o.id}')` : null,
+ });
+ }).join('');
+ panel.innerHTML = `Media server
${cards}
`;
+ } else {
+ _ssRenderDownloadPanel(panel, d, editable);
+ }
+}
+
+function _ssRenderDownloadPanel(panel, d, editable) {
+ const isHybrid = d.download.mode === 'hybrid';
+ const toggle = `
+
+
+
+
`;
+
+ let body;
+ if (isHybrid) {
+ const order = (d.download.hybrid_order && d.download.hybrid_order.length)
+ ? d.download.hybrid_order
+ : d.download.options.map(o => o.id);
+ body = `Drag to set priority — SoulSync tries each in order.
+ ` +
+ order.map((id, i) => {
+ const info = _ssDownloadInfo(id);
+ return `
+
${i + 1}
+ ${info.logo ? `

` : `
${info.emoji}`}
+
${_ssEsc(info.name)}
+
`;
+ }).join('') + `
`;
+ } else {
+ const cards = d.download.options.map(o => {
+ const info = _ssDownloadInfo(o.id);
+ return _ssCard({
+ logo: info.logo, emoji: info.emoji, label: info.name,
+ active: d.download.mode === o.id, available: true,
+ onclick: editable ? `setActiveSource('download','${o.id}')` : null,
+ });
+ }).join('');
+ body = `${cards}
`;
+ }
+ panel.innerHTML = `Download source
${toggle}${body}`;
+ if (isHybrid && editable) _ssWireHybridDrag();
+}
+
+function _ssWireHybridDrag() {
+ const list = document.getElementById('ss-hybrid-list');
+ if (!list) return;
+ list.querySelectorAll('.ss-hybrid-item').forEach(item => {
+ item.addEventListener('dragstart', (e) => {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', item.dataset.src);
+ item.classList.add('dragging');
+ });
+ item.addEventListener('dragend', () => item.classList.remove('dragging'));
+ item.addEventListener('dragover', (e) => { e.preventDefault(); });
+ item.addEventListener('drop', (e) => {
+ e.preventDefault();
+ const dragged = e.dataTransfer.getData('text/plain');
+ if (dragged && dragged !== item.dataset.src) _ssReorderHybrid(dragged, item.dataset.src);
+ });
+ });
+}
+
+function _ssReorderHybrid(draggedId, targetId) {
+ const order = (_ssState.data.download.hybrid_order && _ssState.data.download.hybrid_order.length)
+ ? _ssState.data.download.hybrid_order.slice()
+ : _ssState.data.download.options.map(o => o.id);
+ const from = order.indexOf(draggedId);
+ if (from < 0) return;
+ order.splice(from, 1);
+ const to = order.indexOf(targetId);
+ order.splice(to < 0 ? order.length : to, 0, draggedId);
+ _ssSave({ hybrid_order: order });
+}
+
+async function setActiveSource(kind, id) {
+ const key = kind === 'metadata' ? 'metadata_source' : kind === 'server' ? 'media_server' : 'download_mode';
+ await _ssSave({ [key]: id });
+}
+
+async function setDownloadMode(which) {
+ if (which === 'hybrid') {
+ await _ssSave({ download_mode: 'hybrid' });
+ } else {
+ // Switch to a single source — keep the current single choice if it was
+ // already single, else default to the first option.
+ const d = _ssState.data;
+ const cur = d.download.mode;
+ const single = (cur && cur !== 'hybrid') ? cur : (d.download.options[0] && d.download.options[0].id) || 'soulseek';
+ await _ssSave({ download_mode: single });
+ }
+}
+
+async function _ssSave(patch) {
+ try {
+ const res = await fetch('/api/profiles/active-sources', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patch),
+ });
+ const data = await res.json();
+ if (!data.success) {
+ if (typeof showToast === 'function') showToast(data.error || 'Change failed', 'error');
+ return;
+ }
+ if (typeof showToast === 'function') showToast('Updated', 'success');
+ await _ssLoad(); // re-read + re-render with the new active state
+ if (typeof fetchAndUpdateServiceStatus === 'function') fetchAndUpdateServiceStatus();
+ } catch (e) {
+ if (typeof showToast === 'function') showToast('Change failed', 'error');
+ }
+}
diff --git a/webui/static/style.css b/webui/static/style.css
index 32c7cf4f..e205e946 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -67612,3 +67612,103 @@ body.em-scroll-lock { overflow: hidden; }
background: rgb(var(--accent-light-rgb)); color: #0a0a0a;
border-color: rgb(var(--accent-light-rgb)); font-weight: 600;
}
+
+/* ── Quick-switch modal (active Metadata / Server / Download) — Manage-Workers style ── */
+.ss-overlay { display: flex; align-items: center; justify-content: center; }
+.ss-modal {
+ background: linear-gradient(160deg, #181a21 0%, #121319 100%);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 18px; width: min(760px, 94vw); max-height: 86vh;
+ display: flex; flex-direction: column; overflow: hidden;
+ box-shadow: 0 30px 80px rgba(0, 0, 0, 0.65);
+}
+.ss-modal.ss-in { animation: ss-pop 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
+@keyframes ss-pop { from { opacity: 0; transform: translateY(14px) scale(0.97); } to { opacity: 1; transform: none; } }
+
+.ss-topbar {
+ display: flex; align-items: center; gap: 14px; padding: 18px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(var(--accent-light-rgb), 0.05);
+}
+.ss-topbar-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
+.ss-topbar-logo { width: 36px; height: 36px; object-fit: contain; }
+.ss-topbar-titles { flex: 1; }
+.ss-topbar-title { margin: 0; font-size: 1.2rem; }
+.ss-topbar-sub { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-top: 2px; }
+.ss-icon-btn {
+ background: rgba(255, 255, 255, 0.06); border: none; color: #fff;
+ width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 1.1rem;
+}
+.ss-icon-btn:hover { background: rgba(255, 255, 255, 0.14); }
+
+.ss-body { display: flex; min-height: 320px; max-height: calc(86vh - 78px); }
+.ss-rail {
+ width: 168px; flex-shrink: 0; padding: 14px 10px; display: flex; flex-direction: column; gap: 6px;
+ border-right: 1px solid rgba(255, 255, 255, 0.06); background: rgba(0, 0, 0, 0.18);
+}
+.ss-tab {
+ display: flex; align-items: center; gap: 10px; padding: 11px 14px;
+ background: transparent; border: none; border-radius: 10px; cursor: pointer;
+ color: rgba(255, 255, 255, 0.65); font-size: 0.92rem; text-align: left; transition: all 0.15s;
+}
+.ss-tab:hover { background: rgba(255, 255, 255, 0.05); color: #fff; }
+.ss-tab.active {
+ background: rgba(var(--accent-light-rgb), 0.16);
+ color: rgb(var(--accent-light-rgb)); font-weight: 600;
+ box-shadow: inset 2px 0 0 rgb(var(--accent-light-rgb));
+}
+.ss-tab-emoji { font-size: 1.1rem; }
+
+.ss-panel { flex: 1; padding: 20px 22px; overflow-y: auto; }
+.ss-section-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.45); margin-bottom: 14px; }
+.ss-hint { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-bottom: 12px; }
+.ss-empty { color: rgba(255, 255, 255, 0.4); font-style: italic; padding: 30px 0; text-align: center; }
+
+.ss-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }
+.ss-card {
+ position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px;
+ padding: 18px 12px; background: rgba(255, 255, 255, 0.035);
+ border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px;
+ cursor: pointer; transition: all 0.16s; color: #fff;
+}
+.ss-card:hover:not([disabled]) { background: rgba(255, 255, 255, 0.07); transform: translateY(-2px); border-color: rgba(var(--accent-light-rgb), 0.4); }
+.ss-card.active {
+ border-color: rgb(var(--accent-light-rgb));
+ background: rgba(var(--accent-light-rgb), 0.12);
+ box-shadow: 0 0 0 1px rgb(var(--accent-light-rgb)), 0 8px 24px rgba(var(--accent-light-rgb), 0.18);
+}
+.ss-card--locked { opacity: 0.4; cursor: not-allowed; }
+.ss-card[disabled] { cursor: default; }
+.ss-card-logo { width: 42px; height: 42px; object-fit: contain; }
+.ss-card-emoji { font-size: 2rem; line-height: 1; }
+.ss-card-label { font-size: 0.85rem; text-align: center; }
+.ss-card-badge {
+ position: absolute; top: 6px; left: 6px; font-size: 0.62rem; padding: 2px 6px;
+ background: rgba(0, 0, 0, 0.5); border-radius: 6px; color: rgba(255, 255, 255, 0.7);
+}
+.ss-card-check {
+ position: absolute; top: 6px; right: 8px; color: rgb(var(--accent-light-rgb)); font-weight: 700;
+}
+
+.ss-seg { display: inline-flex; background: rgba(0, 0, 0, 0.25); border-radius: 10px; padding: 4px; margin-bottom: 16px; }
+.ss-seg-btn {
+ border: none; background: transparent; color: rgba(255, 255, 255, 0.6);
+ padding: 7px 18px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; transition: all 0.15s;
+}
+.ss-seg-btn.active { background: rgb(var(--accent-light-rgb)); color: #0a0a0a; font-weight: 600; }
+.ss-seg-btn[disabled] { cursor: default; }
+
+.ss-hybrid-list { display: flex; flex-direction: column; gap: 8px; }
+.ss-hybrid-item {
+ display: flex; align-items: center; gap: 12px; padding: 10px 14px;
+ background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 10px; cursor: grab;
+}
+.ss-hybrid-item.dragging { opacity: 0.5; cursor: grabbing; border-color: rgb(var(--accent-light-rgb)); }
+.ss-hybrid-rank {
+ width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
+ background: rgba(var(--accent-light-rgb), 0.2); color: rgb(var(--accent-light-rgb));
+ border-radius: 50%; font-size: 0.72rem; font-weight: 700;
+}
+.ss-hybrid-logo { width: 26px; height: 26px; object-fit: contain; }
+.ss-hybrid-name { font-size: 0.9rem; }