// SUPPORT MODAL // =============================== function showSupportModal() { const overlay = document.getElementById('support-modal-overlay'); if (overlay) overlay.classList.remove('hidden'); } function closeSupportModal() { const overlay = document.getElementById('support-modal-overlay'); if (overlay) overlay.classList.add('hidden'); } async function copyAddress(address, cryptoName) { try { // navigator.clipboard requires HTTPS — use fallback for HTTP (Docker) if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(address); } else { const textarea = document.createElement('textarea'); textarea.value = address; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } showToast(`${cryptoName} address copied to clipboard`, 'success'); } catch (error) { console.error('Failed to copy address:', error); // Show the address so user can copy manually showToast(`${cryptoName}: ${address}`, 'info'); } } // =============================== // SETTINGS FUNCTIONALITY // =============================== let settingsAutoSaveTimer = null; function debouncedAutoSaveSettings() { if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer); settingsAutoSaveTimer = setTimeout(() => saveSettings(true), 2000); } function handleManualSaveClick() { if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer); saveSettings(false); } function syncMetadataSourceSelection(source) { const select = document.getElementById('metadata-fallback-source'); if (!select || !source) return; const option = select.querySelector(`option[value="${source}"]`); if (option) select.value = source; select.dataset.lastValidSource = source; } function _isMetadataSourceSelectable(source) { if (source === 'spotify') { return _lastStatusPayload?.spotify?.authenticated === true; } if (source === 'discogs') { const token = document.getElementById('discogs-token'); return !!token?.value?.trim(); } return true; } function _metadataSourceFallback(source) { if (source === 'spotify') return 'deezer'; return 'deezer'; } function focusServiceSettingsSection(service, message) { const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); if (!card) return; const header = card.querySelector('.stg-service-header'); if (!card.classList.contains('expanded') && header) { toggleStgService(header); } card.scrollIntoView({ behavior: 'smooth', block: 'center' }); const firstControl = card.querySelector('input, button'); if (firstControl) { firstControl.focus({ preventScroll: true }); } if (message) { showToast(message, 'warning'); } } function sanitizeMetadataSourceSelection({ quiet = true } = {}) { const select = document.getElementById('metadata-fallback-source'); if (!select) return false; const selectedSource = select.value || 'deezer'; if (_isMetadataSourceSelectable(selectedSource)) { select.dataset.lastValidSource = selectedSource; return false; } const lastValid = select.dataset.lastValidSource; const fallbackSource = lastValid && lastValid !== selectedSource && _isMetadataSourceSelectable(lastValid) ? lastValid : _metadataSourceFallback(selectedSource); if (fallbackSource && fallbackSource !== selectedSource) { select.value = fallbackSource; } select.dataset.lastValidSource = fallbackSource; if (!quiet) { const message = selectedSource === 'discogs' ? 'Discogs requires a personal access token before it can be selected as the primary metadata source.' : 'Spotify must be authenticated before it can be selected as the primary metadata source.'; focusServiceSettingsSection(selectedSource, message); } return true; } function handleMetadataSourceChange(event) { const select = event.target; if (!select || select.id !== 'metadata-fallback-source') return; const selectedSource = select.value; if (_isMetadataSourceSelectable(selectedSource)) { select.dataset.lastValidSource = selectedSource; return; } sanitizeMetadataSourceSelection({ quiet: false }); } function initializeSettings() { // This function is called when the settings page is loaded. // It attaches event listeners to all interactive elements on the page. // Accent color listeners (live preview + custom picker toggle) initAccentColorListeners(); // Main save button (manual save, non-quiet) // Uses named function reference so addEventListener deduplicates across repeated calls const saveButton = document.getElementById('save-settings'); if (saveButton) { saveButton.addEventListener('click', handleManualSaveClick); } // Debounced auto-save on all settings inputs // Uses named function reference (debouncedAutoSaveSettings) so addEventListener deduplicates const settingsPage = document.getElementById('settings-page'); if (settingsPage) { settingsPage.querySelectorAll('input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="range"]').forEach(input => { input.addEventListener('input', debouncedAutoSaveSettings); }); settingsPage.querySelectorAll('input[type="checkbox"], select').forEach(input => { input.addEventListener('change', debouncedAutoSaveSettings); }); } const metadataSourceSelect = document.getElementById('metadata-fallback-source'); if (metadataSourceSelect) { metadataSourceSelect.addEventListener('change', handleMetadataSourceChange); } const discogsTokenInput = document.getElementById('discogs-token'); if (discogsTokenInput) { discogsTokenInput.addEventListener('input', () => { if (typeof syncPrimaryMetadataSourceAvailability === 'function') { syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.spotify || null); } sanitizeMetadataSourceSelection({ quiet: true }); }); } // Server toggle buttons const plexToggle = document.getElementById('plex-toggle'); if (plexToggle) { plexToggle.addEventListener('click', () => toggleServer('plex')); } const jellyfinToggle = document.getElementById('jellyfin-toggle'); if (jellyfinToggle) { jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin')); } // Auto-detect buttons const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button'); if (detectSlskdBtn) { detectSlskdBtn.addEventListener('click', autoDetectSlskd); } const detectPlexBtn = document.querySelector('#plex-container .detect-button'); if (detectPlexBtn) { detectPlexBtn.addEventListener('click', autoDetectPlex); } const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button'); if (detectJellyfinBtn) { detectJellyfinBtn.addEventListener('click', autoDetectJellyfin); } // Test connection buttons // Test button event listeners removed - they use onclick attributes in HTML to avoid double firing if (typeof syncPrimaryMetadataSourceAvailability === 'function') { syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.spotify || null); } syncSpotifySettingsAuthState(_lastStatusPayload?.spotify || null); syncMetadataSourceSelection(_lastStatusPayload?.metadata_source?.source); sanitizeMetadataSourceSelection({ quiet: true }); if (metadataSourceSelect) { metadataSourceSelect.dataset.lastValidSource = metadataSourceSelect.value; } } function resetFileOrganizationTemplates() { // Reset templates to defaults const defaults = { album: '$albumartist/$albumartist - $album/$track - $title', single: '$artist/$artist - $title/$title', playlist: '$playlist/$artist - $title', video: '$artist/$title-video' }; document.getElementById('template-album-path').value = defaults.album; document.getElementById('template-single-path').value = defaults.single; document.getElementById('template-playlist-path').value = defaults.playlist; document.getElementById('template-video-path').value = defaults.video; debouncedAutoSaveSettings(); } function validateFileOrganizationTemplates() { const errors = []; // Valid variables for each template type const validVars = { album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$discnum', '$cdnum', '$year', '$quality'], single: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$year', '$quality'], playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality'], video: ['$artist', '$artistletter', '$title', '$year'] }; // Get template values const albumPath = document.getElementById('template-album-path').value.trim(); const singlePath = document.getElementById('template-single-path').value.trim(); const playlistPath = document.getElementById('template-playlist-path').value.trim(); // Validate album template if (albumPath) { if (albumPath.endsWith('/')) { errors.push('Album template cannot end with /'); } if (albumPath.startsWith('/')) { errors.push('Album template cannot start with /'); } if (!albumPath.includes('/')) { errors.push('Album template must include at least one folder (use / separator)'); } if (albumPath.includes('//')) { errors.push('Album template cannot have consecutive slashes //'); } // Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.) const albumVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; const foundVars = albumPath.match(albumVarPattern) || []; foundVars.forEach(v => { // Normalize ${var} to $var for validation const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; const lowerVar = normalized.toLowerCase(); // Check if lowercase version exists in valid vars const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar); if (!isValid) { errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`); } else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) { // Variable is valid but has wrong case errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); } }); } // Validate single template if (singlePath) { if (singlePath.endsWith('/')) { errors.push('Single template cannot end with /'); } if (singlePath.startsWith('/')) { errors.push('Single template cannot start with /'); } // Note: single template is allowed to have no slash (flat file: "$artist - $title") if (singlePath.includes('//')) { errors.push('Single template cannot have consecutive slashes //'); } const singleVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; const foundVars = singlePath.match(singleVarPattern) || []; foundVars.forEach(v => { const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; const lowerVar = normalized.toLowerCase(); const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar); if (!isValid) { errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`); } else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) { errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); } }); } // Validate playlist template if (playlistPath) { if (playlistPath.endsWith('/')) { errors.push('Playlist template cannot end with /'); } if (playlistPath.startsWith('/')) { errors.push('Playlist template cannot start with /'); } if (!playlistPath.includes('/')) { errors.push('Playlist template must include at least one folder (use / separator)'); } if (playlistPath.includes('//')) { errors.push('Playlist template cannot have consecutive slashes //'); } const playlistVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; const foundVars = playlistPath.match(playlistVarPattern) || []; foundVars.forEach(v => { const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; const lowerVar = normalized.toLowerCase(); const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar); if (!isValid) { errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`); } else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) { errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); } }); } return errors; } // Settings redesign — tab switching + service accordions function switchSettingsTab(tab) { // Update tab bar document.querySelectorAll('.stg-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); // Show/hide settings groups and section headers by data-stg attribute document.querySelectorAll('#settings-page [data-stg]').forEach(g => { g.style.display = g.dataset.stg === tab ? '' : 'none'; }); // Re-apply collapsed state on section bodies (tab switch resets inline display) document.querySelectorAll('#settings-page .settings-section-body.collapsed').forEach(b => { b.style.display = 'none'; }); // Also hide/show the column wrappers if they're empty in this tab document.querySelectorAll('#settings-page .settings-left-column, #settings-page .settings-right-column, #settings-page .settings-third-column').forEach(col => { const hasVisible = Array.from(col.querySelectorAll('.settings-group[data-stg]')).some(g => g.style.display !== 'none'); col.style.display = hasVisible ? '' : 'none'; }); // Re-apply conditional visibility (quality profile, source containers, etc.) if (typeof updateDownloadSourceUI === 'function') { try { updateDownloadSourceUI(); } catch (e) { } } // Load DB maintenance info when switching to Advanced tab if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') { try { loadDbMaintenanceInfo(); } catch (e) { } } // Initialize live log viewer when switching to Logs tab if (tab === 'logs') { _logViewerInit(); } else { _logViewerStop(); } // Refresh the green/yellow header gradient when arriving on Connections if (tab === 'connections') { try { applyServiceStatusGradients(); } catch (e) { } } } // ── Settings → Connections: per-service status gradient + verify wiring ── // Gradient shows green when the user has filled in credentials, yellow when empty. // It's based purely on config presence (cheap, no API calls). The verify layer — // which runs on expand / Expand All — surfaces whether those credentials actually // work, via an inline warning bar inside the expanded panel. let _stgServiceStatusState = {}; // service -> {configured: bool} let _stgServiceVerifyInFlight = {}; // service -> true while a verify call is running async function applyServiceStatusGradients() { try { const resp = await fetch('/api/settings/config-status'); if (!resp.ok) return; const data = await resp.json(); _stgServiceStatusState = data || {}; document.querySelectorAll('#settings-page .stg-service[data-service]').forEach(card => { const service = card.getAttribute('data-service'); const header = card.querySelector('.stg-service-header'); if (!service || !header) return; const configured = !!(data[service] && data[service].configured); header.classList.toggle('status-configured', configured); header.classList.toggle('status-missing', !configured); // Ensure the header has a spinner placeholder for the verify-checking state if (!header.querySelector('.stg-service-verify-spinner')) { const spinner = document.createElement('span'); spinner.className = 'stg-service-verify-spinner'; // Insert before the chevron on the right const chevron = header.querySelector('.stg-service-chevron'); if (chevron) header.insertBefore(spinner, chevron); else header.appendChild(spinner); } }); syncSpotifySettingsAuthState(_lastStatusPayload?.spotify || null); } catch (e) { console.warn('[Settings Status] Failed to apply gradients:', e); } } function syncSpotifySettingsAuthState(statusData) { if (!statusData) return; const card = document.querySelector('#settings-page .stg-service[data-service="spotify"]'); if (!card) return; const header = card.querySelector('.stg-service-header'); const dot = card.querySelector('.stg-service-dot'); if (!header && !dot) return; const authenticated = statusData?.authenticated === true; const rateLimited = !!(statusData?.rate_limited && statusData?.rate_limit); const cooldown = !!(statusData?.post_ban_cooldown > 0); const needsAttention = !authenticated || rateLimited || cooldown; if (header) { header.classList.toggle('status-configured', !needsAttention); header.classList.toggle('status-missing', needsAttention); } if (dot) { dot.style.color = needsAttention ? '#f1c40f' : '#1DB954'; } } function _stgSetCheckingState(service, isChecking) { const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); if (!card) return; const header = card.querySelector('.stg-service-header'); const body = card.querySelector('.stg-service-body'); if (header) { header.classList.toggle('status-checking', !!isChecking); // Lazy-create the spinner element so it's there even if // applyServiceStatusGradients() hasn't run yet. if (!header.querySelector('.stg-service-verify-spinner')) { const spinner = document.createElement('span'); spinner.className = 'stg-service-verify-spinner'; const chevron = header.querySelector('.stg-service-chevron'); if (chevron) header.insertBefore(spinner, chevron); else header.appendChild(spinner); } } if (!body) return; const existing = body.querySelector('.stg-service-verify-status'); if (isChecking) { if (!existing) { const status = document.createElement('div'); status.className = 'stg-service-verify-status'; status.textContent = 'Testing connection…'; body.insertBefore(status, body.firstChild); } } else if (existing) { existing.remove(); } } function _stgShowVerifyWarning(service, message) { const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); if (!card) return; const body = card.querySelector('.stg-service-body'); if (!body) return; const existing = body.querySelector('.stg-service-warning'); if (existing) existing.remove(); const warning = document.createElement('div'); warning.className = 'stg-service-warning'; warning.innerHTML = ` `; warning.querySelector('.stg-service-warning-text').textContent = message || 'Connection test failed.'; body.insertBefore(warning, body.firstChild); } function _stgClearVerifyWarning(service) { const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); if (!card) return; const existing = card.querySelector('.stg-service-warning'); if (existing) existing.remove(); } async function _stgRefreshAfterSave() { // Called after a successful settings save. Cheap gradient refresh always, // plus re-verify any cards the user currently has expanded (so they see // immediate feedback on credentials they just edited). Collapsed cards // keep their cached verify result until the user expands them. try { await applyServiceStatusGradients(); const expandedServices = Array.from( document.querySelectorAll('#settings-page .stg-service.expanded[data-service]') ) .map(card => card.getAttribute('data-service')) .filter(Boolean); if (expandedServices.length > 0) { _stgVerifyServices(expandedServices, { force: true }); } } catch (e) { console.warn('[Settings Status] Post-save refresh failed:', e); } } async function _stgVerifyServices(services, { force = false } = {}) { if (!services || !services.length) return {}; // Mark all as checking immediately so the user sees spinners/status lines services.forEach(svc => { _stgServiceVerifyInFlight[svc] = true; _stgSetCheckingState(svc, true); _stgClearVerifyWarning(svc); }); try { const url = '/api/settings/verify' + (force ? '?force=true' : ''); const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ services }) }); const data = await resp.json(); services.forEach(svc => { _stgServiceVerifyInFlight[svc] = false; _stgSetCheckingState(svc, false); const result = data[svc]; if (result && result.success === false) { _stgShowVerifyWarning(svc, result.error || result.message || ''); } }); return data; } catch (e) { console.warn('[Settings Verify] Network error:', e); services.forEach(svc => { _stgServiceVerifyInFlight[svc] = false; _stgSetCheckingState(svc, false); _stgShowVerifyWarning(svc, 'Unable to reach the verification endpoint.'); }); return {}; } } function toggleStgService(el) { const service = el.closest('.stg-service'); if (service) { const wasExpanded = service.classList.contains('expanded'); service.classList.toggle('expanded'); // Fire verify when expanding a single card (not on collapse). The backend // caches per service for 5 min, so rapid expand/collapse won't re-ping. if (!wasExpanded) { const serviceName = service.getAttribute('data-service'); if (serviceName && !_stgServiceVerifyInFlight[serviceName]) { _stgVerifyServices([serviceName]); } } } } function toggleAllServiceAccordions(btn) { const services = document.querySelectorAll('#settings-page .stg-service'); const allExpanded = Array.from(services).every(s => s.classList.contains('expanded')); const willExpand = !allExpanded; services.forEach(s => s.classList.toggle('expanded', willExpand)); btn.textContent = allExpanded ? 'Expand All' : 'Collapse All'; // On Expand All, fire a single batched verify for every service that has a // data-service attribute. Backend caps concurrency at 3 to avoid rate limits. // Skipped on Collapse All. if (willExpand) { const serviceNames = Array.from(services) .map(s => s.getAttribute('data-service')) .filter(Boolean) .filter(name => !_stgServiceVerifyInFlight[name]); if (serviceNames.length > 0) { _stgVerifyServices(serviceNames); } } } // ── 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: '🎶' }, { id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' }, { id: 'amazon', name: 'Amazon Music', icon: null, emoji: '🛒' }, { id: 'lidarr', name: 'Lidarr', icon: null, emoji: '📦' }, { id: 'soundcloud', name: 'SoundCloud', icon: 'https://www.svgrepo.com/show/452219/soundcloud.svg', emoji: '☁️' }, { id: 'torrent', name: 'Torrent', icon: null, emoji: '🧲' }, { id: 'usenet', name: 'Usenet', icon: null, emoji: '📰' }, ]; let _hybridSourceOrder = ['soulseek', 'youtube']; let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, amazon: false, lidarr: false, soundcloud: false, torrent: false, usenet: 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.name}` : `${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(); debouncedAutoSaveSettings(); } 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(); debouncedAutoSaveSettings(); } 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; const sourceStatus = settings._source_status || {}; 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; } } // Auto-disable sources that aren't configured on the server let changed = false; for (const src of HYBRID_SOURCES) { if (_hybridSourceEnabled[src.id] && sourceStatus[src.id] === false) { _hybridSourceEnabled[src.id] = false; changed = true; } } if (changed) { _hybridSourceOrder = _hybridSourceOrder.filter(id => _hybridSourceEnabled[id] !== false); } _hybridVisualOrder = null; // Reset so buildHybridSourceList rebuilds it buildHybridSourceList(); } function updateLossyBitrateOptions() { const codec = document.getElementById('lossy-copy-codec')?.value || 'mp3'; const bitrateSelect = document.getElementById('lossy-copy-bitrate'); if (!bitrateSelect) return; const opt320 = bitrateSelect.querySelector('option[value="320"]'); if (codec === 'opus') { // Opus max is 256kbps per channel — hide 320 option if (opt320) opt320.disabled = true; if (bitrateSelect.value === '320') bitrateSelect.value = '256'; } else { if (opt320) opt320.disabled = false; } } function updatePlexConfigurationButtons() { const plexUrl = document.getElementById('plex-url'); const plexToken = document.getElementById('plex-token'); const hasPlexConfig = Boolean((plexUrl?.value || '').trim() || (plexToken?.value || '').trim()); const plexViewConfigButton = document.getElementById('plex-view-config-button'); const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); const plexManualConfigButton = document.getElementById('plex-manual-config-button'); const plexUrlActions = document.getElementById('plex-url-actions'); const plexTokenActions = document.getElementById('plex-token-actions'); const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); if (plexViewConfigButton) plexViewConfigButton.style.display = hasPlexConfig ? '' : 'none'; if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = hasPlexConfig ? 'none' : ''; if (plexManualConfigButton) plexManualConfigButton.style.display = hasPlexConfig ? 'none' : ''; if (plexUrlActions) plexUrlActions.style.display = hasPlexConfig ? 'none' : 'flex'; if (plexTokenActions) plexTokenActions.style.display = hasPlexConfig ? 'none' : 'flex'; if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; } async function loadSettingsData() { try { const response = await fetch(API.settings); const settings = await response.json(); // Populate Spotify settings document.getElementById('spotify-client-id').value = settings.spotify?.client_id || ''; document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || ''; document.getElementById('spotify-redirect-uri').value = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; document.getElementById('spotify-callback-display').textContent = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; // Populate Tidal settings document.getElementById('tidal-client-id').value = settings.tidal?.client_id || ''; document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || ''; document.getElementById('tidal-redirect-uri').value = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; document.getElementById('tidal-callback-display').textContent = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; // Populate Deezer OAuth settings document.getElementById('deezer-app-id').value = settings.deezer?.app_id || ''; document.getElementById('deezer-app-secret').value = settings.deezer?.app_secret || ''; document.getElementById('deezer-redirect-uri').value = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback'; document.getElementById('deezer-callback-display').textContent = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback'; // Add event listeners to update display URLs when input changes document.getElementById('spotify-redirect-uri').addEventListener('input', function () { document.getElementById('spotify-callback-display').textContent = this.value || 'http://127.0.0.1:8888/callback'; }); document.getElementById('tidal-redirect-uri').addEventListener('input', function () { document.getElementById('tidal-callback-display').textContent = this.value || 'http://127.0.0.1:8889/tidal/callback'; }); document.getElementById('deezer-redirect-uri').addEventListener('input', function () { document.getElementById('deezer-callback-display').textContent = this.value || 'http://127.0.0.1:8008/deezer/callback'; }); // Populate Plex settings const plexUrlInput = document.getElementById('plex-url'); const plexTokenInput = document.getElementById('plex-token'); if (plexUrlInput) plexUrlInput.value = settings.plex?.base_url || ''; if (plexTokenInput) plexTokenInput.value = settings.plex?.token || ''; if (plexUrlInput) plexUrlInput.addEventListener('input', updatePlexConfigurationButtons); if (plexTokenInput) plexTokenInput.addEventListener('input', updatePlexConfigurationButtons); updatePlexConfigurationButtons(); // Populate Jellyfin settings document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || ''; document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || ''; document.getElementById('jellyfin-timeout').value = settings.jellyfin?.api_timeout || 120; // Populate Navidrome settings document.getElementById('navidrome-url').value = settings.navidrome?.base_url || ''; document.getElementById('navidrome-username').value = settings.navidrome?.username || ''; document.getElementById('navidrome-password').value = settings.navidrome?.password || ''; // Set active server and toggle visibility const activeServer = settings.active_media_server || 'plex'; toggleServer(activeServer); // Load Plex music libraries if Plex is the active server if (activeServer === 'plex') { loadPlexMusicLibraries(); } // Load Jellyfin users and music libraries if Jellyfin is the active server if (activeServer === 'jellyfin') { loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); } // Load Navidrome music folders if Navidrome is the active server if (activeServer === 'navidrome') { loadNavidromeMusicFolders(); } // Populate Soulseek settings document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || ''; document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || ''; document.getElementById('soulseek-search-timeout').value = settings.soulseek?.search_timeout || 60; document.getElementById('soulseek-search-timeout-buffer').value = settings.soulseek?.search_timeout_buffer || 15; document.getElementById('soulseek-search-min-delay-seconds').value = settings.soulseek?.search_min_delay_seconds ?? 0; document.getElementById('soulseek-min-peer-speed').value = settings.soulseek?.min_peer_upload_speed || 0; document.getElementById('soulseek-max-peer-queue').value = settings.soulseek?.max_peer_queue || 0; document.getElementById('soulseek-download-timeout').value = Math.round((settings.soulseek?.download_timeout || 600) / 60); document.getElementById('soulseek-auto-clear-searches').checked = settings.soulseek?.auto_clear_searches !== false; // Populate ListenBrainz settings document.getElementById('listenbrainz-base-url').value = settings.listenbrainz?.base_url || ''; document.getElementById('listenbrainz-token').value = settings.listenbrainz?.token || ''; // Populate AcoustID settings document.getElementById('acoustid-api-key').value = settings.acoustid?.api_key || ''; document.getElementById('acoustid-enabled').checked = settings.acoustid?.enabled || false; // Populate Last.fm settings document.getElementById('lastfm-api-key').value = settings.lastfm?.api_key || ''; document.getElementById('lastfm-api-secret').value = settings.lastfm?.api_secret || ''; document.getElementById('lastfm-scrobble-enabled').checked = settings.lastfm?.scrobble_enabled === true; const lfmStatus = document.getElementById('lastfm-scrobble-status'); if (lfmStatus) { lfmStatus.textContent = settings.lastfm?.session_key ? 'Authorized' : 'Not authorized'; } // Populate ListenBrainz scrobble toggle document.getElementById('listenbrainz-scrobble-enabled').checked = settings.listenbrainz?.scrobble_enabled === true; // Populate Genius settings document.getElementById('genius-access-token').value = settings.genius?.access_token || ''; // Populate iTunes settings document.getElementById('itunes-country').value = settings.itunes?.country || 'US'; // Populate Discogs settings document.getElementById('discogs-token').value = settings.discogs?.token || ''; // Populate Metadata source setting document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'deezer'; // Populate Hydrabase settings const hbConfig = settings.hydrabase || {}; document.getElementById('hydrabase-url').value = hbConfig.url || ''; document.getElementById('hydrabase-api-key').value = hbConfig.api_key || ''; document.getElementById('hydrabase-auto-connect').checked = hbConfig.auto_connect || false; // Check live connection status + add Hydrabase to fallback dropdown if connected fetch('/api/hydrabase/status').then(r => r.json()).then(s => { const btn = document.getElementById('hydrabase-connect-btn'); const statusEl = document.getElementById('hydrabase-settings-status'); if (s.connected) { if (btn) btn.textContent = 'Disconnect'; if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; } // Add Hydrabase to fallback source dropdown const fbSelect = document.getElementById('metadata-fallback-source'); if (fbSelect && !fbSelect.querySelector('option[value="hydrabase"]')) { const opt = document.createElement('option'); opt.value = 'hydrabase'; opt.textContent = 'Hydrabase (P2P)'; fbSelect.appendChild(opt); } // Restore selection if it was hydrabase if ((settings.metadata?.fallback_source) === 'hydrabase') { fbSelect.value = 'hydrabase'; } } }).catch(() => { }); // Populate Download settings (right column) document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads'; document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer'; document.getElementById('staging-path').value = settings.import?.staging_path || './Staging'; document.getElementById('music-videos-path').value = settings.library?.music_videos_path || './MusicVideos'; // Populate Download Source settings document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; document.getElementById('stream-source').value = settings.download_source?.stream_source || 'youtube'; document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3'; loadHybridSourceOrder(settings); document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless'; document.getElementById('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false; document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless'; document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false; document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless'; document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false; loadHiFiInstances(); document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac'; document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false; document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || ''; document.getElementById('amazon-quality').value = settings.amazon_download?.quality || 'flac'; document.getElementById('amazon-allow-fallback').checked = settings.amazon_download?.allow_fallback !== false; document.getElementById('lidarr-url').value = settings.lidarr_download?.url || ''; document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || ''; const _prowUrl = document.getElementById('prowlarr-url'); const _prowKey = document.getElementById('prowlarr-api-key'); const _prowIds = document.getElementById('prowlarr-indexer-ids'); if (_prowUrl) _prowUrl.value = settings.prowlarr?.url || ''; if (_prowKey) _prowKey.value = settings.prowlarr?.api_key || ''; if (_prowIds) _prowIds.value = settings.prowlarr?.indexer_ids || ''; const _tcType = document.getElementById('torrent-client-type'); const _tcUrl = document.getElementById('torrent-client-url'); const _tcUser = document.getElementById('torrent-client-username'); const _tcPass = document.getElementById('torrent-client-password'); const _tcCat = document.getElementById('torrent-client-category'); const _tcPath = document.getElementById('torrent-client-save-path'); if (_tcType) _tcType.value = settings.torrent_client?.type || 'qbittorrent'; if (_tcUrl) _tcUrl.value = settings.torrent_client?.url || ''; if (_tcUser) _tcUser.value = settings.torrent_client?.username || ''; if (_tcPass) _tcPass.value = settings.torrent_client?.password || ''; if (_tcCat) _tcCat.value = settings.torrent_client?.category || 'soulsync'; if (_tcPath) _tcPath.value = settings.torrent_client?.save_path || ''; const _ucType = document.getElementById('usenet-client-type'); const _ucUrl = document.getElementById('usenet-client-url'); const _ucKey = document.getElementById('usenet-client-api-key'); const _ucUser = document.getElementById('usenet-client-username'); const _ucPass = document.getElementById('usenet-client-password'); const _ucCat = document.getElementById('usenet-client-category'); if (_ucType) _ucType.value = settings.usenet_client?.type || 'sabnzbd'; if (_ucUrl) _ucUrl.value = settings.usenet_client?.url || ''; if (_ucKey) _ucKey.value = settings.usenet_client?.api_key || ''; if (_ucUser) _ucUser.value = settings.usenet_client?.username || ''; if (_ucPass) _ucPass.value = settings.usenet_client?.password || ''; if (_ucCat) _ucCat.value = settings.usenet_client?.category || 'soulsync'; if (typeof updateUsenetClientUI === 'function') updateUsenetClientUI(); // Sync ARL to connections tab field + bidirectional listeners const _connArl = document.getElementById('deezer-connection-arl'); const _dlArl = document.getElementById('deezer-download-arl'); if (_connArl) _connArl.value = settings.deezer_download?.arl || ''; if (_connArl && _dlArl) { _connArl.addEventListener('input', () => { _dlArl.value = _connArl.value; }); _dlArl.addEventListener('input', () => { _connArl.value = _dlArl.value; }); } // Populate YouTube settings document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || ''; document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3; // Update UI based on download source mode updateDownloadSourceUI(); // Populate Database settings document.getElementById('max-workers').value = settings.database?.max_workers || '5'; // Populate Post-Processing settings document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false; document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false; document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false; document.getElementById('prefer-caa-art').checked = settings.metadata_enhancement?.prefer_caa_art === true; document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false; document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true; document.getElementById('duration-tolerance-seconds').value = settings.post_processing?.duration_tolerance_seconds ?? 0; // Load service master toggles document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false; document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false; document.getElementById('embed-musicbrainz').checked = settings.musicbrainz?.embed_tags !== false; document.getElementById('embed-deezer').checked = settings.deezer?.embed_tags !== false; document.getElementById('embed-audiodb').checked = settings.audiodb?.embed_tags !== false; document.getElementById('embed-tidal').checked = settings.tidal?.embed_tags !== false; document.getElementById('embed-qobuz').checked = settings.qobuz?.embed_tags !== false; document.getElementById('embed-lastfm').checked = settings.lastfm?.embed_tags !== false; document.getElementById('embed-genius').checked = settings.genius?.embed_tags !== false; document.getElementById('embed-hifi').checked = settings.hifi?.embed_tags !== false; // Load per-tag toggles from data-config attributes document.querySelectorAll('[data-config]').forEach(cb => { const path = cb.dataset.config.split('.'); let val = settings; for (const key of path) { val = val?.[key]; } cb.checked = val !== false; }); // Apply service disabled state to child tags ['spotify', 'itunes', 'musicbrainz', 'deezer', 'audiodb', 'tidal', 'qobuz', 'lastfm', 'genius', 'hifi'].forEach(svc => { const master = document.getElementById('embed-' + svc); if (master) toggleServiceTags(master, svc); }); document.getElementById('post-processing-options').style.display = settings.metadata_enhancement?.enabled !== false ? 'block' : 'none'; // Populate File Organization settings document.getElementById('file-organization-enabled').checked = settings.file_organization?.enabled !== false; document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title'; document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title'; document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title'; document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video'; document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc'; document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first'; document.getElementById('artist-separator').value = settings.metadata_enhancement?.tags?.artist_separator || ', '; document.getElementById('write-multi-artist').checked = settings.metadata_enhancement?.tags?.write_multi_artist || false; document.getElementById('feat-in-title').checked = settings.metadata_enhancement?.tags?.feat_in_title || false; document.getElementById('allow-duplicate-tracks').checked = settings.wishlist?.allow_duplicate_tracks !== false; // Populate Playlist Sync settings document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false; // Populate Post-Download Conversion settings document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true; document.getElementById('lossy-copy-enabled').checked = settings.lossy_copy?.enabled === true; document.getElementById('lossy-copy-codec').value = settings.lossy_copy?.codec || 'mp3'; document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320'; updateLossyBitrateOptions(); document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true; // Populate Listening Stats settings document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true; document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30; document.getElementById('lossy-copy-options').style.display = settings.lossy_copy?.enabled ? 'block' : 'none'; // Populate Music Library Paths const _musicPaths = settings.library?.music_paths || []; renderMusicPaths(_musicPaths); // Populate Content Filter settings document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false; // Populate Genre Whitelist const gwEnabled = settings.genre_whitelist?.enabled === true; document.getElementById('genre-whitelist-enabled').checked = gwEnabled; const gwContainer = document.getElementById('genre-whitelist-container'); if (gwContainer) gwContainer.style.display = gwEnabled ? '' : 'none'; if (gwEnabled) { _genreWhitelistRender(settings.genre_whitelist?.genres || []); } // Populate Import settings document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true; // Populate M3U Export settings document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true; document.getElementById('m3u-entry-base-path').value = settings.m3u_export?.entry_base_path || ''; // Populate UI Appearance settings const accentPreset = settings.ui_appearance?.accent_preset || '#1db954'; const accentCustom = settings.ui_appearance?.accent_color || '#1db954'; const presetSelect = document.getElementById('accent-preset'); const customPicker = document.getElementById('accent-custom-color'); const customGroup = document.getElementById('custom-color-group'); if (presetSelect) { // Check if the saved preset matches a dropdown option const presetOptions = Array.from(presetSelect.options).map(o => o.value); if (presetOptions.includes(accentPreset)) { presetSelect.value = accentPreset; } else { presetSelect.value = 'custom'; } if (presetSelect.value === 'custom') { if (customGroup) customGroup.style.display = ''; if (customPicker) customPicker.value = accentCustom; applyAccentColor(accentCustom); } else { if (customGroup) customGroup.style.display = 'none'; applyAccentColor(accentPreset); } } // Sidebar visualizer type const vizType = settings.ui_appearance?.sidebar_visualizer || 'bars'; const vizSelect = document.getElementById('sidebar-visualizer-type'); if (vizSelect) vizSelect.value = vizType; sidebarVisualizerType = vizType; // Background particles toggle const particlesEnabled = settings.ui_appearance?.particles_enabled !== false; // default true const particlesCheckbox = document.getElementById('particles-enabled'); if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled; applyParticlesSetting(particlesEnabled); // Worker orbs toggle const workerOrbsEnabled = settings.ui_appearance?.worker_orbs_enabled !== false; // default true const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled'); if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled; applyWorkerOrbsSetting(workerOrbsEnabled); // Reduce effects toggle const reduceEffects = settings.ui_appearance?.reduce_effects === true; // default false const reduceCheckbox = document.getElementById('reduce-effects-enabled'); if (reduceCheckbox) reduceCheckbox.checked = reduceEffects; applyReduceEffects(reduceEffects); // Populate Logging information const logLevelSelect = document.getElementById('log-level-select'); if (logLevelSelect) logLevelSelect.value = settings.logging?.level || 'INFO'; document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; // Load Discovery Lookback Period setting try { const lookbackResponse = await fetch('/api/discovery/lookback-period'); const lookbackData = await lookbackResponse.json(); if (lookbackData.period) { document.getElementById('discovery-lookback-period').value = lookbackData.period; } } catch (error) { console.error('Error loading discovery lookback period:', error); } // Load Hemisphere setting try { const hemiResponse = await fetch('/api/discovery/hemisphere'); const hemiData = await hemiResponse.json(); if (hemiData.hemisphere) { document.getElementById('discovery-hemisphere').value = hemiData.hemisphere; } } catch (error) { console.error('Error loading hemisphere setting:', error); } // Load current log level try { const logLevelResponse = await fetch('/api/settings/log-level'); const logLevelData = await logLevelResponse.json(); if (logLevelData.success && logLevelData.level) { document.getElementById('log-level-select').value = logLevelData.level; } } catch (error) { console.error('Error loading log level:', error); } // Load security settings try { const requirePin = settings.security?.require_pin_on_launch || false; document.getElementById('security-require-pin').checked = requirePin; // CORS origins — stored verbatim as the user typed (string). const corsOrigins = settings.security?.cors_origins || ''; const corsField = document.getElementById('security-cors-origins'); if (corsField) corsField.value = corsOrigins; // Check if admin has a PIN set const profilesRes = await fetch('/api/profiles'); const profilesData = await profilesRes.json(); const adminProfile = (profilesData.profiles || []).find(p => p.is_admin); const adminHasPin = adminProfile?.has_pin || false; // Show/hide PIN setup vs change sections document.getElementById('security-pin-setup').style.display = adminHasPin ? 'none' : 'block'; document.getElementById('security-change-pin-section').style.display = adminHasPin ? 'block' : 'none'; // If no PIN, disable the toggle if (!adminHasPin) { document.getElementById('security-require-pin').checked = false; document.getElementById('security-require-pin').disabled = true; } } catch (error) { console.error('Error loading security settings:', error); } // Check dev mode status try { const devResponse = await fetch('/api/dev-mode'); const devData = await devResponse.json(); if (devData.enabled) { document.getElementById('dev-mode-status').textContent = 'Active'; document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))'; document.getElementById('hydrabase-nav').style.display = ''; document.getElementById('hydrabase-button-container').style.display = ''; } } catch (error) { console.error('Error checking dev mode:', error); } } catch (error) { console.error('Error loading settings:', error); showToast('Failed to load settings', 'error'); } } async function changeLogLevel() { const selector = document.getElementById('log-level-select'); const level = selector.value; try { const response = await fetch('/api/settings/log-level', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: level }) }); const data = await response.json(); if (data.success) { showToast(`Log level changed to ${level}`, 'success'); console.log(`Log level changed to: ${level}`); } else { showToast(`Failed to change log level: ${data.error}`, 'error'); } } catch (error) { console.error('Error changing log level:', error); showToast('Failed to change log level', 'error'); } } function updateMediaServerFields() { const serverType = document.getElementById('media-server-type').value; const urlInput = document.getElementById('media-server-url'); const tokenInput = document.getElementById('media-server-token'); if (serverType === 'plex') { urlInput.placeholder = 'http://localhost:32400'; tokenInput.placeholder = 'Plex Token'; } else { urlInput.placeholder = 'http://localhost:8096'; tokenInput.placeholder = 'Jellyfin API Key'; } } let _plexPinAuthRequestId = null; let _plexPinAuthPollInterval = null; function showPlexConfiguration(disableFields = false, isManualConfig = false) { stopPlexPinAuthPolling(); const plexConfig = document.getElementById('plex-configuration'); const plexSetup = document.getElementById('plex-setup'); const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); const plexUrl = document.getElementById('plex-url'); const plexToken = document.getElementById('plex-token'); const plexLibraryContainer = document.getElementById('plex-library-selector-container'); if (plexConfig) plexConfig.style.display = ''; if (plexSetup) plexSetup.style.display = 'none'; if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; if (plexUrl) plexUrl.disabled = disableFields; if (plexToken) plexToken.disabled = disableFields; if (plexLibraryContainer && isManualConfig) { plexLibraryContainer.style.display = 'none'; } setPlexConfigActionButton(isManualConfig); updatePlexConfigurationButtons(); } function showPlexSetup() { const plexConfig = document.getElementById('plex-configuration'); const plexSetup = document.getElementById('plex-setup'); const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); const plexLibraryContainer = document.getElementById('plex-library-selector-container'); if (plexConfig) plexConfig.style.display = 'none'; if (plexSetup) plexSetup.style.display = ''; if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; if (plexLibraryContainer) plexLibraryContainer.style.display = 'none'; setPlexConfigActionButton(false); } function setPlexConfigActionButton(isManualConfig) { const actionButton = document.getElementById('plex-config-action-button'); if (!actionButton) return; if (isManualConfig) { actionButton.textContent = 'Cancel'; actionButton.onclick = showPlexSetup; actionButton.title = 'Cancel manual Plex configuration'; } else { actionButton.textContent = 'Clear Configuration'; actionButton.onclick = clearPlexConfiguration; actionButton.title = 'Clear saved Plex configuration'; } } async function startPlexPinAuth() { const setupButtons = document.getElementById('plex-setup-buttons'); const authFlow = document.getElementById('plex-pin-auth-flow'); const statusEl = document.getElementById('plex-pin-status'); if (setupButtons) setupButtons.style.display = 'none'; if (authFlow) authFlow.style.display = ''; if (statusEl) statusEl.textContent = 'Starting Plex authorization...'; try { showLoadingOverlay('Starting Plex authorization...'); const response = await fetch('/api/plex/pin/start', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to start Plex PIN flow'); } _plexPinAuthRequestId = result.request_id; const pinCodeEl = document.getElementById('plex-pin-code'); if (pinCodeEl) pinCodeEl.textContent = result.code || ''; if (statusEl) { statusEl.textContent = result.expires_in ? `Enter this code at plex.tv/link. Code expires in ${result.expires_in} seconds.` : 'Enter this code at plex.tv/link. Waiting for authorization...'; } startPlexPinAuthPolling(); } catch (error) { console.error('Plex PIN auth start failed:', error); showToast(error.message || 'Failed to start Plex authorization', 'error'); cancelPlexPinAuth(); } finally { hideLoadingOverlay(); } } function startPlexPinAuthPolling() { stopPlexPinAuthPolling(); if (!_plexPinAuthRequestId) return; _plexPinAuthPollInterval = setInterval(pollPlexPinAuthStatus, 5000); pollPlexPinAuthStatus(); } function stopPlexPinAuthPolling() { if (_plexPinAuthPollInterval) { clearInterval(_plexPinAuthPollInterval); _plexPinAuthPollInterval = null; } } async function pollPlexPinAuthStatus() { if (!_plexPinAuthRequestId) return; try { const response = await fetch(`/api/plex/pin/status?request_id=${encodeURIComponent(_plexPinAuthRequestId)}`); const result = await response.json(); const statusEl = document.getElementById('plex-pin-status'); if (!result.success && result.expired) { if (statusEl) statusEl.textContent = 'PIN code expired. Generate a new code to continue.'; stopPlexPinAuthPolling(); return; } if (result.success) { stopPlexPinAuthPolling(); if (statusEl) statusEl.textContent = 'Authorization complete! Saving Plex configuration...'; document.getElementById('plex-url').value = result.found_url || ''; document.getElementById('plex-token').value = result.token || ''; if (typeof saveSettings === 'function') { await saveSettings(true); } showToast('Plex successfully linked', 'success'); showPlexConfiguration(true); await testConnection('plex'); return; } if (result.status) { if (statusEl) statusEl.textContent = result.status; return; } if (result.error) { if (statusEl) statusEl.textContent = result.error; return; } } catch (error) { console.error('Error polling Plex PIN status:', error); const statusEl = document.getElementById('plex-pin-status'); if (statusEl) statusEl.textContent = 'Unable to contact Plex auth status. Retrying...'; } } function cancelPlexPinAuth() { stopPlexPinAuthPolling(); _plexPinAuthRequestId = null; const setupButtons = document.getElementById('plex-setup-buttons'); const authFlow = document.getElementById('plex-pin-auth-flow'); if (setupButtons) setupButtons.style.display = ''; if (authFlow) authFlow.style.display = 'none'; } function restartPlexPinAuth() { cancelPlexPinAuth(); startPlexPinAuth(); } async function clearPlexConfiguration() { cancelPlexPinAuth(); const plexUrl = document.getElementById('plex-url'); const plexToken = document.getElementById('plex-token'); const plexConfig = document.getElementById('plex-configuration'); const plexSetup = document.getElementById('plex-setup'); const plexSetupButtons = document.getElementById('plex-setup-buttons'); const plexViewConfigButton = document.getElementById('plex-view-config-button'); const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); const plexManualConfigButton = document.getElementById('plex-manual-config-button'); if (plexUrl) plexUrl.value = ''; if (plexToken) plexToken.value = ''; if (plexConfig) plexConfig.style.display = 'none'; if (plexSetup) plexSetup.style.display = ''; if (plexSetupButtons) plexSetupButtons.style.display = ''; if (plexViewConfigButton) plexViewConfigButton.style.display = 'none'; if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = ''; if (plexManualConfigButton) plexManualConfigButton.style.display = ''; const plexLibraryContainer = document.getElementById('plex-library-selector-container'); const plexLibrarySelect = document.getElementById('plex-music-library'); if (plexLibrarySelect) { plexLibrarySelect.innerHTML = ''; } if (plexLibraryContainer) { plexLibraryContainer.style.display = 'none'; } updatePlexConfigurationButtons(); try { await fetch('/api/plex/clear-library', { method: 'POST' }); } catch (e) { console.warn('Failed to clear Plex library preference:', e); } if (typeof saveSettings === 'function') { saveSettings(true); } if (typeof showToast === 'function') { showToast('Plex configuration cleared', 'success'); } } function toggleServer(serverType) { // Update toggle buttons document.getElementById('plex-toggle').classList.remove('active'); document.getElementById('jellyfin-toggle').classList.remove('active'); document.getElementById('navidrome-toggle').classList.remove('active'); document.getElementById('soulsync-toggle')?.classList.remove('active'); document.getElementById(`${serverType}-toggle`)?.classList.add('active'); // Show/hide server containers document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex'); document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin'); document.getElementById('navidrome-container').classList.toggle('hidden', serverType !== 'navidrome'); document.getElementById('soulsync-container')?.classList.toggle('hidden', serverType !== 'soulsync'); // Show Plex setup when Plex is selected; otherwise hide both Plex panels const plexConfig = document.getElementById('plex-configuration'); const plexSetup = document.getElementById('plex-setup'); if (plexConfig) plexConfig.style.display = serverType === 'plex' ? 'none' : ''; if (plexSetup) plexSetup.style.display = serverType === 'plex' ? '' : 'none'; // Load Plex music libraries when switching to Plex if (serverType === 'plex') { loadPlexMusicLibraries(); } // Load Jellyfin users and music libraries when switching to Jellyfin if (serverType === 'jellyfin') { loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); } // Load Navidrome music folders when switching to Navidrome if (serverType === 'navidrome') { loadNavidromeMusicFolders(); } // Auto-save after server toggle change debouncedAutoSaveSettings(); } function updateDownloadSourceUI() { const mode = document.getElementById('download-source-mode').value; const hybridContainer = document.getElementById('hybrid-settings-container'); const soulseekContainer = document.getElementById('soulseek-settings-container'); const tidalContainer = document.getElementById('tidal-download-settings-container'); const qobuzContainer = document.getElementById('qobuz-settings-container'); const youtubeContainer = document.getElementById('youtube-settings-container'); const hifiContainer = document.getElementById('hifi-download-settings-container'); const deezerDlContainer = document.getElementById('deezer-download-settings-container'); const amazonContainer = document.getElementById('amazon-download-settings-container'); const lidarrContainer = document.getElementById('lidarr-download-settings-container'); const soundcloudContainer = document.getElementById('soundcloud-download-settings-container'); hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none'; // Determine which sources are active let activeSources = new Set(); if (mode === 'hybrid') { 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); } soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none'; tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none'; qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none'; youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none'; hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none'; if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none'; if (amazonContainer) amazonContainer.style.display = activeSources.has('amazon') ? 'block' : 'none'; if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none'; if (soundcloudContainer) soundcloudContainer.style.display = activeSources.has('soundcloud') ? 'block' : 'none'; const prowlarrRedirect = document.getElementById('prowlarr-source-redirect'); if (prowlarrRedirect) { const showProwlarr = activeSources.has('torrent') || activeSources.has('usenet'); prowlarrRedirect.style.display = showProwlarr ? 'block' : 'none'; } // Quality profile is Soulseek-only and downloads-tab-only const qualityProfileSection = document.getElementById('quality-profile-section'); if (qualityProfileSection) { const activeTab = document.querySelector('.stg-tab.active'); const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads'; qualityProfileSection.style.display = (activeSources.has('soulseek') && onDownloadsTab) ? '' : 'none'; } if (activeSources.has('tidal')) { checkTidalDownloadAuthStatus(); } if (activeSources.has('qobuz')) { checkQobuzAuthStatus(); } if (activeSources.has('hifi')) { testHiFiConnection(); } if (activeSources.has('amazon')) { testAmazonConnection(); } if (activeSources.has('soundcloud')) { testSoundcloudConnection(); } } 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' }, { value: 'hifi', label: 'HiFi' }, { value: 'deezer_dl', label: 'Deezer' }, { value: 'amazon', label: 'Amazon Music' }, { value: 'lidarr', label: 'Lidarr' }, { value: 'soundcloud', label: 'SoundCloud' }, ]; 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; } // Refresh source-specific settings visibility based on new primary/secondary updateDownloadSourceUI(); } // =============================== // QUALITY PROFILE FUNCTIONS // =============================== let currentQualityProfile = null; async function loadQualityProfile() { try { const response = await fetch('/api/quality-profile'); const data = await response.json(); if (data.success) { currentQualityProfile = data.profile; populateQualityProfileUI(currentQualityProfile); } } catch (error) { console.error('Error loading quality profile:', error); } } function populateQualityProfileUI(profile) { // Update preset buttons document.querySelectorAll('.preset-button').forEach(btn => { btn.classList.remove('active'); }); const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`); if (activePresetBtn) { activePresetBtn.classList.add('active'); } // Populate each quality tier const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; qualities.forEach(quality => { const config = profile.qualities[quality]; if (config) { // Set enabled checkbox const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`); if (enabledCheckbox) { enabledCheckbox.checked = config.enabled; } // Set min/max sliders const minSlider = document.getElementById(`${quality}-min`); const maxSlider = document.getElementById(`${quality}-max`); if (minSlider && maxSlider) { minSlider.value = config.min_kbps; maxSlider.value = config.max_kbps; updateQualityRange(quality); } // Set priority display const prioritySpan = document.getElementById(`priority-${quality}`); if (prioritySpan) { prioritySpan.textContent = `Priority: ${config.priority}`; } // Toggle sliders visibility const sliders = document.getElementById(`sliders-${quality}`); if (sliders) { if (config.enabled) { sliders.classList.remove('disabled'); } else { sliders.classList.add('disabled'); } } // FLAC-specific: restore bit depth selector and fallback toggle if (quality === 'flac') { const bitDepthValue = config.bit_depth || 'any'; document.querySelectorAll('.bit-depth-btn').forEach(btn => { btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue); }); const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); if (bitDepthSelector) { if (config.enabled) { bitDepthSelector.classList.remove('disabled'); } else { bitDepthSelector.classList.add('disabled'); } } // Show/hide and restore fallback toggle const fallbackToggle = document.getElementById('flac-fallback-toggle'); if (fallbackToggle) { fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block'; } const fallbackCb = document.getElementById('flac-bit-depth-fallback'); if (fallbackCb) { fallbackCb.checked = config.bit_depth_fallback !== false; } } } }); // Set fallback checkbox const fallbackCheckbox = document.getElementById('quality-fallback-enabled'); if (fallbackCheckbox) { fallbackCheckbox.checked = profile.fallback_enabled; } } function updateQualityRange(quality) { const minSlider = document.getElementById(`${quality}-min`); const maxSlider = document.getElementById(`${quality}-max`); const minValue = document.getElementById(`${quality}-min-value`); const maxValue = document.getElementById(`${quality}-max-value`); if (!minSlider || !maxSlider || !minValue || !maxValue) return; let min = parseInt(minSlider.value); let max = parseInt(maxSlider.value); // Ensure min doesn't exceed max if (min > max) { min = max; minSlider.value = min; } // Ensure max doesn't go below min if (max < min) { max = min; maxSlider.value = max; } minValue.textContent = `${min} kbps`; maxValue.textContent = `${max} kbps`; } function toggleQuality(quality) { const checkbox = document.getElementById(`quality-${quality}-enabled`); const sliders = document.getElementById(`sliders-${quality}`); if (checkbox && sliders) { if (checkbox.checked) { sliders.classList.remove('disabled'); } else { sliders.classList.add('disabled'); } } // Also toggle FLAC bit depth selector if (quality === 'flac') { const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); if (bitDepthSelector && checkbox) { if (checkbox.checked) { bitDepthSelector.classList.remove('disabled'); } else { bitDepthSelector.classList.add('disabled'); } } } // Mark preset as custom when manually changing if (currentQualityProfile) { currentQualityProfile.preset = 'custom'; document.querySelectorAll('.preset-button').forEach(btn => { btn.classList.remove('active'); }); } } function setFlacBitDepth(value) { document.querySelectorAll('.bit-depth-btn').forEach(btn => { btn.classList.toggle('active', btn.getAttribute('data-value') === value); }); // Show/hide fallback toggle — only relevant when a specific bit depth is selected const fallbackToggle = document.getElementById('flac-fallback-toggle'); if (fallbackToggle) { fallbackToggle.style.display = value === 'any' ? 'none' : 'block'; } // Mark preset as custom when manually changing if (currentQualityProfile) { currentQualityProfile.preset = 'custom'; document.querySelectorAll('.preset-button').forEach(btn => { btn.classList.remove('active'); }); } debouncedAutoSaveSettings(); } function setFlacBitDepthFallback(enabled) { if (currentQualityProfile) { currentQualityProfile.preset = 'custom'; document.querySelectorAll('.preset-button').forEach(btn => { btn.classList.remove('active'); }); } debouncedAutoSaveSettings(); } async function applyQualityPreset(presetName) { try { showLoadingOverlay(`Applying ${presetName} preset...`); const response = await fetch(`/api/quality-profile/preset/${presetName}`, { method: 'POST' }); const data = await response.json(); if (data.success) { currentQualityProfile = data.profile; populateQualityProfileUI(currentQualityProfile); showToast(`Applied '${presetName}' preset`, 'success'); } else { showToast(`Failed to apply preset: ${data.error}`, 'error'); } } catch (error) { console.error('Error applying quality preset:', error); showToast('Failed to apply preset', 'error'); } finally { hideLoadingOverlay(); } } function collectQualityProfileFromUI() { const profile = { version: 2, preset: 'custom', // Will be overridden if a preset is active qualities: {}, fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true }; const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; qualities.forEach((quality, index) => { const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false; const minSlider = document.getElementById(`${quality}-min`); const maxSlider = document.getElementById(`${quality}-max`); // Preserve priority from the currently loaded profile instead of using array order const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1); profile.qualities[quality] = { enabled: enabled, min_kbps: parseInt(minSlider?.value || 0), max_kbps: parseInt(maxSlider?.value || 99999), priority: existingPriority }; // Add FLAC-specific bit_depth and fallback settings if (quality === 'flac') { const activeBtn = document.querySelector('.bit-depth-btn.active'); profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any'; const fallbackCb = document.getElementById('flac-bit-depth-fallback'); profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true; } }); // Check if current profile matches a preset if (currentQualityProfile && currentQualityProfile.preset !== 'custom') { profile.preset = currentQualityProfile.preset; } return profile; } async function saveQualityProfile() { try { const profile = collectQualityProfileFromUI(); const response = await fetch('/api/quality-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile) }); const data = await response.json(); if (data.success) { currentQualityProfile = profile; console.log('Quality profile saved successfully'); return true; } else { console.error('Failed to save quality profile:', data.error); return false; } } catch (error) { console.error('Error saving quality profile:', error); return false; } } // =============================== // END QUALITY PROFILE FUNCTIONS // =============================== async function toggleHydrabaseFromSettings() { const statusEl = document.getElementById('hydrabase-settings-status'); const btn = document.getElementById('hydrabase-connect-btn'); const url = document.getElementById('hydrabase-url').value.trim(); const apiKey = document.getElementById('hydrabase-api-key').value.trim(); if (!url || !apiKey) { if (statusEl) statusEl.textContent = 'URL and API Key required'; return; } // Save settings first await saveSettings(true); try { // Check current status const statusRes = await fetch('/api/hydrabase/status'); const statusData = await statusRes.json(); if (statusData.connected) { // Disconnect await fetch('/api/hydrabase/disconnect', { method: 'POST' }); if (btn) btn.textContent = 'Connect'; if (statusEl) { statusEl.textContent = 'Disconnected'; statusEl.style.color = 'rgba(255,255,255,0.4)'; } // Remove from fallback dropdown + reset to iTunes if was selected const fbSel2 = document.getElementById('metadata-fallback-source'); if (fbSel2) { const hbOpt = fbSel2.querySelector('option[value="hydrabase"]'); if (hbOpt) { if (fbSel2.value === 'hydrabase') fbSel2.value = 'itunes'; hbOpt.remove(); } } showToast('Hydrabase disconnected', 'info'); } else { // Connect const res = await fetch('/api/hydrabase/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, api_key: apiKey }) }); const data = await res.json(); if (data.success) { if (btn) btn.textContent = 'Disconnect'; if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; } // Add to fallback dropdown const fbSel = document.getElementById('metadata-fallback-source'); if (fbSel && !fbSel.querySelector('option[value="hydrabase"]')) { const opt = document.createElement('option'); opt.value = 'hydrabase'; opt.textContent = 'Hydrabase (P2P)'; fbSel.appendChild(opt); } showToast('Hydrabase connected', 'success'); } else { if (statusEl) statusEl.textContent = data.error || 'Connection failed'; showToast('Hydrabase connection failed', 'error'); } } } catch (e) { if (statusEl) statusEl.textContent = 'Error'; showToast('Hydrabase connection error', 'error'); } } // ── Music Library Paths ── function renderMusicPaths(paths) { const container = document.getElementById('music-paths-list'); if (!container) return; if (!paths || paths.length === 0) { container.innerHTML = '
No paths configured. Click "Add Path" to add your music folder(s).
'; return; } container.innerHTML = paths.map((p, i) => `
`).join(''); // Attach auto-save to dynamically rendered inputs container.querySelectorAll('.music-path-input').forEach(input => { input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); }); } function addMusicPathRow() { const container = document.getElementById('music-paths-list'); if (!container) return; // Clear the "no paths" message if present const placeholder = container.querySelector('div[style*="color: rgba"]'); if (placeholder && !container.querySelector('.music-path-row')) placeholder.remove(); const row = document.createElement('div'); row.className = 'form-group music-path-row'; row.style.marginBottom = '4px'; row.innerHTML = ` `; container.appendChild(row); const input = row.querySelector('input'); input.focus(); // Auto-save when the user finishes typing a path input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); } function _removeMusicPathRow(btn) { btn.closest('.music-path-row').remove(); // Auto-save after removing a path if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); } function collectMusicPaths() { const inputs = document.querySelectorAll('.music-path-input'); const paths = []; inputs.forEach(input => { const val = input.value.trim(); if (val) paths.push(val); }); return paths; } // ── Genre Whitelist ── let _genreWhitelistCache = []; function _genreWhitelistRender(genres) { _genreWhitelistCache = genres && genres.length ? genres : []; const container = document.getElementById('genre-whitelist-chips'); const countEl = document.getElementById('genre-whitelist-count'); if (!container) return; if (!_genreWhitelistCache.length) { container.innerHTML = '
No genres configured. Click "Reset to Defaults" to load the default whitelist.
'; if (countEl) countEl.textContent = ''; return; } const searchVal = (document.getElementById('genre-whitelist-search')?.value || '').toLowerCase(); const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache; container.innerHTML = filtered.map(g => `${escapeHtml(g)}` ).join(''); if (countEl) countEl.textContent = `${_genreWhitelistCache.length} genres`; _initGenreChipClickHandler(); } function _initGenreChipClickHandler() { const container = document.getElementById('genre-whitelist-chips'); if (!container) return; container.onclick = (e) => { const btn = e.target.closest('.genre-chip-x'); if (btn) { e.preventDefault(); _genreWhitelistRemove(btn.dataset.genre); } }; } function _genreWhitelistRemove(genre) { _genreWhitelistCache = _genreWhitelistCache.filter(g => g !== genre); _genreWhitelistRender(_genreWhitelistCache); if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); } function _genreWhitelistAdd(genre) { genre = genre.trim(); if (!genre) return; if (_genreWhitelistCache.some(g => g.toLowerCase() === genre.toLowerCase())) return; _genreWhitelistCache.push(genre); _genreWhitelistCache.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); _genreWhitelistRender(_genreWhitelistCache); if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); } async function _genreWhitelistReset() { try { const resp = await fetch('/api/genre-whitelist/defaults'); const data = await resp.json(); if (data.genres) { _genreWhitelistCache = data.genres; _genreWhitelistRender(_genreWhitelistCache); if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); showToast(`Loaded ${data.genres.length} default genres`, 'success'); } } catch (e) { showToast('Failed to load defaults', 'error'); } } // Toggle whitelist container visibility + init document.addEventListener('change', (e) => { if (e.target.id === 'genre-whitelist-enabled') { const container = document.getElementById('genre-whitelist-container'); if (container) container.style.display = e.target.checked ? '' : 'none'; // Auto-populate with defaults on first enable if empty if (e.target.checked && _genreWhitelistCache.length === 0) { _genreWhitelistReset(); } } }); // Search/add handler document.addEventListener('keydown', (e) => { if (e.target.id === 'genre-whitelist-search' && e.key === 'Enter') { e.preventDefault(); _genreWhitelistAdd(e.target.value); e.target.value = ''; } }); document.addEventListener('input', (e) => { if (e.target.id === 'genre-whitelist-search') { _genreWhitelistRender(_genreWhitelistCache); } }); function _collectGenreWhitelist() { return _genreWhitelistCache; } // ── Live Log Viewer ── let _logViewerActive = false; let _logViewerFilter = ''; let _logViewerSource = 'app'; let _logViewerSearch = ''; const _LOG_MAX_LINES = 2000; function _logClassify(line) { // Exact logger format first if (line.includes(' - DEBUG - ')) return 'DEBUG'; if (line.includes(' - INFO - ')) return 'INFO'; if (line.includes(' - WARNING - ')) return 'WARNING'; if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'ERROR'; // Heuristic for print() output const ll = line.toLowerCase(); if (ll.includes('error') || ll.includes('traceback') || ll.includes('exception') || ll.includes('failed')) return 'ERROR'; if (ll.includes('warning') || ll.includes('warn')) return 'WARNING'; if (ll.includes('debug')) return 'DEBUG'; return 'INFO'; } function _logClassToCSS(level) { return { DEBUG: 'log-debug', INFO: 'log-info', WARNING: 'log-warning', ERROR: 'log-error' }[level] || 'log-plain'; } async function _logViewerInit() { if (_logViewerActive) return; _logViewerActive = true; _logViewerSource = document.getElementById('log-viewer-source')?.value || 'app'; // Fetch initial tail try { const params = new URLSearchParams({ source: _logViewerSource, lines: 300 }); if (_logViewerFilter) params.set('level', _logViewerFilter); if (_logViewerSearch) params.set('search', _logViewerSearch); const resp = await fetch(`/api/logs/tail?${params}`); const data = await resp.json(); if (data.lines) { const container = document.getElementById('log-viewer-lines'); if (container) { container.innerHTML = ''; _logViewerAppendLines(data.lines); } } } catch (e) { console.warn('Failed to load initial logs:', e); } // Subscribe to live updates if (typeof socket !== 'undefined' && socket && socket.connected) { socket.emit('logs:subscribe', { source: _logViewerSource }); socket.on('logs:live', _logViewerOnLive); } } function _logViewerStop() { if (!_logViewerActive) return; _logViewerActive = false; if (typeof socket !== 'undefined' && socket) { socket.off('logs:live', _logViewerOnLive); socket.emit('logs:unsubscribe', {}); } } function _logViewerOnLive(data) { if (!_logViewerActive || !data.lines) return; if (data.source !== _logViewerSource) return; let lines = data.lines; // Apply level filter client-side for live lines if (_logViewerFilter) { lines = lines.filter(l => _logClassify(l) === _logViewerFilter); } // Apply search filter if (_logViewerSearch) { const s = _logViewerSearch.toLowerCase(); lines = lines.filter(l => l.toLowerCase().includes(s)); } if (lines.length > 0) _logViewerAppendLines(lines); } function _logViewerAppendLines(lines) { const container = document.getElementById('log-viewer-lines'); if (!container) return; const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked; const terminal = document.getElementById('log-viewer-terminal'); const frag = document.createDocumentFragment(); for (const line of lines) { const div = document.createElement('div'); div.className = 'log-line ' + _logClassToCSS(_logClassify(line)); div.textContent = line; frag.appendChild(div); } container.appendChild(frag); // Trim old lines while (container.children.length > _LOG_MAX_LINES) { container.removeChild(container.firstChild); } // Update count const countEl = document.getElementById('log-viewer-line-count'); if (countEl) countEl.textContent = `${container.children.length} lines`; // Auto-scroll if (autoScroll && terminal) { terminal.scrollTop = terminal.scrollHeight; } } async function _logViewerChangeSource() { _logViewerStop(); _logViewerSource = document.getElementById('log-viewer-source')?.value || 'app'; const container = document.getElementById('log-viewer-lines'); if (container) container.innerHTML = '
Loading...
'; await _logViewerInit(); } function _logViewerFilterLevel(btn) { document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _logViewerFilter = btn.dataset.level || ''; _logViewerReload(); } let _logSearchDebounce = null; function _logViewerOnSearch(input) { clearTimeout(_logSearchDebounce); _logSearchDebounce = setTimeout(() => { _logViewerSearch = (input.value || '').trim(); _logViewerReload(); }, 300); } function _logViewerReload() { _logViewerStop(); const container = document.getElementById('log-viewer-lines'); if (container) container.innerHTML = '
Loading...
'; _logViewerInit(); } function _logViewerCopy() { const container = document.getElementById('log-viewer-lines'); if (!container) return; const text = Array.from(container.children).map(el => el.textContent).join('\n'); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(() => showToast('Logs copied', 'success')); } else { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showToast('Logs copied', 'success'); } } function _logViewerClear() { const container = document.getElementById('log-viewer-lines'); if (container) container.innerHTML = ''; const countEl = document.getElementById('log-viewer-line-count'); if (countEl) countEl.textContent = '0 lines'; } // ── Database Maintenance ── async function loadDbMaintenanceInfo() { try { const resp = await fetch('/api/database/maintenance/info'); const data = await resp.json(); if (!data.success) return; const sizeEl = document.getElementById('db-size-display'); const freeEl = document.getElementById('db-freepages-display'); const vacEl = document.getElementById('db-autovacuum-display'); if (sizeEl) sizeEl.textContent = data.total_size_display; if (freeEl) freeEl.textContent = data.free_pages > 0 ? `${data.free_pages.toLocaleString()} (${data.free_size_display} reclaimable)` : 'None — database is fully compacted'; if (vacEl) vacEl.textContent = data.auto_vacuum_label; // Hide enable button if already incremental const incBtn = document.getElementById('db-incvacuum-btn'); if (incBtn && data.auto_vacuum === 2) { incBtn.textContent = 'Incremental Vacuum Enabled'; incBtn.disabled = true; incBtn.style.opacity = '0.5'; } } catch (e) { console.error('Error loading DB maintenance info:', e); } } async function runDatabaseVacuum() { const btn = document.getElementById('db-vacuum-btn'); const status = document.getElementById('db-vacuum-status'); if (!confirm('This will compact the database by rewriting it. The database will be locked during this operation. For large databases this may take over a minute. Continue?')) return; btn.disabled = true; btn.textContent = 'Compacting...'; if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Running VACUUM — this may take a while...'; } try { const resp = await fetch('/api/database/maintenance/vacuum', { method: 'POST' }); const data = await resp.json(); if (data.success) { showToast(`Database compacted in ${data.elapsed_seconds}s — saved ${data.saved_display}`, 'success'); if (status) { status.style.color = '#4caf50'; status.textContent = `Done in ${data.elapsed_seconds}s. Saved ${data.saved_display}.`; } loadDbMaintenanceInfo(); } else { showToast('Vacuum failed: ' + (data.error || 'Unknown error'), 'error'); if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); } } } catch (e) { showToast('Vacuum failed: ' + e.message, 'error'); if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; } } finally { btn.disabled = false; btn.textContent = 'Compact Database (VACUUM)'; } } async function enableIncrementalVacuum() { const btn = document.getElementById('db-incvacuum-btn'); const status = document.getElementById('db-vacuum-status'); if (!confirm('This will enable incremental vacuum mode. It requires a one-time full VACUUM to activate, which locks the database and may take over a minute on large databases. Continue?')) return; btn.disabled = true; btn.textContent = 'Enabling...'; if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Enabling incremental vacuum — this may take a while...'; } try { const resp = await fetch('/api/database/maintenance/enable-incremental-vacuum', { method: 'POST' }); const data = await resp.json(); if (data.success) { const msg = data.already_enabled ? 'Already enabled' : `Enabled in ${data.elapsed_seconds}s — saved ${data.saved_display}`; showToast(msg, 'success'); if (status) { status.style.color = '#4caf50'; status.textContent = msg; } loadDbMaintenanceInfo(); } else { showToast('Failed: ' + (data.error || 'Unknown error'), 'error'); if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); } } } catch (e) { showToast('Failed: ' + e.message, 'error'); if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; } } finally { btn.disabled = false; btn.textContent = 'Enable Incremental Vacuum'; } } async function activateDevMode() { const password = document.getElementById('dev-mode-password').value; try { const response = await fetch('/api/dev-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }); const data = await response.json(); if (data.success) { document.getElementById('dev-mode-status').textContent = 'Active'; document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))'; document.getElementById('hydrabase-nav').style.display = ''; document.getElementById('hydrabase-button-container').style.display = ''; document.getElementById('dev-mode-password').value = ''; showToast('Dev mode activated', 'success'); } else { showToast('Invalid password', 'error'); } } catch (e) { showToast('Failed to activate dev mode', 'error'); } } // ── Hydrabase Functions ── let _hydrabaseConnected = false; async function hydrabaseToggleConnection() { if (_hydrabaseConnected) { await hydrabaseDisconnect(); } else { await hydrabaseConnect(); } } async function hydrabaseConnect() { const url = document.getElementById('hydra-ws-url').value.trim(); const apiKey = document.getElementById('hydra-api-key').value.trim(); if (!url || !apiKey) { showToast('URL and API key required', 'error'); return; } const statusEl = document.getElementById('hydra-connection-status'); const btn = document.getElementById('hydra-connect-btn'); statusEl.textContent = 'Connecting...'; statusEl.style.color = '#f0ad4e'; try { const response = await fetch('/api/hydrabase/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, api_key: apiKey }) }); const data = await response.json(); if (data.success) { _hydrabaseConnected = true; statusEl.textContent = 'Connected'; statusEl.style.color = 'rgb(var(--accent-light-rgb))'; btn.textContent = 'Disconnect'; showToast('Connected to Hydrabase', 'success'); } else { statusEl.textContent = 'Failed'; statusEl.style.color = '#f44336'; showToast(data.error || 'Connection failed', 'error'); } } catch (e) { statusEl.textContent = 'Error'; statusEl.style.color = '#f44336'; showToast('Connection error', 'error'); } } async function hydrabaseDisconnect() { try { await fetch('/api/hydrabase/disconnect', { method: 'POST' }); } catch (e) { } _hydrabaseConnected = false; document.getElementById('hydra-connection-status').textContent = 'Disconnected'; document.getElementById('hydra-connection-status').style.color = '#888'; document.getElementById('hydra-connect-btn').textContent = 'Connect'; // Dev mode is disabled on disconnect — hide Hydrabase nav and update settings status document.getElementById('hydrabase-nav').style.display = 'none'; document.getElementById('hydrabase-button-container').style.display = 'none'; const devStatus = document.getElementById('dev-mode-status'); if (devStatus) { devStatus.textContent = 'Inactive'; devStatus.style.color = '#888'; } showToast('Disconnected — dev mode disabled', 'success'); navigateToPage('settings'); } async function loadHydrabaseComparisons() { const container = document.getElementById('hydra-comparisons-container'); if (!container) return; try { const response = await fetch('/api/hydrabase/comparisons'); const data = await response.json(); if (!data.success || !data.comparisons?.length) { container.innerHTML = '

No comparisons yet. Search with Hydrabase active to generate comparisons.

'; return; } let html = ''; for (const comp of data.comparisons) { const time = new Date(comp.timestamp * 1000).toLocaleTimeString(); html += `
"${comp.query}" ${time}
Hydrabase
${comp.hydrabase?.tracks || 0}T / ${comp.hydrabase?.artists || 0}A / ${comp.hydrabase?.albums || 0}Al
Spotify
${comp.spotify?.tracks || 0}T / ${comp.spotify?.artists || 0}A / ${comp.spotify?.albums || 0}Al
${comp.fallback_source === 'deezer' ? 'Deezer' : 'iTunes'}
${(comp.fallback || comp.itunes)?.tracks || 0}T / ${(comp.fallback || comp.itunes)?.artists || 0}A / ${(comp.fallback || comp.itunes)?.albums || 0}Al
`; } container.innerHTML = html; } catch (e) { container.innerHTML = '

Failed to load comparisons.

'; } } async function hydrabaseSendRaw(textareaId) { const textarea = document.getElementById(textareaId); const raw = textarea.value.trim(); if (!raw) { showToast('Payload is empty', 'error'); return; } if (!_hydrabaseConnected) { showToast('Not connected to Hydrabase', 'error'); return; } let payload; try { payload = JSON.parse(raw); } catch (e) { showToast('Invalid JSON payload', 'error'); return; } // Auto-inject a fresh nonce if not set or zero if (!payload.nonce) { payload.nonce = Date.now(); } const responseArea = document.getElementById('hydra-response'); responseArea.textContent = 'Sending...'; try { const response = await fetch('/api/hydrabase/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ payload }) }); const data = await response.json(); if (data.success) { responseArea.textContent = JSON.stringify(data.data, null, 2); } else { responseArea.textContent = 'Error: ' + (data.error || 'Unknown error'); if (data.error && data.error.includes('Not connected')) { _hydrabaseConnected = false; document.getElementById('hydra-connection-status').textContent = 'Disconnected'; document.getElementById('hydra-connection-status').style.color = '#888'; document.getElementById('hydra-connect-btn').textContent = 'Connect'; } } } catch (e) { responseArea.textContent = 'Error: ' + e.message; } } // ── Tag embedding accordion helpers ── function toggleTagGroup(header) { const body = header.nextElementSibling; const arrow = header.querySelector('.tag-group-arrow'); if (body.style.display === 'none') { body.style.display = 'block'; arrow.classList.add('open'); } else { body.style.display = 'none'; arrow.classList.remove('open'); } } function toggleServiceTags(masterCheckbox, serviceName) { const group = masterCheckbox.closest('.tag-service-group'); if (!group) return; const body = group.querySelector('.tag-service-body'); if (!body) return; const childCheckboxes = body.querySelectorAll('input[type="checkbox"]'); childCheckboxes.forEach(cb => { const label = cb.closest('.checkbox-label'); if (masterCheckbox.checked) { if (label) label.classList.remove('disabled-tag'); cb.disabled = false; } else { if (label) label.classList.add('disabled-tag'); cb.disabled = true; } }); } function _collectServiceTags(serviceName) { const tags = {}; document.querySelectorAll(`[data-config^="${serviceName}.tags."]`).forEach(cb => { const key = cb.dataset.config.split('.').pop(); tags[key] = cb.checked; }); return tags; } function _getTagConfig(path) { const el = document.querySelector(`[data-config="${path}"]`); return el ? el.checked : true; } async function saveSettings(quiet = false) { // Validate file organization templates before saving const validationErrors = validateFileOrganizationTemplates(); if (validationErrors.length > 0) { if (!quiet) showToast('Template validation failed: ' + validationErrors.join(', '), 'error'); return; } // Determine active server from toggle buttons let activeServer = 'plex'; if (document.getElementById('jellyfin-toggle').classList.contains('active')) { activeServer = 'jellyfin'; } else if (document.getElementById('navidrome-toggle').classList.contains('active')) { activeServer = 'navidrome'; } else if (document.getElementById('soulsync-toggle')?.classList.contains('active')) { activeServer = 'soulsync'; } const metadataSourceSelect = document.getElementById('metadata-fallback-source'); const discogsTokenInput = document.getElementById('discogs-token'); const discogsTokenPresent = !!discogsTokenInput?.value?.trim(); let metadataSource = metadataSourceSelect?.value || 'deezer'; const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true; if (metadataSource === 'spotify' && !spotifySessionActive) { metadataSource = _metadataSourceFallback('spotify'); if (metadataSourceSelect) metadataSourceSelect.value = metadataSource; if (!quiet) { showToast('Spotify is disconnected, so the primary metadata source was switched.', 'warning'); } } else if (metadataSource === 'discogs' && !discogsTokenPresent) { metadataSource = _metadataSourceFallback('discogs'); if (metadataSourceSelect) metadataSourceSelect.value = metadataSource; if (!quiet) { showToast('Discogs requires a personal access token before it can be selected as the primary metadata source.', 'warning'); } } const settings = { active_media_server: activeServer, spotify: { client_id: document.getElementById('spotify-client-id').value, client_secret: document.getElementById('spotify-client-secret').value, redirect_uri: document.getElementById('spotify-redirect-uri').value, embed_tags: document.getElementById('embed-spotify').checked, tags: _collectServiceTags('spotify') }, tidal: { client_id: document.getElementById('tidal-client-id').value, client_secret: document.getElementById('tidal-client-secret').value, redirect_uri: document.getElementById('tidal-redirect-uri').value, embed_tags: document.getElementById('embed-tidal').checked, tags: _collectServiceTags('tidal') }, plex: { base_url: document.getElementById('plex-url').value, token: document.getElementById('plex-token').value }, jellyfin: { base_url: document.getElementById('jellyfin-url').value, api_key: document.getElementById('jellyfin-api-key').value, api_timeout: parseInt(document.getElementById('jellyfin-timeout').value) || 30 }, navidrome: { base_url: document.getElementById('navidrome-url').value, username: document.getElementById('navidrome-username').value, password: document.getElementById('navidrome-password').value }, soulseek: { slskd_url: document.getElementById('soulseek-url').value, api_key: document.getElementById('soulseek-api-key').value, download_path: document.getElementById('download-path').value, transfer_path: document.getElementById('transfer-path').value, search_timeout: parseInt(document.getElementById('soulseek-search-timeout').value) || 60, search_timeout_buffer: parseInt(document.getElementById('soulseek-search-timeout-buffer').value) || 15, search_min_delay_seconds: parseInt(document.getElementById('soulseek-search-min-delay-seconds').value) || 0, min_peer_upload_speed: parseInt(document.getElementById('soulseek-min-peer-speed').value) || 0, max_peer_queue: parseInt(document.getElementById('soulseek-max-peer-queue').value) || 0, download_timeout: (parseInt(document.getElementById('soulseek-download-timeout').value) || 10) * 60, auto_clear_searches: document.getElementById('soulseek-auto-clear-searches').checked }, listenbrainz: { base_url: document.getElementById('listenbrainz-base-url').value, token: document.getElementById('listenbrainz-token').value, scrobble_enabled: document.getElementById('listenbrainz-scrobble-enabled').checked, }, acoustid: { api_key: document.getElementById('acoustid-api-key').value, enabled: document.getElementById('acoustid-enabled').checked }, lastfm: { api_key: document.getElementById('lastfm-api-key').value, api_secret: document.getElementById('lastfm-api-secret').value, scrobble_enabled: document.getElementById('lastfm-scrobble-enabled').checked, embed_tags: document.getElementById('embed-lastfm').checked, tags: _collectServiceTags('lastfm') }, genius: { access_token: document.getElementById('genius-access-token').value, embed_tags: document.getElementById('embed-genius').checked, tags: _collectServiceTags('genius') }, itunes: { country: document.getElementById('itunes-country').value || 'US', embed_tags: document.getElementById('embed-itunes').checked, tags: _collectServiceTags('itunes') }, discogs: { token: document.getElementById('discogs-token').value, }, metadata: { fallback_source: metadataSource }, hydrabase: { url: document.getElementById('hydrabase-url').value, api_key: document.getElementById('hydrabase-api-key').value, auto_connect: document.getElementById('hydrabase-auto-connect').checked }, download_source: { 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(), stream_source: document.getElementById('stream-source').value, max_concurrent: parseInt(document.getElementById('max-concurrent-downloads').value) || 3, }, tidal_download: { quality: document.getElementById('tidal-download-quality').value || 'lossless', allow_fallback: document.getElementById('tidal-allow-fallback').checked, }, hifi_download: { quality: document.getElementById('hifi-download-quality').value || 'lossless', allow_fallback: document.getElementById('hifi-allow-fallback').checked, }, hifi: { embed_tags: document.getElementById('embed-hifi').checked, tags: _collectServiceTags('hifi') }, deezer_download: { quality: document.getElementById('deezer-download-quality').value || 'flac', arl: document.getElementById('deezer-download-arl').value || '', allow_fallback: document.getElementById('deezer-allow-fallback').checked, }, amazon_download: { quality: document.getElementById('amazon-quality').value || 'flac', allow_fallback: document.getElementById('amazon-allow-fallback').checked, }, lidarr_download: { url: document.getElementById('lidarr-url').value || '', api_key: document.getElementById('lidarr-api-key').value || '', }, prowlarr: { url: document.getElementById('prowlarr-url')?.value || '', api_key: document.getElementById('prowlarr-api-key')?.value || '', indexer_ids: document.getElementById('prowlarr-indexer-ids')?.value || '', }, torrent_client: { type: document.getElementById('torrent-client-type')?.value || 'qbittorrent', url: document.getElementById('torrent-client-url')?.value || '', username: document.getElementById('torrent-client-username')?.value || '', password: document.getElementById('torrent-client-password')?.value || '', category: document.getElementById('torrent-client-category')?.value || 'soulsync', save_path: document.getElementById('torrent-client-save-path')?.value || '', }, usenet_client: { type: document.getElementById('usenet-client-type')?.value || 'sabnzbd', url: document.getElementById('usenet-client-url')?.value || '', api_key: document.getElementById('usenet-client-api-key')?.value || '', username: document.getElementById('usenet-client-username')?.value || '', password: document.getElementById('usenet-client-password')?.value || '', category: document.getElementById('usenet-client-category')?.value || 'soulsync', }, soundcloud_download: { // No knobs yet — anonymous-only. Keeping the key present so // future tier-2 OAuth wiring (Go+ session token) doesn't have // to migrate existing configs. }, qobuz: { quality: document.getElementById('qobuz-quality').value || 'lossless', embed_tags: document.getElementById('embed-qobuz').checked, tags: _collectServiceTags('qobuz'), allow_fallback: document.getElementById('qobuz-allow-fallback').checked, }, database: { max_workers: parseInt(document.getElementById('max-workers').value) }, metadata_enhancement: { enabled: document.getElementById('metadata-enabled').checked, embed_album_art: document.getElementById('embed-album-art').checked, cover_art_download: document.getElementById('cover-art-download').checked, prefer_caa_art: document.getElementById('prefer-caa-art').checked, lrclib_enabled: document.getElementById('lrclib-enabled').checked, tags: { quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'), genre_merge: _getTagConfig('metadata_enhancement.tags.genre_merge'), artist_separator: document.getElementById('artist-separator').value, write_multi_artist: document.getElementById('write-multi-artist').checked, feat_in_title: document.getElementById('feat-in-title').checked } }, musicbrainz: { embed_tags: document.getElementById('embed-musicbrainz').checked, tags: _collectServiceTags('musicbrainz') }, deezer: { app_id: document.getElementById('deezer-app-id').value, app_secret: document.getElementById('deezer-app-secret').value, redirect_uri: document.getElementById('deezer-redirect-uri').value, embed_tags: document.getElementById('embed-deezer').checked, tags: _collectServiceTags('deezer') }, audiodb: { embed_tags: document.getElementById('embed-audiodb').checked, tags: _collectServiceTags('audiodb') }, file_organization: { enabled: document.getElementById('file-organization-enabled').checked, disc_label: document.getElementById('disc-label').value, collab_artist_mode: document.getElementById('collab-artist-mode').value, templates: { album_path: document.getElementById('template-album-path').value, single_path: document.getElementById('template-single-path').value, playlist_path: document.getElementById('template-playlist-path').value, video_path: document.getElementById('template-video-path').value } }, wishlist: { allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked }, playlist_sync: { create_backup: document.getElementById('create-backup').checked }, content_filter: { allow_explicit: document.getElementById('allow-explicit').checked }, genre_whitelist: { enabled: document.getElementById('genre-whitelist-enabled').checked, genres: _collectGenreWhitelist(), }, post_processing: { replaygain_enabled: document.getElementById('replaygain-enabled').checked, duration_tolerance_seconds: parseFloat(document.getElementById('duration-tolerance-seconds').value) || 0, }, library: { music_paths: collectMusicPaths(), music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos' }, import: { replace_lower_quality: document.getElementById('import-replace-lower-quality').checked, staging_path: document.getElementById('staging-path').value || './Staging' }, lossy_copy: { enabled: document.getElementById('lossy-copy-enabled').checked, codec: document.getElementById('lossy-copy-codec').value, bitrate: document.getElementById('lossy-copy-bitrate').value, delete_original: document.getElementById('lossy-copy-delete-original').checked, downsample_hires: document.getElementById('downsample-hires').checked }, listening_stats: { enabled: document.getElementById('listening-stats-enabled').checked, poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30, }, m3u_export: { enabled: document.getElementById('m3u-export-enabled').checked, entry_base_path: document.getElementById('m3u-entry-base-path').value || '' }, ui_appearance: { accent_preset: document.getElementById('accent-preset')?.value || '#1db954', accent_color: document.getElementById('accent-custom-color')?.value || '#1db954', sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars', particles_enabled: document.getElementById('particles-enabled')?.checked !== false, worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false, reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true }, youtube: { cookies_browser: document.getElementById('youtube-cookies-browser').value, download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3, }, security: { require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '', } }; // Validate cors_origins entries — backend silently filters malformed // values, so warn the user up-front if any line doesn't look like a // URL (or the special '*' token). One-shot toast; doesn't block save. const corsRaw = settings.security.cors_origins; if (corsRaw) { const entries = corsRaw.replace(/\n/g, ',').split(',') .map(s => s.trim()) .filter(s => s); const invalid = entries.filter(e => { if (e === '*') return false; // Accept scheme://host[:port] only — no path, query, or fragment. // Engineio compares Origin against {scheme}://{host} exactly. return !/^https?:\/\/[^\s/?#]+$/i.test(e); }); if (invalid.length) { showToast( `Allowed Origins: ${invalid.length} entr${invalid.length === 1 ? 'y looks' : 'ies look'} malformed (need full URL like https://soulsync.example.com, no trailing slash). Saving anyway — they\'ll be ignored.`, 'warning' ); } } try { if (!quiet) showLoadingOverlay('Saving settings...'); // Save main settings const response = await fetch(API.settings, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); const result = await response.json(); // Save quality profile const qualityProfileSaved = await saveQualityProfile(); // Save discovery lookback period let lookbackSaved = true; try { const lookbackPeriod = document.getElementById('discovery-lookback-period').value; const lookbackResponse = await fetch('/api/discovery/lookback-period', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ period: lookbackPeriod }) }); const lookbackResult = await lookbackResponse.json(); lookbackSaved = lookbackResult.success === true; } catch (error) { console.error('Error saving discovery lookback period:', error); lookbackSaved = false; } // Save hemisphere setting try { const hemisphere = document.getElementById('discovery-hemisphere').value; await fetch('/api/discovery/hemisphere', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hemisphere }) }); } catch (error) { console.error('Error saving hemisphere setting:', error); } if (result.success && qualityProfileSaved && lookbackSaved) { showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success'); _forceServiceStatusRefresh(); _stgRefreshAfterSave(); } else if (result.success && qualityProfileSaved && !lookbackSaved) { showToast('Settings saved, but discovery lookback period failed to save', 'warning'); _forceServiceStatusRefresh(); _stgRefreshAfterSave(); } else if (result.success && !qualityProfileSaved) { showToast('Settings saved, but quality profile failed to save', 'warning'); _forceServiceStatusRefresh(); _stgRefreshAfterSave(); } else { showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services'); } } catch (error) { console.error('Error saving settings:', error); showToast('Failed to save settings', 'error', 'set-services'); } finally { if (!quiet) hideLoadingOverlay(); } } async function authorizeLastfmScrobbling() { try { // Save settings first so API secret is stored await saveSettings(); const resp = await fetch('/api/lastfm/auth-url'); const data = await resp.json(); if (data.success && data.url) { window.open(data.url, '_blank', 'width=600,height=500'); showToast('Authorize SoulSync in the Last.fm window that opened', 'info'); } else { showToast(data.error || 'Could not generate auth URL', 'error'); } } catch (e) { showToast('Failed to start Last.fm authorization', 'error'); } } async function testConnection(service) { try { showLoadingOverlay(`Testing ${service} connection...`); const response = await fetch(API.testConnection, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service }) }); const result = await response.json(); if (result.success) { // Use backend's message which contains dynamic source name showToast(result.message || `${service} connection successful`, 'success'); // Load music libraries after successful connection if (service === 'plex') { loadPlexMusicLibraries(); } else if (service === 'jellyfin') { loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); } else if (service === 'navidrome') { loadNavidromeMusicFolders(); } } else { showToast(`${service} connection failed: ${result.error}`, 'error', 'gs-connecting'); } } catch (error) { console.error(`Error testing ${service} connection:`, error); showToast(`Failed to test ${service} connection`, 'error', 'gs-connecting'); } finally { hideLoadingOverlay(); } } async function clearQuarantine() { if (!await showConfirmDialog({ title: 'Clear Quarantine', message: 'Delete all files in the quarantine folder? This cannot be undone.', confirmText: 'Delete', destructive: true })) return; try { showLoadingOverlay('Clearing quarantine folder...'); const response = await fetch('/api/quarantine/clear', { method: 'POST' }); const result = await response.json(); if (result.success) { showToast(result.message || 'Quarantine cleared', 'success'); } else { showToast(`Failed to clear quarantine: ${result.error}`, 'error'); } } catch (error) { console.error('Error clearing quarantine:', error); showToast('Failed to clear quarantine', 'error'); } finally { hideLoadingOverlay(); } } // ======================== API Key Management ======================== async function loadApiKeys() { const container = document.getElementById('api-keys-list'); if (!container) return; try { const response = await fetch('/api/v1/api-keys-internal'); if (response.ok) { const data = await response.json(); renderApiKeys(data.data?.keys || []); } else { container.innerHTML = '
No API keys configured.
'; } } catch (e) { container.innerHTML = '
No API keys configured.
'; } } function renderApiKeys(keys) { const container = document.getElementById('api-keys-list'); if (!container) return; if (!keys || keys.length === 0) { container.innerHTML = '
No API keys yet. Generate one below.
'; return; } container.innerHTML = keys.map(k => `
${escapeHtml(k.label || 'Unnamed')}
${escapeHtml(k.key_prefix || 'sk_...')}... · Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'} ${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''}
`).join(''); _initApiKeyClickHandler(); } function _initApiKeyClickHandler() { const container = document.getElementById('api-keys-list'); if (!container) return; container.onclick = (e) => { const btn = e.target.closest('.revoke-api-key-btn'); if (btn) { e.preventDefault(); revokeApiKey(btn.dataset.keyId, btn.dataset.keyLabel); } }; } async function generateApiKey() { const labelInput = document.getElementById('api-key-label'); const label = labelInput ? labelInput.value.trim() : ''; try { const response = await fetch('/api/v1/api-keys-internal/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: label || 'Default' }) }); const data = await response.json(); if (data.success && data.data?.key) { const keyDisplay = document.getElementById('api-key-generated'); const keyValue = document.getElementById('api-key-value'); if (keyDisplay && keyValue) { keyValue.textContent = data.data.key; keyDisplay.style.display = 'block'; } if (labelInput) labelInput.value = ''; showToast('API key generated! Copy it now.', 'success'); loadApiKeys(); } else { showToast(data.error?.message || 'Failed to generate API key', 'error'); } } catch (error) { console.error('Error generating API key:', error); showToast('Failed to generate API key', 'error'); } } function copyApiKey() { const keyValue = document.getElementById('api-key-value'); if (keyValue) { navigator.clipboard.writeText(keyValue.textContent).then(() => { showToast('API key copied to clipboard', 'success'); }).catch(() => { // Fallback for older browsers const range = document.createRange(); range.selectNode(keyValue); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); document.execCommand('copy'); showToast('API key copied', 'success'); }); } } async function revokeApiKey(keyId, label) { if (!await showConfirmDialog({ title: 'Revoke API Key', message: `Revoke API key "${label}"? Any apps using this key will stop working.`, confirmText: 'Revoke', destructive: true })) return; try { const response = await fetch(`/api/v1/api-keys-internal/revoke/${keyId}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { showToast('API key revoked', 'success'); loadApiKeys(); } else { showToast(data.error?.message || 'Failed to revoke key', 'error'); } } catch (error) { console.error('Error revoking API key:', error); showToast('Failed to revoke key', 'error'); } } // Dashboard-specific test functions that create activity items async function testDashboardConnection(service) { try { showLoadingOverlay(`Testing ${service} service...`); const response = await fetch(API.testDashboardConnection, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service }) }); const result = await response.json(); if (result.success) { // Use backend's message which contains dynamic source name showToast(result.message || `${service} service verified`, 'success'); // Refresh status indicators immediately so UI reflects the new state fetchAndUpdateServiceStatus(); } else { showToast(`${service} service check failed: ${result.error}`, 'error'); } } catch (error) { console.error(`Error testing ${service} service:`, error); showToast(`Failed to test ${service} service`, 'error'); } finally { hideLoadingOverlay(); } } // Individual Auto-detect functions - same as GUI async function autoDetectPlex() { try { showLoadingOverlay('Auto-detecting Plex server...'); const response = await fetch('/api/detect-media-server', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'plex' }) }); const result = await response.json(); if (result.success) { document.getElementById('plex-url').value = result.found_url; showToast(`Plex server detected: ${result.found_url}`, 'success'); } else { showToast(result.error, 'error'); } } catch (error) { console.error('Error auto-detecting Plex:', error); showToast('Failed to auto-detect Plex server', 'error'); } finally { hideLoadingOverlay(); } } async function autoDetectJellyfin() { try { showLoadingOverlay('Auto-detecting Jellyfin server...'); const response = await fetch('/api/detect-media-server', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'jellyfin' }) }); const result = await response.json(); if (result.success) { document.getElementById('jellyfin-url').value = result.found_url; showToast(`Jellyfin server detected: ${result.found_url}`, 'success'); } else { showToast(result.error, 'error'); } } catch (error) { console.error('Error auto-detecting Jellyfin:', error); showToast('Failed to auto-detect Jellyfin server', 'error'); } finally { hideLoadingOverlay(); } } async function autoDetectNavidrome() { try { showLoadingOverlay('Auto-detecting Navidrome server...'); const response = await fetch('/api/detect-media-server', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_type: 'navidrome' }) }); const result = await response.json(); if (result.success) { document.getElementById('navidrome-url').value = result.found_url; showToast(`Navidrome server detected: ${result.found_url}`, 'success'); } else { showToast(result.error, 'error'); } } catch (error) { console.error('Error auto-detecting Navidrome:', error); showToast('Failed to auto-detect Navidrome server', 'error'); } finally { hideLoadingOverlay(); } } async function autoDetectSlskd() { try { showLoadingOverlay('Auto-detecting Soulseek (slskd) server...'); const response = await fetch('/api/detect-soulseek', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (result.success) { document.getElementById('soulseek-url').value = result.found_url; showToast(`Soulseek server detected: ${result.found_url}`, 'success'); } else { showToast(result.error, 'error'); } } catch (error) { console.error('Error auto-detecting Soulseek:', error); showToast('Failed to auto-detect Soulseek server', 'error'); } finally { hideLoadingOverlay(); } } function cancelDetection(service) { const progressDiv = document.getElementById(`${service}-detection-progress`); progressDiv.classList.add('hidden'); showToast(`${service} detection cancelled`, 'error'); } function updateStatusDisplays() { // Update status displays based on current service status // This would be called after status updates const services = ['spotify', 'media-server', 'soulseek']; services.forEach(service => { const display = document.getElementById(`${service}-status-display`); if (display) { // Status will be updated by the regular status monitoring } }); } async function authenticateSpotify() { try { showLoadingOverlay('Saving credentials and starting Spotify authentication...'); // Save settings first to ensure client_id/client_secret are persisted await saveSettings(); showToast('Spotify authentication started', 'success'); window._spotifyAuthWindow = window.open('/auth/spotify', '_blank'); } catch (error) { console.error('Error authenticating Spotify:', error); showToast('Failed to start Spotify authentication', 'error', 'gs-connecting'); } finally { hideLoadingOverlay(); } } async function disconnectSpotify() { if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: 'Disconnect Spotify? Spotify-specific actions will stop until you reauthenticate.' })) { return; } try { showLoadingOverlay('Disconnecting Spotify...'); const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); const data = await response.json(); if (data.success) { showToast(data.message || 'Spotify disconnected.', 'success'); syncMetadataSourceSelection(data.source || 'deezer'); // Immediately refresh status to update UI await fetchAndUpdateServiceStatus(); } else { showToast(`Failed to disconnect: ${data.error}`, 'error'); } } catch (error) { console.error('Error disconnecting Spotify:', error); showToast('Failed to disconnect Spotify', 'error'); } finally { hideLoadingOverlay(); } } // ── Spotify Rate Limit Handling ─────────────────────────────────────────── let _spotifyRateLimitShown = false; let _spotifyInCooldown = false; let _rateLimitModalOpen = false; let _rateLimitCountdownInterval = null; let _rateLimitExpiresAt = 0; function handleSpotifyRateLimit(rateLimitInfo) { if (!rateLimitInfo || !rateLimitInfo.active) { if (_spotifyRateLimitShown) { _spotifyRateLimitShown = false; closeRateLimitModal(); showToast('Spotify access restored', 'success'); // Refresh discover page if user is on it — data source switched back to Spotify if (currentPage === 'discover') { console.log('Spotify restored — refreshing discover page data'); loadDiscoverPage(); } } return; } // Update countdown if modal is open (status pushes every 10s keep it accurate) if (_rateLimitModalOpen && rateLimitInfo.remaining_seconds) { _rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000); } if (!_spotifyRateLimitShown) { _spotifyRateLimitShown = true; _spotifyInCooldown = false; showRateLimitModal(rateLimitInfo); // Refresh discover page if user is on it — data source switched to iTunes if (currentPage === 'discover') { console.log('Spotify rate limited — refreshing discover page with iTunes data'); loadDiscoverPage(); } } } function showRateLimitModal(rateLimitInfo) { const overlay = document.getElementById('rate-limit-modal-overlay'); if (!overlay) return; // Populate details const banDuration = document.getElementById('rate-limit-ban-duration'); const endpoint = document.getElementById('rate-limit-endpoint'); const countdown = document.getElementById('rate-limit-countdown'); banDuration.textContent = formatRateLimitDuration(rateLimitInfo.retry_after || rateLimitInfo.remaining_seconds); endpoint.textContent = rateLimitInfo.endpoint || 'unknown'; countdown.textContent = formatRateLimitDuration(rateLimitInfo.remaining_seconds); // Set expiry for live countdown _rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000); // Start live countdown timer if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval); _rateLimitCountdownInterval = setInterval(() => { const remaining = Math.max(0, Math.round((_rateLimitExpiresAt - Date.now()) / 1000)); countdown.textContent = formatRateLimitDuration(remaining); if (remaining <= 0) { clearInterval(_rateLimitCountdownInterval); _rateLimitCountdownInterval = null; } }, 1000); overlay.classList.remove('hidden'); _rateLimitModalOpen = true; } function closeRateLimitModal() { const overlay = document.getElementById('rate-limit-modal-overlay'); if (overlay) overlay.classList.add('hidden'); if (_rateLimitCountdownInterval) { clearInterval(_rateLimitCountdownInterval); _rateLimitCountdownInterval = null; } _rateLimitModalOpen = false; } async function disconnectSpotifyFromRateLimit() { closeRateLimitModal(); try { showLoadingOverlay('Disconnecting Spotify...'); const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); const data = await response.json(); if (data.success) { _spotifyRateLimitShown = false; showToast(data.message || 'Spotify disconnected.', 'success'); syncMetadataSourceSelection(data.source || 'deezer'); await fetchAndUpdateServiceStatus(); if (currentPage === 'discover') { loadDiscoverPage(); } } else { showToast(`Failed to disconnect: ${data.error}`, 'error'); } } catch (error) { console.error('Error disconnecting Spotify:', error); showToast('Failed to disconnect Spotify', 'error'); } finally { hideLoadingOverlay(); } } function formatRateLimitDuration(seconds) { if (!seconds || seconds <= 0) return '0s'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } async function authenticateTidal() { try { showLoadingOverlay('Saving credentials and starting Tidal authentication...'); // Save settings first to ensure credentials are persisted await saveSettings(); showToast('Tidal authentication started', 'success'); window.open('/auth/tidal', '_blank'); } catch (error) { console.error('Error authenticating Tidal:', error); showToast('Failed to start Tidal authentication', 'error'); } finally { hideLoadingOverlay(); } } async function disconnectTidal() { // Clear saved Tidal token. Use when re-authentication doesn't pick // up newly-added scopes (existing token predates a scope expansion // and `prompt=consent` alone isn't forcing fresh consent on this // user's auth flow). After disconnect, click Authenticate again // for a clean grant. if (!confirm('Disconnect Tidal? Saved token will be cleared and you\'ll need to re-authenticate.')) { return; } try { showLoadingOverlay('Disconnecting Tidal...'); const resp = await fetch('/api/tidal/disconnect', { method: 'POST' }); const data = await resp.json(); if (resp.ok && data.success) { showToast('Tidal disconnected. Click Authenticate to reconnect with current scopes.', 'success'); } else { showToast(`Disconnect failed: ${data.error || 'unknown error'}`, 'error'); } } catch (error) { console.error('Error disconnecting Tidal:', error); showToast('Failed to disconnect Tidal', 'error'); } finally { hideLoadingOverlay(); } } async function authenticateDeezer() { try { showLoadingOverlay('Saving credentials and starting Deezer authentication...'); await saveSettings(); showToast('Deezer authentication started', 'success'); window.open('/auth/deezer', '_blank'); } catch (error) { console.error('Error authenticating Deezer:', error); showToast('Failed to start Deezer authentication', 'error'); } finally { hideLoadingOverlay(); } } // ===== Tidal Download Auth (Device Flow) ===== async function testHiFiConnection() { const statusEl = document.getElementById('hifi-connection-status'); const btn = document.getElementById('hifi-test-btn'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { const resp = await fetch('/api/hifi/status'); const data = await resp.json(); if (data.available) { statusEl.textContent = `Connected (v${data.version || '?'})`; statusEl.style.color = '#4caf50'; } else { statusEl.textContent = 'No instances reachable'; statusEl.style.color = '#ff9800'; } } catch (e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#f44336'; } } async function testSoundcloudConnection() { const statusEl = document.getElementById('soundcloud-connection-status'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { const resp = await fetch('/api/soundcloud/status'); const data = await resp.json(); if (data.available && data.reachable) { statusEl.textContent = 'Connected (anonymous)'; statusEl.style.color = '#4caf50'; } else if (data.available) { // Client up but the live probe failed — likely a SoundCloud // outage or a transient yt-dlp parse error. Surface plainly. statusEl.textContent = 'Reachable check failed — try again'; statusEl.style.color = '#ff9800'; } else { statusEl.textContent = data.error || 'Unavailable'; statusEl.style.color = '#f44336'; } } catch (e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#f44336'; } } async function testLidarrConnection() { const statusEl = document.getElementById('lidarr-connection-status'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { // Save settings first so the backend has the URL/key await saveSettings(); const resp = await fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service: 'lidarr' }) }); const data = await resp.json(); if (data.success) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; } else { statusEl.textContent = data.error || 'Connection failed'; statusEl.style.color = '#f44336'; } } catch (e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#f44336'; } } function _setIndStatusDot(dotId, state) { const dot = document.getElementById(dotId); if (!dot) return; dot.classList.remove('ind-status-dot-unknown', 'ind-status-dot-connected', 'ind-status-dot-error'); if (state === 'connected') { dot.classList.add('ind-status-dot-connected'); dot.title = 'Connected'; } else if (state === 'error') { dot.classList.add('ind-status-dot-error'); dot.title = 'Connection failed'; } else { dot.classList.add('ind-status-dot-unknown'); dot.title = 'Not tested'; } } async function testProwlarrConnection() { const statusEl = document.getElementById('prowlarr-connection-status'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { await saveSettings(); const resp = await fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service: 'prowlarr' }) }); const data = await resp.json(); if (data.success) { statusEl.textContent = data.message || 'Connected'; statusEl.style.color = '#4caf50'; _setIndStatusDot('prowlarr-status-dot', 'connected'); loadProwlarrIndexers(); } else { statusEl.textContent = data.error || 'Connection failed'; statusEl.style.color = '#f44336'; _setIndStatusDot('prowlarr-status-dot', 'error'); } } catch (e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#f44336'; _setIndStatusDot('prowlarr-status-dot', 'error'); } } function updateUsenetClientUI() { const type = document.getElementById('usenet-client-type')?.value || 'sabnzbd'; const apikeyGroup = document.getElementById('usenet-apikey-group'); const userGroup = document.getElementById('usenet-username-group'); const passGroup = document.getElementById('usenet-password-group'); if (type === 'sabnzbd') { if (apikeyGroup) apikeyGroup.style.display = ''; if (userGroup) userGroup.style.display = 'none'; if (passGroup) passGroup.style.display = 'none'; } else { if (apikeyGroup) apikeyGroup.style.display = 'none'; if (userGroup) userGroup.style.display = ''; if (passGroup) passGroup.style.display = ''; } } async function testUsenetClientConnection() { const statusEl = document.getElementById('usenet-client-connection-status'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { await saveSettings(); const resp = await fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service: 'usenet_client' }) }); const data = await resp.json(); if (data.success) { statusEl.textContent = data.message || 'Connected'; statusEl.style.color = '#4caf50'; _setIndStatusDot('usenet-client-status-dot', 'connected'); } else { statusEl.textContent = data.error || 'Connection failed'; statusEl.style.color = '#f44336'; _setIndStatusDot('usenet-client-status-dot', 'error'); } } catch (e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#f44336'; _setIndStatusDot('usenet-client-status-dot', 'error'); } } async function testTorrentClientConnection() { const statusEl = document.getElementById('torrent-client-connection-status'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { await saveSettings(); const resp = await fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service: 'torrent_client' }) }); const data = await resp.json(); if (data.success) { statusEl.textContent = data.message || 'Connected'; statusEl.style.color = '#4caf50'; _setIndStatusDot('torrent-client-status-dot', 'connected'); } else { statusEl.textContent = data.error || 'Connection failed'; statusEl.style.color = '#f44336'; _setIndStatusDot('torrent-client-status-dot', 'error'); } } catch (e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#f44336'; _setIndStatusDot('torrent-client-status-dot', 'error'); } } async function loadProwlarrIndexers() { const listEl = document.getElementById('prowlarr-indexer-list'); if (!listEl) return; listEl.innerHTML = 'Loading…'; try { const resp = await fetch('/api/prowlarr/indexers'); const data = await resp.json(); if (!data.success) { listEl.innerHTML = `${data.error || 'Prowlarr not configured.'}`; return; } if (!data.indexers || data.indexers.length === 0) { listEl.innerHTML = 'No indexers configured in Prowlarr yet. Add some in Prowlarr → Indexers.'; return; } const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const rows = data.indexers.map(idx => { const protoClass = idx.protocol === 'usenet' ? 'ind-indexer-card-proto-usenet' : 'ind-indexer-card-proto-torrent'; const protoLabel = idx.protocol === 'usenet' ? 'Usenet' : 'Torrent'; const privacyHTML = idx.privacy ? `${esc(idx.privacy)}` : ''; const disabledClass = idx.enable ? '' : ' ind-indexer-card-disabled'; return `
#${esc(idx.id)} ${esc(idx.name)} ${privacyHTML} ${protoLabel}
`; }).join(''); listEl.innerHTML = rows; } catch (e) { listEl.innerHTML = `Failed to load indexers: ${e.message}`; } } async function loadHiFiInstances() { const listEl = document.getElementById('hifi-instances-list'); if (!listEl) return; try { const resp = await fetch('/api/hifi/instances/list'); const data = await resp.json(); if (!data.instances || data.instances.length === 0) { listEl.innerHTML = '
No instances configured.
'; return; } listEl.innerHTML = data.instances.map((inst, i) => { const enabledClass = inst.enabled ? '' : 'hifi-instance-disabled'; const checkHtml = inst.enabled ? `` : ``; return `
${escapeHtml(inst.url)} ${checkHtml}
`; }).join(''); _initHiFiDragDrop(); _initHiFiClickHandlers(); } catch (e) { listEl.innerHTML = `
Error loading instances: ${escapeHtml(e.message)}
`; } } function _initHiFiDragDrop() { const listEl = document.getElementById('hifi-instances-list'); if (!listEl) return; let dragIdx = null; listEl.querySelectorAll('.hifi-instance-item').forEach((item, idx) => { item.addEventListener('dragstart', (e) => { dragIdx = idx; item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); item.addEventListener('dragend', () => { item.classList.remove('dragging'); dragIdx = null; listEl.querySelectorAll('.hifi-instance-item').forEach(i => i.classList.remove('drag-over')); }); item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; item.classList.add('drag-over'); }); item.addEventListener('dragleave', () => { item.classList.remove('drag-over'); }); item.addEventListener('drop', async (e) => { e.preventDefault(); item.classList.remove('drag-over'); if (dragIdx === null) return; const items = [...listEl.querySelectorAll('.hifi-instance-item')]; const dragged = items[dragIdx]; if (dragIdx !== idx) { if (dragIdx < idx) { item.after(dragged); } else { item.before(dragged); } const urls = [...listEl.querySelectorAll('.hifi-instance-item')].map(el => el.dataset.url); await _saveHiFiInstanceOrder(urls); } }); }); } function _initHiFiClickHandlers() { const listEl = document.getElementById('hifi-instances-list'); if (!listEl) return; listEl.onclick = (e) => { const toggle = e.target.closest('.hifi-instance-toggle'); if (toggle) { e.preventDefault(); toggleHiFiInstance(toggle.dataset.url); return; } const remove = e.target.closest('.hifi-instance-remove'); if (remove) { e.preventDefault(); removeHiFiInstance(remove.dataset.url); } }; } async function _saveHiFiInstanceOrder(urls) { try { await fetch('/api/hifi/instances/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ urls }) }); } catch (e) { console.error('Failed to save HiFi instance order:', e); } } async function toggleHiFiInstance(url) { const listEl = document.getElementById('hifi-instances-list'); if (!listEl) return; const item = listEl.querySelector(`.hifi-instance-item[data-url="${url}"]`); const toggle = item?.querySelector('.hifi-instance-toggle'); const currentlyEnabled = toggle?.classList.contains('on'); const newEnabled = !currentlyEnabled; try { const resp = await fetch('/api/hifi/instances/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, enabled: newEnabled }) }); const data = await resp.json(); if (data.success) { loadHiFiInstances(); } else { alert(data.error || 'Failed to toggle instance'); } } catch (e) { alert(`Error: ${e.message}`); } } async function addHiFiInstance() { const input = document.getElementById('hifi-new-instance'); if (!input) return; const url = input.value.trim(); if (!url) return; if (!url.startsWith('http://') && !url.startsWith('https://')) { alert('URL must start with http:// or https://'); return; } try { const resp = await fetch('/api/hifi/instances', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const data = await resp.json(); if (data.success) { input.value = ''; loadHiFiInstances(); } else { alert(data.error || 'Failed to add instance'); } } catch (e) { alert(`Error: ${e.message}`); } } async function removeHiFiInstance(url) { try { const resp = await fetch(`/api/hifi/instances?url=${encodeURIComponent(url)}`, { method: 'DELETE' }); const data = await resp.json(); if (data.success) { loadHiFiInstances(); } else { alert(data.error || 'Failed to remove instance'); } } catch (e) { alert(`Error: ${e.message}`); } } async function checkHiFiInstances() { const panel = document.getElementById('hifi-instances-status-panel'); const btn = document.getElementById('hifi-instances-check-btn'); if (!panel) return; panel.style.display = 'block'; panel.innerHTML = '
Checking instances...
'; if (btn) { btn.disabled = true; btn.textContent = 'Checking...'; } try { const resp = await fetch('/api/hifi/instances'); const data = await resp.json(); if (!data.instances || data.instances.length === 0) { panel.innerHTML = '
No instances configured.
'; return; } const _statusIcon = (inst) => { if (inst.can_download) return '● Download'; if (inst.can_search) return '● Search only'; if (inst.status === 'online') return '● Online (limited)'; if (inst.status === 'ssl_error') return '● SSL error'; if (inst.status === 'timeout') return '● Timeout'; if (inst.status === 'offline') return '● Offline'; return `● ${escapeHtml(inst.status)}`; }; panel.innerHTML = data.instances.map(inst => { const isActive = inst.url === data.active; const ver = inst.version ? ` v${inst.version}` : ''; const activeTag = isActive ? ' (ACTIVE)' : ''; return `
${escapeHtml(inst.url)}${ver}${activeTag} ${_statusIcon(inst)}
`; }).join(''); } catch (e) { panel.innerHTML = `
Error checking instances: ${escapeHtml(e.message)}
`; } finally { if (btn) { btn.disabled = false; btn.textContent = 'Check All Instances'; } } } async function testDeezerDownloadConnection() { const statusEl = document.getElementById('deezer-download-status'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { // Save the ARL first so the backend can use it const arl = document.getElementById('deezer-download-arl')?.value || ''; if (!arl) { statusEl.textContent = 'No ARL token provided'; statusEl.style.color = '#ff9800'; return; } const resp = await fetch('/api/deezer-download/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ arl }), }); const data = await resp.json(); if (data.success) { statusEl.textContent = `Connected as ${data.user || 'Unknown'} (${data.tier || 'Free'})`; statusEl.style.color = '#4caf50'; } else { statusEl.textContent = data.error || 'Authentication failed'; statusEl.style.color = '#f44336'; } } catch (e) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#f44336'; } } async function testAmazonConnection() { const statusEl = document.getElementById('amazon-connection-status'); if (!statusEl) return; statusEl.textContent = 'Checking...'; statusEl.style.color = '#aaa'; try { const resp = await fetch('/api/amazon/test-connection'); const data = await resp.json(); if (data.connected) { statusEl.textContent = '✓ Connected — T2Tunes up'; statusEl.style.color = '#4caf50'; } else { statusEl.textContent = '✗ ' + (data.error || 'T2Tunes unreachable'); statusEl.style.color = '#f44336'; } } catch (e) { statusEl.textContent = '✗ Connection error'; statusEl.style.color = '#f44336'; } } async function checkTidalDownloadAuthStatus() { const statusEl = document.getElementById('tidal-download-auth-status'); const btn = document.getElementById('tidal-download-auth-btn'); try { const resp = await fetch('/api/tidal/download/auth/status'); const data = await resp.json(); if (data.authenticated) { statusEl.textContent = 'Authenticated'; statusEl.style.color = '#4caf50'; btn.textContent = 'Re-link Tidal Account'; } else { statusEl.textContent = 'Not authenticated'; statusEl.style.color = '#ff9800'; btn.textContent = 'Link Tidal Account'; } } catch (e) { statusEl.textContent = ''; } } let _tidalAuthPollTimer = null; async function startTidalDownloadAuth() { const btn = document.getElementById('tidal-download-auth-btn'); const statusEl = document.getElementById('tidal-download-auth-status'); const codeEl = document.getElementById('tidal-download-auth-code'); btn.disabled = true; btn.textContent = 'Starting...'; statusEl.textContent = ''; try { const resp = await fetch('/api/tidal/download/auth/start', { method: 'POST' }); const data = await resp.json(); if (!resp.ok || !data.success) { throw new Error(data.error || 'Failed to start auth'); } // Show the link/code to the user const uri = data.verification_uri || ''; const code = data.user_code || ''; codeEl.style.display = 'block'; codeEl.innerHTML = `Go to ${uri} and enter code: ${code}`; btn.textContent = 'Waiting for approval...'; statusEl.textContent = 'Waiting...'; statusEl.style.color = '#ff9800'; // Poll for completion if (_tidalAuthPollTimer) clearInterval(_tidalAuthPollTimer); _tidalAuthPollTimer = setInterval(async () => { try { const checkResp = await fetch('/api/tidal/download/auth/check'); const checkData = await checkResp.json(); if (checkData.status === 'completed') { clearInterval(_tidalAuthPollTimer); _tidalAuthPollTimer = null; codeEl.style.display = 'none'; statusEl.textContent = 'Authenticated'; statusEl.style.color = '#4caf50'; btn.disabled = false; btn.textContent = 'Re-link Tidal Account'; showToast('Tidal download account linked successfully', 'success'); } else if (checkData.status === 'error') { clearInterval(_tidalAuthPollTimer); _tidalAuthPollTimer = null; codeEl.style.display = 'none'; statusEl.textContent = 'Auth failed'; statusEl.style.color = '#f44336'; btn.disabled = false; btn.textContent = 'Link Tidal Account'; showToast('Tidal auth failed: ' + (checkData.message || 'Unknown error'), 'error'); } // status === 'pending' — keep polling } catch (pollErr) { console.error('Tidal auth poll error:', pollErr); } }, 3000); } catch (error) { console.error('Tidal download auth error:', error); showToast('Failed to start Tidal auth: ' + error.message, 'error'); btn.disabled = false; btn.textContent = 'Link Tidal Account'; codeEl.style.display = 'none'; } } // =============================== // QOBUZ AUTH FUNCTIONS // =============================== async function checkQobuzAuthStatus() { try { const resp = await fetch('/api/qobuz/auth/status'); const data = await resp.json(); // Update downloads tab section const formEl = document.getElementById('qobuz-auth-form'); const loggedInEl = document.getElementById('qobuz-auth-logged-in'); const userInfoEl = document.getElementById('qobuz-auth-user-info'); // Update connections tab section const connFormEl = document.getElementById('qobuz-connection-form'); const connLoggedInEl = document.getElementById('qobuz-connection-logged-in'); const connUserInfoEl = document.getElementById('qobuz-connection-user-info'); if (data.authenticated) { const user = data.user || {}; const label = `Connected: ${user.display_name || 'Qobuz User'} (${user.subscription || 'Active'})`; if (userInfoEl) { userInfoEl.textContent = label; } if (loggedInEl) loggedInEl.style.display = 'flex'; if (formEl) formEl.style.display = 'none'; if (connUserInfoEl) { connUserInfoEl.textContent = label; } if (connLoggedInEl) connLoggedInEl.style.display = 'flex'; if (connFormEl) connFormEl.style.display = 'none'; } else { if (loggedInEl) loggedInEl.style.display = 'none'; if (formEl) formEl.style.display = 'block'; if (connLoggedInEl) connLoggedInEl.style.display = 'none'; if (connFormEl) connFormEl.style.display = 'block'; } } catch (e) { console.error('Qobuz auth status check failed:', e); } } async function loginQobuzFromConnections() { const btn = document.getElementById('qobuz-connection-login-btn'); const statusEl = document.getElementById('qobuz-connection-status'); const email = document.getElementById('qobuz-connection-email').value.trim(); const password = document.getElementById('qobuz-connection-password').value; if (!email || !password) { showToast('Please enter your Qobuz email and password', 'warning'); return; } btn.disabled = true; btn.textContent = 'Connecting...'; statusEl.textContent = ''; try { const resp = await fetch('/api/qobuz/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const data = await resp.json(); if (data.success) { showToast('Qobuz connected successfully!', 'success'); document.getElementById('qobuz-connection-password').value = ''; checkQobuzAuthStatus(); } else { statusEl.textContent = data.error || 'Login failed'; statusEl.style.color = '#ff5555'; showToast(data.error || 'Qobuz login failed', 'error'); } } catch (error) { console.error('Qobuz login error:', error); statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; showToast('Failed to connect to Qobuz', 'error'); } finally { btn.disabled = false; btn.textContent = 'Connect Qobuz'; } } async function loginQobuzWithToken() { const btn = document.getElementById('qobuz-token-login-btn'); const statusEl = document.getElementById('qobuz-token-status'); const token = document.getElementById('qobuz-connection-token').value.trim(); if (!token) { showToast('Please paste your Qobuz auth token', 'warning'); return; } btn.disabled = true; btn.textContent = 'Connecting...'; if (statusEl) statusEl.textContent = ''; try { const resp = await fetch('/api/qobuz/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); const data = await resp.json(); if (data.success) { showToast('Qobuz connected via token!', 'success'); document.getElementById('qobuz-connection-token').value = ''; checkQobuzAuthStatus(); } else { if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; } showToast(data.error || 'Qobuz token login failed', 'error'); } } catch (error) { console.error('Qobuz token login error:', error); if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; } showToast('Failed to connect to Qobuz', 'error'); } finally { btn.disabled = false; btn.textContent = 'Connect with Token'; } } async function loginQobuzWithTokenFromDownloads() { const btn = document.getElementById('qobuz-download-token-btn'); const statusEl = document.getElementById('qobuz-download-token-status'); const token = document.getElementById('qobuz-download-token').value.trim(); if (!token) { showToast('Please paste your Qobuz auth token', 'warning'); return; } btn.disabled = true; btn.textContent = 'Connecting...'; if (statusEl) statusEl.textContent = ''; try { const resp = await fetch('/api/qobuz/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); const data = await resp.json(); if (data.success) { showToast('Qobuz connected via token!', 'success'); document.getElementById('qobuz-download-token').value = ''; checkQobuzAuthStatus(); } else { if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; } showToast(data.error || 'Qobuz token login failed', 'error'); } } catch (error) { console.error('Qobuz token login error:', error); if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; } showToast('Failed to connect to Qobuz', 'error'); } finally { btn.disabled = false; btn.textContent = 'Connect with Token'; } } async function loginQobuz() { const btn = document.getElementById('qobuz-login-btn'); const statusEl = document.getElementById('qobuz-auth-status'); const email = document.getElementById('qobuz-email').value.trim(); const password = document.getElementById('qobuz-password').value; if (!email || !password) { showToast('Please enter your Qobuz email and password', 'warning'); return; } btn.disabled = true; btn.textContent = 'Connecting...'; statusEl.textContent = ''; try { const resp = await fetch('/api/qobuz/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const data = await resp.json(); if (data.success) { showToast('Qobuz connected successfully!', 'success'); // Clear password field document.getElementById('qobuz-password').value = ''; checkQobuzAuthStatus(); } else { statusEl.textContent = data.error || 'Login failed'; statusEl.style.color = '#ff5555'; showToast(data.error || 'Qobuz login failed', 'error'); } } catch (error) { console.error('Qobuz login error:', error); statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; showToast('Failed to connect to Qobuz', 'error'); } finally { btn.disabled = false; btn.textContent = 'Connect Qobuz'; } } async function logoutQobuz() { try { await fetch('/api/qobuz/auth/logout', { method: 'POST' }); showToast('Qobuz disconnected', 'success'); checkQobuzAuthStatus(); } catch (e) { console.error('Qobuz logout error:', e); } } const PATH_INPUT_IDS = { download: 'download-path', transfer: 'transfer-path', staging: 'staging-path', 'music-videos': 'music-videos-path', 'm3u-entry-base': 'm3u-entry-base-path' }; function togglePathLock(pathType, btn) { const input = document.getElementById(PATH_INPUT_IDS[pathType]); if (!input) return; const isLocked = input.hasAttribute('readonly'); if (isLocked) { input.removeAttribute('readonly'); input.focus(); btn.textContent = 'Lock'; btn.classList.remove('locked'); } else { input.setAttribute('readonly', ''); btn.textContent = 'Unlock'; btn.classList.add('locked'); } } // ===============================