// 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;
// The "Only allow AcoustID-verified tracks" toggle lives in Audio Verification
// and is always shown (its help notes it needs AcoustID enabled), so users can
// always find it. Kept as a no-op for any existing callers.
function syncAcoustidRequireVerifiedVisibility() {
const group = document.getElementById('acoustid-require-verified-group');
if (group) group.style.display = '';
}
window.syncAcoustidRequireVerifiedVisibility = syncAcoustidRequireVerifiedVisibility;
// Retry Logic: the two numeric rows are only meaningful when their parent toggle
// is on — hide them otherwise. "Retries per query" needs Exhaustive retry;
// "Minimum matching mismatches" needs the version-mismatch fallback.
function syncRetryConditionalRows() {
const pairs = [
['retry-exhaustive', 'retries-per-query-row'],
['accept-version-mismatch-fallback', 'version-mismatch-min-count-row'],
];
for (const [toggleId, rowId] of pairs) {
const toggle = document.getElementById(toggleId);
const row = document.getElementById(rowId);
if (row) row.style.display = (toggle && toggle.checked) ? '' : 'none';
}
}
window.syncRetryConditionalRows = syncRetryConditionalRows;
function debouncedAutoSaveSettings() {
// Ignore changes made while the page is programmatically populating its
// fields on load — those aren't user edits and must not trigger a full
// save (which re-initializes every backend service client).
if (window._suppressSettingsAutoSave) return;
// #879: never auto-save while the last settings load failed — the form is
// showing defaults, not the real config, so saving would wipe it.
if (window._settingsLoadFailed) return;
// #827: the Logs tab has no savable settings — its live-viewer controls
// (source picker, filters, auto-scroll) were tripping the auto-save and
// flooding app.log with "Settings saved" lines, drowning out the logs the
// user is trying to read. Never auto-save while the Logs tab is active.
if (document.querySelector('.stg-tab.active')?.dataset.tab === 'logs') return;
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') {
// Official Spotify needs a connected session.
return _lastStatusPayload?.spotify?.authenticated === true;
}
if (source === 'spotify_free') {
// No-creds Spotify only needs the SpotipyFree package installed —
// selecting it IS the opt-in, so it must NOT depend on having selected it.
return _lastStatusPayload?.spotify?.free_installed === 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 });
}
let _settingsInitialized = false;
// Tell password-manager extensions (Bitwarden / 1Password / LastPass) to ignore
// this app's credential inputs. The settings page is full of API-key / token /
// secret fields; password managers treat them as login forms and re-scan the
// whole (large, constantly-mutating) DOM on every change, which can peg the main
// thread for seconds. These attributes make them skip the fields entirely.
function _markCredentialFieldsNoAutofill(root) {
const scope = root || document;
scope.querySelectorAll('input, textarea').forEach((el) => {
if (el.dataset.bwignore !== undefined) return; // already tagged
el.setAttribute('data-bwignore', ''); // Bitwarden
el.setAttribute('data-1p-ignore', ''); // 1Password
el.setAttribute('data-lpignore', 'true'); // LastPass
if (!el.getAttribute('autocomplete')) el.setAttribute('autocomplete', 'off');
});
}
// Run once on load (inputs exist from page load — all pages are mounted).
if (document.readyState !== 'loading') _markCredentialFieldsNoAutofill();
else document.addEventListener('DOMContentLoaded', () => _markCredentialFieldsNoAutofill());
function initializeSettings() {
// This function is called when the settings page is loaded.
// It attaches event listeners to all interactive elements on the page.
// Listeners are stable for the page lifetime, so wiring them once avoids
// re-scanning the ~960-node settings subtree on every revisit (scroll jank).
if (_settingsInitialized) return;
_settingsInitialized = true;
// Re-tag in case any inputs were added dynamically since page load.
_markCredentialFieldsNoAutofill(document.getElementById('settings-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) { }
}
// First time the Downloads tab is shown, auto-probe source status so the
// dots reflect real connection state without a manual "Test all sources".
if (tab === 'downloads' && typeof autoTestSourcesOnce === 'function') {
autoTestSourcesOnce();
}
// 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: '📰' },
];
const ALBUM_LEVEL_HYBRID_SOURCES = new Set(['soulseek', 'torrent', 'usenet']);
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
// In hybrid mode, only one source's config panel is shown at a time (clicked
// open from its row), so the long per-source config blocks don't all stack up.
let _expandedHybridSource = null;
function toggleHybridSourceConfig(srcId) {
_expandedHybridSource = (_expandedHybridSource === srcId) ? null : srcId;
buildHybridSourceList();
updateDownloadSourceUI();
// Bring the freshly opened config panel into view.
if (_expandedHybridSource) {
const map = {
soulseek: 'soulseek-settings-container', youtube: 'youtube-settings-container',
tidal: 'tidal-download-settings-container', qobuz: 'qobuz-settings-container',
hifi: 'hifi-download-settings-container', deezer_dl: 'deezer-download-settings-container',
amazon: 'amazon-download-settings-container', lidarr: 'lidarr-download-settings-container',
soundcloud: 'soundcloud-download-settings-container', torrent: 'prowlarr-source-redirect',
usenet: 'prowlarr-source-redirect',
};
const el = document.getElementById(map[_expandedHybridSource]);
if (el) setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 60);
}
}
// ── Per-source live connection status (shown as a dot in the hybrid list and
// driven by the "Test all sources" button). srcId -> 'unknown'|'testing'|'ok'|'fail'|'na'
let _hybridSourceStatus = {};
async function _ssJson(url, opts) {
const r = await fetch(url, opts);
return await r.json();
}
function _ssTestConn(service) {
return _ssJson(API.testConnection || '/api/test-connection', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service })
}).then(j => !!j.success);
}
// Each probe returns a boolean (connected/ok). Endpoints mirror the per-source
// "Test Connection" buttons so the results match what those buttons would show.
const HYBRID_SOURCE_PROBE = {
soulseek: () => _ssTestConn('soulseek'),
tidal: () => _ssTestConn('tidal'),
qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
deezer_dl: () => _ssJson('/api/deezer-download/test').then(j => j.success === true),
amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
lidarr: () => _ssTestConn('lidarr'),
soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
torrent: () => _ssTestConn('torrent_client'),
usenet: () => _ssTestConn('usenet_client'),
youtube: () => Promise.resolve(true), // no auth required
};
// Configured metadata / server connections that support a generic test.
const CONNECTION_TEST_SERVICES = ['spotify', 'server', 'tidal', 'qobuz', 'lastfm', 'genius', 'listenbrainz', 'acoustid', 'discogs'];
async function testAllSources(opts = {}) {
const silent = opts.silent === true; // no toast / no connection sweep (used for auto-run on load)
const btn = document.getElementById('test-all-sources-btn');
if (btn && !silent) { btn.disabled = true; btn.dataset._label = btn.textContent; btn.textContent = 'Testing…'; }
// Which download sources to test: the enabled hybrid sources, or the single
// selected source in non-hybrid mode.
const mode = document.getElementById('download-source-mode')?.value;
const sources = new Set();
if (mode === 'hybrid') {
(typeof getHybridOrder === 'function' ? getHybridOrder() : []).forEach(s => sources.add(s));
} else if (mode) {
sources.add(mode);
}
if (sources.size === 0) sources.add('soulseek');
// Torrent/Usenet downloads go through Prowlarr — its connection must be
// established first or those source tests fail. Probe Prowlarr up front.
if (sources.has('torrent') || sources.has('usenet')) {
try { await _ssTestConn('prowlarr'); } catch (e) { /* surfaced via the per-source test below */ }
}
for (const id of sources) _hybridSourceStatus[id] = 'testing';
buildHybridSourceList();
let ok = 0, fail = 0;
for (const id of sources) {
const probe = HYBRID_SOURCE_PROBE[id];
if (!probe) { _hybridSourceStatus[id] = 'na'; continue; }
try { const good = await probe(); _hybridSourceStatus[id] = good ? 'ok' : 'fail'; good ? ok++ : fail++; }
catch (e) { _hybridSourceStatus[id] = 'fail'; fail++; }
buildHybridSourceList();
}
// Also test the metadata / server connections the user has configured
// (skipped on the silent auto-run to keep page load light).
let connOk = 0, connFail = 0;
if (!silent) {
try {
const cfg = await _ssJson('/api/settings/config-status');
for (const svc of CONNECTION_TEST_SERVICES) {
const configured = svc === 'server' ? true : (cfg && cfg[svc] && cfg[svc].configured);
if (!configured) continue;
try { const good = await _ssTestConn(svc); good ? connOk++ : connFail++; } catch (e) { connFail++; }
}
} catch (e) { /* config-status unavailable — skip connection sweep */ }
}
if (btn && !silent) { btn.disabled = false; btn.textContent = btn.dataset._label || 'Test all sources'; }
if (!silent) {
const parts = [`sources ${ok}✓${fail ? ' / ' + fail + '✗' : ''}`];
if (connOk || connFail) parts.push(`connections ${connOk}✓${connFail ? ' / ' + connFail + '✗' : ''}`);
showToast('Tested ' + parts.join(', '), (fail || connFail) ? 'error' : 'success');
}
}
window.testAllSources = testAllSources;
// Auto-populate the source status dots once after the page settles, so they
// reflect real state after a restart without the user having to click Test.
let _sourcesAutoTested = false;
function autoTestSourcesOnce() {
if (_sourcesAutoTested) return;
_sourcesAutoTested = true;
setTimeout(() => { try { testAllSources({ silent: true }); } catch (e) { } }, 1200);
}
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 canOwnAlbum = enabled && priorityNum === 1 && ALBUM_LEVEL_HYBRID_SOURCES.has(srcId);
const sourceLevel = canOwnAlbum ? 'Album-level' : 'Track-level';
const sourceLevelClass = canOwnAlbum ? 'album' : 'track';
const sourceLevelTitle = canOwnAlbum
? 'This first source can download a whole album release before per-track fallback.'
: 'This source runs as per-track fallback in the current hybrid order.';
const sourceLevelBadge = `${sourceLevel}`;
const item = document.createElement('div');
const isExpanded = enabled && _expandedHybridSource === srcId;
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}${isExpanded ? ' config-open' : ''}`;
item.draggable = true;
item.dataset.sourceId = srcId;
// The name + a config chevron open this source's settings panel inline
// (only one at a time), so the long config blocks don't all stack up.
const clickConfig = enabled ? `onclick="toggleHybridSourceConfig('${srcId}')"` : '';
item.innerHTML = `
⠿
${src.icon
? `
`
: `${src.emoji}`
}
${src.name}
${sourceLevelBadge}
${priorityNum}
${enabled ? `` : ''}
`;
// Real drag-to-reorder (the help text promised it; previously only the
// arrow buttons worked — item.draggable was set with no handlers).
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', srcId);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
container.querySelectorAll('.hybrid-source-item').forEach(el => el.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', (e) => {
e.preventDefault();
item.classList.remove('drag-over');
const draggedId = e.dataTransfer.getData('text/plain');
if (draggedId && draggedId !== srcId) _reorderHybridSource(draggedId, srcId);
});
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 _reorderHybridSource(draggedId, targetId) {
// Move draggedId to just before targetId in the visual order, then rebuild
// the enabled subset + persist — same model moveHybridSource uses.
if (!_hybridVisualOrder) return;
const from = _hybridVisualOrder.indexOf(draggedId);
if (from < 0) return;
_hybridVisualOrder.splice(from, 1);
const to = _hybridVisualOrder.indexOf(targetId);
if (to < 0) { _hybridVisualOrder.splice(from, 0, draggedId); return; } // target gone — undo
_hybridVisualOrder.splice(to, 0, draggedId);
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
buildHybridSourceList();
updateDownloadSourceUI();
debouncedAutoSaveSettings();
}
function toggleHybridSource(srcId, enabled) {
_hybridSourceEnabled[srcId] = enabled;
// If the source we just disabled had its config panel open, close it.
if (!enabled && _expandedHybridSource === srcId) _expandedHybridSource = null;
// 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);
}
// ---- Preferred album-art sources (reuses the hybrid-source-list styling) ----
const ART_SOURCES = [
{ id: 'caa', name: 'Cover Art Archive', icon: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png', emoji: '🎨' },
{ id: 'deezer', name: 'Deezer', icon: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610', emoji: '🎧' },
{ id: 'itunes', name: 'iTunes', icon: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', emoji: '🍎' },
{ id: 'spotify', name: 'Spotify', icon: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', emoji: '🟢' },
{ id: 'audiodb', name: 'TheAudioDB', icon: null, emoji: '💿' },
];
let _artSourceEnabled = {}; // id -> bool
let _artVisualOrder = []; // available source ids, in display order
let _artAvailable = []; // ids the user is connected to
function buildArtSourceList() {
const container = document.getElementById('art-source-list');
if (!container) return;
container.innerHTML = '';
if (!_artVisualOrder.length) {
container.innerHTML = '
No connected art sources available.
';
return;
}
const enabledOrder = getArtOrder();
_artVisualOrder.forEach((srcId) => {
const src = ART_SOURCES.find(s => s.id === srcId);
if (!src) return;
const enabled = _artSourceEnabled[srcId] === true;
const priorityNum = enabled ? enabledOrder.indexOf(srcId) + 1 : '';
const item = document.createElement('div');
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
item.dataset.sourceId = srcId;
item.innerHTML = `
${src.icon
? `
`
: `${src.emoji}`
}
${src.name}
${priorityNum}
`;
container.appendChild(item);
});
}
function moveArtSource(srcId, direction) {
const idx = _artVisualOrder.indexOf(srcId);
if (idx < 0) return;
const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= _artVisualOrder.length) return;
[_artVisualOrder[idx], _artVisualOrder[newIdx]] = [_artVisualOrder[newIdx], _artVisualOrder[idx]];
buildArtSourceList();
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
}
function toggleArtSource(srcId, enabled) {
_artSourceEnabled[srcId] = enabled;
buildArtSourceList();
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
}
// Saved value: enabled sources in their displayed order.
function getArtOrder() {
return _artVisualOrder.filter(id => _artSourceEnabled[id] === true);
}
async function loadArtSourceOrder(settings) {
const valid = new Set(ART_SOURCES.map(s => s.id));
const saved = (settings && settings.metadata_enhancement
&& Array.isArray(settings.metadata_enhancement.album_art_order))
? settings.metadata_enhancement.album_art_order : [];
// Populate the saved order SYNCHRONOUSLY, filtered to known art sources
// (NOT by availability). This guarantees a save that fires before the
// availability fetch resolves — or while a saved source is temporarily
// disconnected — can never wipe the user's saved order. The backend skips
// any unavailable source at resolution time, and it re-activates on
// reconnect, so keeping it in the list is safe and preserves intent.
_artSourceEnabled = {};
_artVisualOrder = [];
saved.forEach(id => {
if (valid.has(id) && !_artVisualOrder.includes(id)) {
_artVisualOrder.push(id);
_artSourceEnabled[id] = true;
}
});
buildArtSourceList();
// Then fetch which sources are actually connected and append any that
// aren't already listed (shown disabled, ready to enable).
try {
const resp = await fetch('/api/metadata/art-sources');
const data = await resp.json();
_artAvailable = (data.available || []).map(s => (s && s.id) ? s.id : s);
} catch (e) {
_artAvailable = [];
}
_artAvailable.forEach(id => {
if (valid.has(id) && !_artVisualOrder.includes(id)) {
_artVisualOrder.push(id);
_artSourceEnabled[id] = false;
}
});
buildArtSourceList();
}
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();
// #879: a failed GET /api/settings returns an error body (e.g. {"error":
// "..."} on a 500), NOT real settings. Populating from it blanks every
// field to its default ('settings.spotify?.x || ""'), and the next
// (auto)save then overwrites the user's real config. Abort BEFORE
// touching any field and flag it so saves stay blocked until a good load.
if (!response.ok || !settings || typeof settings !== 'object' || settings.error) {
window._settingsLoadFailed = true;
throw new Error('settings load failed (HTTP ' + response.status + '): ' +
((settings && settings.error) || 'unexpected response'));
}
window._settingsLoadFailed = false; // good load → saving is safe again
// 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;
const _acoustidRequireVerified = document.getElementById('acoustid-require-verified');
if (_acoustidRequireVerified) _acoustidRequireVerified.checked = settings.acoustid?.require_verified === true;
// Show the "require verified" toggle (under Quality Profile) only when AcoustID is on.
if (typeof syncAcoustidRequireVerifiedVisibility === 'function') syncAcoustidRequireVerifiedVisibility();
// 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. 'Spotify Free' is stored as
// fallback_source='spotify' + spotify_free=true (so all downstream
// 'spotify' routing is unchanged) — map it back to the dropdown value.
const _fbSrc = settings.metadata?.fallback_source || 'deezer';
const _metaSel = (_fbSrc === 'spotify' && settings.metadata?.spotify_free === true)
? 'spotify_free' : _fbSrc;
document.getElementById('metadata-fallback-source').value = _metaSel;
const _efEl = document.getElementById('metadata-spotify-free-enrichment');
// Default ON: unset (undefined) reads as enabled, matching the worker's
// config default (metadata.spotify_free_enrichment defaults True).
if (_efEl) _efEl.checked = settings.metadata?.spotify_free_enrichment !== false;
// 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';
document.getElementById('playlists-materialize-path').value = settings.playlists?.materialize_path || './Playlists';
document.getElementById('playlists-materialize-mode').value = settings.playlists?.materialize_mode || 'symlink';
// 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);
loadArtSourceOrder(settings);
// Per-source download quality is now derived from the global Quality
// Profile (ranked targets) — the per-source quality selects were removed.
loadHiFiInstances();
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
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 || '';
// Stalled-torrent knobs live under download_source but render in the
// torrent client section. Timeout is stored in SECONDS, shown in MINUTES.
const _tcStall = document.getElementById('torrent-stall-timeout');
const _tcStallAct = document.getElementById('torrent-stall-action');
if (_tcStall) {
const secs = settings.download_source?.torrent_stall_timeout_seconds;
_tcStall.value = (secs === undefined || secs === null) ? 10 : Math.round(Number(secs) / 60);
}
if (_tcStallAct) _tcStallAct.value = settings.download_source?.torrent_stall_action || 'abandon';
const _tcDlPath = document.getElementById('torrent-download-path');
if (_tcDlPath) _tcDlPath.value = settings.download_source?.torrent_download_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';
const _ucDlPath = document.getElementById('usenet-download-path');
if (_ucDlPath) _ucDlPath.value = settings.download_source?.usenet_download_path || '';
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;
// Show the cookies.txt paste box only in "custom" mode. We never echo the
// stored cookie back to the UI (it's secret + lives in a file, not config);
// if one is already saved, say so via placeholder so a blank save won't wipe it.
const _ytCookieSel = document.getElementById('youtube-cookies-browser');
const _ytPasteBox = document.getElementById('youtube-cookies-paste');
const _ytPasteGroup = document.getElementById('youtube-cookies-paste-group');
if (_ytCookieSel && _ytPasteGroup) {
const _toggleYtPaste = () => {
_ytPasteGroup.style.display = _ytCookieSel.value === 'custom' ? '' : 'none';
};
if (_ytPasteBox && settings.youtube?.cookies_file) {
_ytPasteBox.placeholder = 'A cookies.txt is saved. Paste again to replace it, or leave blank to keep it.';
}
_toggleYtPaste();
if (!_ytCookieSel.dataset.pasteToggleBound) {
_ytCookieSel.addEventListener('change', _toggleYtPaste);
_ytCookieSel.dataset.pasteToggleBound = '1';
}
}
// 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('single-to-album-enabled').checked = settings.metadata_enhancement?.single_to_album === 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('audio-completeness-check').checked = settings.post_processing?.audio_completeness_check === true;
document.getElementById('duration-tolerance-seconds').value = settings.post_processing?.duration_tolerance_seconds ?? 0;
document.getElementById('retry-next-candidate').checked = settings.post_processing?.retry_next_candidate_on_mismatch !== false;
document.getElementById('retry-exhaustive').checked = settings.post_processing?.retry_exhaustive === true;
document.getElementById('retries-per-query').value = settings.post_processing?.retries_per_query ?? 5;
document.getElementById('accept-version-mismatch-fallback').checked = settings.post_processing?.accept_version_mismatch_fallback === true;
document.getElementById('version-mismatch-min-count').value = settings.post_processing?.version_mismatch_min_count ?? 2;
if (typeof syncRetryConditionalRows === 'function') syncRetryConditionalRows();
// 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-playlist-item').value = settings.file_organization?.templates?.playlist_item || '';
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;
const _syncModeEl = document.getElementById('playlist-sync-mode');
if (_syncModeEl) _syncModeEl.value = settings.playlist_sync?.mode || 'replace';
// 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
const _qualFilterEl = document.getElementById('import-quality-filter-enabled');
if (_qualFilterEl) _qualFilterEl.checked = settings.import?.quality_filter_enabled !== false; // default ON
document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true;
const _folderArtistEl = document.getElementById('import-folder-artist-override');
if (_folderArtistEl) _folderArtistEl.checked = settings.import?.folder_artist_override === true;
const _transferPermEl = document.getElementById('import-transfer-permanent');
if (_transferPermEl) _transferPermEl.checked = settings.import?.transfer_is_permanent === 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 === true; // default OFF (GPU cost)
const particlesCheckbox = document.getElementById('particles-enabled');
if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled;
applyParticlesSetting(particlesEnabled);
// Worker orbs toggle. When the user hasn't saved a preference, reflect the
// server-decided browser-aware default (window._workerOrbsEnabled — OFF on
// Firefox for perf) so saving settings doesn't silently flip a first-time
// Firefox user's orbs back on. An explicit saved config value always wins.
const _orbsCfg = settings.ui_appearance?.worker_orbs_enabled;
const workerOrbsEnabled = (_orbsCfg === undefined || _orbsCfg === null)
? (window._workerOrbsEnabled !== false)
: (_orbsCfg !== false);
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled;
applyWorkerOrbsSetting(workerOrbsEnabled);
// Reduce effects toggle. This flag is device-scoped: localStorage (set by the
// live toggle and by weak-hardware auto-detect) is the source of truth for THIS
// machine; the server value is only the cross-device default used when this
// device has never chosen. Prefer localStorage when present so opening Settings
// doesn't clobber an auto-enabled (or manually-set) per-device choice.
const serverReduce = settings.ui_appearance?.reduce_effects === true; // default false
const localReduce = localStorage.getItem('soulsync-reduce-effects'); // '1' | '0' | null
const reduceEffects = localReduce !== null ? (localReduce === '1') : serverReduce;
const reduceCheckbox = document.getElementById('reduce-effects-enabled');
if (reduceCheckbox) reduceCheckbox.checked = reduceEffects;
applyReduceEffects(reduceEffects);
// Max Performance — same device-scoped resolution as reduce-effects:
// localStorage is the per-device truth, server value the cross-device default.
// Applied last so it can lock/override the dependent toggles above when on.
const serverMaxPerf = settings.ui_appearance?.max_performance === true; // default false
const localMaxPerf = localStorage.getItem('soulsync-max-performance'); // '1' | '0' | null
const maxPerf = localMaxPerf !== null ? (localMaxPerf === '1') : serverMaxPerf;
const maxPerfCheckbox = document.getElementById('max-performance-enabled');
if (maxPerfCheckbox) maxPerfCheckbox.checked = maxPerf;
applyMaxPerformance(maxPerf);
// 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;
// Reverse-proxy mode + auth-proxy header (default off / empty).
const trustProxy = document.getElementById('security-trust-proxy');
if (trustProxy) trustProxy.checked = settings.security?.trust_reverse_proxy || false;
const authHeader = document.getElementById('security-auth-proxy-header');
if (authHeader) authHeader.value = settings.security?.auth_proxy_header || '';
const reqLogin = document.getElementById('security-require-login');
if (reqLogin) reqLogin.checked = settings.security?.require_login || false;
// 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;
}
// Login: the "Require login" toggle is gated on an admin password —
// visually locked until Step 1 is done (anti-lockout, made obvious).
updateRequireLoginGate(adminProfile?.has_password || false);
// Show already-saved password/recovery state (vs looking unset).
applyLoginSavedState(adminProfile);
} 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);
}
// Secret fields now arrive masked as REDACTED_SECRET_SENTINEL (#832
// follow-up) — wire them so editing replaces the mask instead of typing
// on top of it, and an untouched field re-masks on blur (round-trips the
// sentinel, which the server treats as "keep existing").
_wireRedactedSecrets();
} catch (error) {
console.error('Error loading settings:', error);
// #879: any load failure → block saves so a blank/partial form can't be
// written over the real config. Cleared on the next successful load.
window._settingsLoadFailed = true;
showToast('Failed to load settings — reload the page before saving (your saved config is untouched)', 'error');
}
}
// Mirrors ConfigManager.REDACTED_SENTINEL — secrets are never sent to the
// browser; configured ones come back as this placeholder (rendered as dots in
// the password inputs).
const REDACTED_SECRET_SENTINEL = '__redacted_unchanged__';
function _wireRedactedSecrets() {
document.querySelectorAll('input[type="password"]').forEach(el => {
if (el.dataset.redactWired === '1') return;
el.dataset.redactWired = '1';
// Clear the mask on focus so the user types a fresh value, not on top
// of the sentinel.
el.addEventListener('focus', () => {
if (el.value === REDACTED_SECRET_SENTINEL) {
el.value = '';
el.dataset.wasRedacted = '1';
}
});
// Untouched (focused but not edited, left empty) → restore the mask so
// save round-trips the sentinel and the real secret is kept.
el.addEventListener('blur', () => {
if (el.dataset.wasRedacted === '1' && el.value === '') {
el.value = REDACTED_SECRET_SENTINEL;
el.dataset.wasRedacted = '';
}
});
// Real typing means a genuine change/clear — drop the redacted mark so
// blur won't re-mask (an emptied field then saves as a real clear).
el.addEventListener('input', () => { el.dataset.wasRedacted = ''; });
});
}
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);
}
// In single-source mode the one config block is shown directly. In hybrid
// mode there can be many active sources, so we only reveal the one the user
// clicked open in the priority list (accordion-style) — no endless stack.
const isHybrid = mode === 'hybrid';
const showCfg = (src) => activeSources.has(src) && (!isHybrid || _expandedHybridSource === src);
soulseekContainer.style.display = showCfg('soulseek') ? 'block' : 'none';
tidalContainer.style.display = showCfg('tidal') ? 'block' : 'none';
qobuzContainer.style.display = showCfg('qobuz') ? 'block' : 'none';
youtubeContainer.style.display = showCfg('youtube') ? 'block' : 'none';
hifiContainer.style.display = showCfg('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = showCfg('deezer_dl') ? 'block' : 'none';
if (amazonContainer) amazonContainer.style.display = showCfg('amazon') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = showCfg('lidarr') ? 'block' : 'none';
if (soundcloudContainer) soundcloudContainer.style.display = showCfg('soundcloud') ? 'block' : 'none';
const prowlarrRedirect = document.getElementById('prowlarr-source-redirect');
if (prowlarrRedirect) {
const showProwlarr = showCfg('torrent') || showCfg('usenet');
prowlarrRedirect.style.display = showProwlarr ? 'block' : 'none';
}
// Indexers & Downloaders section: only relevant when a torrent or usenet
// source is actually selected. Hide the whole intro + Prowlarr/Torrent/Usenet
// tiles otherwise (Soulseek/HiFi-only users never see them). The two client
// tiles are gated individually on their own source. Selection-based (not the
// hybrid expand state) — the tiles are full config sections, not accordion
// panels. Tab-gated so it never leaks onto another tab.
const onDownloadsTab = document.querySelector('.stg-tab.active')?.dataset.tab === 'downloads';
const torrentActive = activeSources.has('torrent');
const usenetActive = activeSources.has('usenet');
const indSection = document.getElementById('indexers-downloaders-section');
if (indSection) indSection.style.display = (onDownloadsTab && (torrentActive || usenetActive)) ? '' : 'none';
const torrentTile = document.getElementById('torrent-tile');
if (torrentTile) torrentTile.style.display = torrentActive ? '' : 'none';
const usenetTile = document.getElementById('usenet-tile');
if (usenetTile) usenetTile.style.display = usenetActive ? '' : 'none';
// Quality profile is now a GLOBAL system — the same ranked-target list
// drives every source (Soulseek, Tidal, Qobuz, HiFi, Deezer, …), so it is
// no longer Soulseek-gated. Show the whole collapsible tile whenever the
// downloads tab is active (gated as a unit so there's never an empty
// expandable shell).
const qualityProfileTile = document.getElementById('quality-profile-tile');
if (qualityProfileTile) {
const activeTab = document.querySelector('.stg-tab.active');
const onQualityTab = activeTab && activeTab.dataset.tab === 'quality';
qualityProfileTile.style.display = onQualityTab ? '' : 'none';
}
// Only auto-probe a source's live status when its config panel is visible
// (always in single-source mode; only the opened one in hybrid mode).
if (showCfg('tidal')) {
checkTidalDownloadAuthStatus();
}
if (showCfg('qobuz')) {
checkQobuzAuthStatus();
}
if (showCfg('hifi')) {
testHiFiConnection();
}
if (showCfg('amazon')) {
testAmazonConnection();
}
if (showCfg('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;
let qualityProfileAutoSaveTimer = null;
// Save just the quality profile (not the whole settings page). Used for quality
// target edits so reordering a target doesn't re-init every backend client.
function debouncedSaveQualityProfile() {
if (window._suppressSettingsAutoSave) return;
if (window._settingsLoadFailed) return;
if (qualityProfileAutoSaveTimer) clearTimeout(qualityProfileAutoSaveTimer);
qualityProfileAutoSaveTimer = setTimeout(() => saveQualityProfile(), 800);
}
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);
}
}
// v3: the working copy of the ordered target list. Mirrors the DOM rows
// and is the single source of truth that collectQualityProfileFromUI reads.
let currentRankedTargets = [];
function rtLabel(t) {
const fmt = (t.format || 'any').toUpperCase();
if (RT_LOSSLESS_FORMATS.includes(t.format)) {
const bd = t.bit_depth ? `${t.bit_depth}-bit` : '';
const sr = t.min_sample_rate ? `≥${t.min_sample_rate / 1000}kHz` : '';
const detail = [bd, sr].filter(Boolean).join('/');
return detail ? `${fmt} ${detail}` : fmt;
}
return t.min_bitrate ? `${fmt} ≥${t.min_bitrate}kbps` : fmt;
}
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');
// The API migrates v2 → v3, so ranked_targets is always present.
currentRankedTargets = Array.isArray(profile.ranked_targets)
? profile.ranked_targets.map(t => ({ ...t }))
: [];
renderRankedTargets();
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
if (fallbackCheckbox) fallbackCheckbox.checked = profile.fallback_enabled !== false;
const searchModeSelect = document.getElementById('quality-search-mode');
if (searchModeSelect) searchModeSelect.value = profile.search_mode === 'best_quality' ? 'best_quality' : 'priority';
const rankCandidatesCheckbox = document.getElementById('quality-rank-candidates');
if (rankCandidatesCheckbox) rankCandidatesCheckbox.checked = profile.rank_candidates_by_quality === true;
onSearchModeChange();
}
// Hide the "rank-based download order" toggle when Best quality is active —
// that mode always ranks by quality, so the toggle would be meaningless there.
function onSearchModeChange() {
const mode = document.getElementById('quality-search-mode')?.value;
const group = document.getElementById('quality-rank-candidates-group');
if (group) group.style.display = mode === 'best_quality' ? 'none' : '';
}
// Toggle the collapsible help text below a setting's ⓘ icon. Walks forward from
// the icon's row to the next .setting-help-body sibling, so it works whether the
// body is the immediate next element or sits after a control (e.g. a