soulsync/webui/static/settings.js
Broque Thomas f28f9808db Tidal: surface Favorite Tracks as virtual playlist (issue #502)
Adds the user's Tidal favorited tracks ("My Collection" in the Tidal
app) as a virtual playlist alongside their real playlists, mirroring
how Spotify's "Liked Songs" is treated.

Reporter (yug1900) located the working endpoint after the prior
`/v2/favorites?filter[type]=TRACKS` attempt returned empty data —
that endpoint is scoped to collections the third-party app created
itself, not personal favorites. Real endpoint:

    GET /v2/userCollectionTracks/me/relationships/items
        ?countryCode=US&locale=en-US&include=items

Cursor-paginated (20 per page, follow `links.next` with
`page[cursor]=...` until exhausted). Response only carries
track-level attributes — artist + album NAMES come back as
relationship-link stubs, not embedded data.

Implementation:

* Two-phase fetch — `_iter_collection_track_ids` walks the cursor
  chain to enumerate every track id (cheap, IDs only), then
  `get_collection_tracks` batch-hydrates 20 IDs at a time through
  the existing `_get_tracks_batch` helper which already knows how
  to `include=artists,albums`. No duplication of the JSON:API
  artist/album parse, no new dataclass shape.
* Virtual playlist `tidal-favorites` appended to the end of
  `/api/tidal/playlists`. ID intentionally has no colon —
  sync-services.js renderer interpolates IDs into CSS selectors
  via template literals (`#tidal-card-${p.id} .foo`) and a `:`
  would parse as a CSS pseudo-class operator.
* `tidal_client.get_playlist("tidal-favorites")` recognizes the
  virtual id and dispatches to the collection path internally, so
  every per-id consumer gets it for free: detail endpoint, mirror
  auto-refresh automation, "build Spotify discovery from Tidal
  playlist" flow.

OAuth scope expansion:

* Added `collection.read` to both OAuth flows (the
  `core/tidal_client.py::authenticate` standalone path AND the
  `web_server.py::auth_tidal` web flow — they were independent
  scope strings that both needed updating).
* Added `prompt=consent` to both flows — without it Tidal silently
  returns a token carrying only the ORIGINAL scope set even after
  re-authentication, because Tidal treats the existing
  authorization as still valid.
* New `disconnect()` method + `POST /api/tidal/disconnect`
  endpoint + Disconnect button next to Authenticate in Settings →
  Connections → Tidal — required for users whose existing token
  predates the scope expansion (forces a clean grant).

Reconnect-needed UI hint:

* `_collection_needs_reconnect` flag set on 401/403 from the
  collection endpoint, cleared on next successful walk, NOT set
  on 5xx (transient server errors must not falsely tell the user
  to reconnect).
* Listing endpoint reads the flag and surfaces a placeholder card
  titled "Favorite Tracks (reconnect Tidal to enable)" with a
  description pointing at Settings, so the user has something
  visible to act on instead of a silently missing row.

Diagnostic logging — collection request URL + response status +
first 300 bytes of body now logged at info level so future "why
is my collection empty" reports can be diagnosed from app.log
without needing live reproduction.

22 new tests pin: cursor walk (full chain, max-ids cap mid-page +
at page boundary), auth gates (no token / 401 / 403 all bail
clean), reconnect-flag lifecycle (set on 401/403, cleared on next
successful walk, NOT set on 5xx), forward-compat type filter
(non-track entries skipped), count helper, batch hydration
delegation + chunking at the 20-per-batch cap, partial-batch
failure containment, virtual-id dispatch (real playlist ids still
flow through the normal path).

Closes #502.
2026-05-10 21:36:22 -07:00

4091 lines
173 KiB
JavaScript

// SUPPORT MODAL
// ===============================
function showSupportModal() {
const overlay = document.getElementById('support-modal-overlay');
if (overlay) overlay.classList.remove('hidden');
}
function closeSupportModal() {
const overlay = document.getElementById('support-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function copyAddress(address, cryptoName) {
try {
// navigator.clipboard requires HTTPS — use fallback for HTTP (Docker)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(address);
} else {
const textarea = document.createElement('textarea');
textarea.value = address;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
showToast(`${cryptoName} address copied to clipboard`, 'success');
} catch (error) {
console.error('Failed to copy address:', error);
// Show the address so user can copy manually
showToast(`${cryptoName}: ${address}`, 'info');
}
}
// ===============================
// SETTINGS FUNCTIONALITY
// ===============================
let settingsAutoSaveTimer = null;
function debouncedAutoSaveSettings() {
if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer);
settingsAutoSaveTimer = setTimeout(() => saveSettings(true), 2000);
}
function handleManualSaveClick() {
if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer);
saveSettings(false);
}
function syncMetadataSourceSelection(source) {
const select = document.getElementById('metadata-fallback-source');
if (!select || !source) return;
const option = select.querySelector(`option[value="${source}"]`);
if (option) select.value = source;
select.dataset.lastValidSource = source;
}
function _isMetadataSourceSelectable(source) {
if (source === 'spotify') {
return _lastStatusPayload?.spotify?.authenticated === true;
}
if (source === 'discogs') {
const token = document.getElementById('discogs-token');
return !!token?.value?.trim();
}
return true;
}
function _metadataSourceFallback(source) {
if (source === 'spotify') return 'deezer';
if (source === 'discogs') return 'itunes';
return 'itunes';
}
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 || 'itunes';
if (_isMetadataSourceSelectable(selectedSource)) {
select.dataset.lastValidSource = selectedSource;
return false;
}
const lastValid = select.dataset.lastValidSource;
const fallbackSource = lastValid && lastValid !== selectedSource && _isMetadataSourceSelectable(lastValid)
? lastValid
: _metadataSourceFallback(selectedSource);
if (fallbackSource && fallbackSource !== selectedSource) {
select.value = fallbackSource;
}
select.dataset.lastValidSource = fallbackSource;
if (!quiet) {
const message = selectedSource === 'discogs'
? 'Discogs requires a personal access token before it can be selected as the primary metadata source.'
: 'Spotify must be authenticated before it can be selected as the primary metadata source.';
focusServiceSettingsSection(selectedSource, message);
}
return true;
}
function handleMetadataSourceChange(event) {
const select = event.target;
if (!select || select.id !== 'metadata-fallback-source') return;
const selectedSource = select.value;
if (_isMetadataSourceSelectable(selectedSource)) {
select.dataset.lastValidSource = selectedSource;
return;
}
sanitizeMetadataSourceSelection({ quiet: false });
}
function initializeSettings() {
// This function is called when the settings page is loaded.
// It attaches event listeners to all interactive elements on the page.
// Accent color listeners (live preview + custom picker toggle)
initAccentColorListeners();
// Main save button (manual save, non-quiet)
// Uses named function reference so addEventListener deduplicates across repeated calls
const saveButton = document.getElementById('save-settings');
if (saveButton) {
saveButton.addEventListener('click', handleManualSaveClick);
}
// Debounced auto-save on all settings inputs
// Uses named function reference (debouncedAutoSaveSettings) so addEventListener deduplicates
const settingsPage = document.getElementById('settings-page');
if (settingsPage) {
settingsPage.querySelectorAll('input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="range"]').forEach(input => {
input.addEventListener('input', debouncedAutoSaveSettings);
});
settingsPage.querySelectorAll('input[type="checkbox"], select').forEach(input => {
input.addEventListener('change', debouncedAutoSaveSettings);
});
}
const metadataSourceSelect = document.getElementById('metadata-fallback-source');
if (metadataSourceSelect) {
metadataSourceSelect.addEventListener('change', handleMetadataSourceChange);
}
const discogsTokenInput = document.getElementById('discogs-token');
if (discogsTokenInput) {
discogsTokenInput.addEventListener('input', () => {
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.spotify || null);
}
sanitizeMetadataSourceSelection({ quiet: true });
});
}
// Server toggle buttons
const plexToggle = document.getElementById('plex-toggle');
if (plexToggle) {
plexToggle.addEventListener('click', () => toggleServer('plex'));
}
const jellyfinToggle = document.getElementById('jellyfin-toggle');
if (jellyfinToggle) {
jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin'));
}
// Auto-detect buttons
const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button');
if (detectSlskdBtn) {
detectSlskdBtn.addEventListener('click', autoDetectSlskd);
}
const detectPlexBtn = document.querySelector('#plex-container .detect-button');
if (detectPlexBtn) {
detectPlexBtn.addEventListener('click', autoDetectPlex);
}
const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button');
if (detectJellyfinBtn) {
detectJellyfinBtn.addEventListener('click', autoDetectJellyfin);
}
// Test connection buttons
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.spotify || null);
}
syncSpotifySettingsAuthState(_lastStatusPayload?.spotify || null);
syncMetadataSourceSelection(_lastStatusPayload?.metadata_source?.source);
sanitizeMetadataSourceSelection({ quiet: true });
if (metadataSourceSelect) {
metadataSourceSelect.dataset.lastValidSource = metadataSourceSelect.value;
}
}
function resetFileOrganizationTemplates() {
// Reset templates to defaults
const defaults = {
album: '$albumartist/$albumartist - $album/$track - $title',
single: '$artist/$artist - $title/$title',
playlist: '$playlist/$artist - $title',
video: '$artist/$title-video'
};
document.getElementById('template-album-path').value = defaults.album;
document.getElementById('template-single-path').value = defaults.single;
document.getElementById('template-playlist-path').value = defaults.playlist;
document.getElementById('template-video-path').value = defaults.video;
debouncedAutoSaveSettings();
}
function validateFileOrganizationTemplates() {
const errors = [];
// Valid variables for each template type
const validVars = {
album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$discnum', '$cdnum', '$year', '$quality'],
single: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$year', '$quality'],
playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality'],
video: ['$artist', '$artistletter', '$title', '$year']
};
// Get template values
const albumPath = document.getElementById('template-album-path').value.trim();
const singlePath = document.getElementById('template-single-path').value.trim();
const playlistPath = document.getElementById('template-playlist-path').value.trim();
// Validate album template
if (albumPath) {
if (albumPath.endsWith('/')) {
errors.push('Album template cannot end with /');
}
if (albumPath.startsWith('/')) {
errors.push('Album template cannot start with /');
}
if (!albumPath.includes('/')) {
errors.push('Album template must include at least one folder (use / separator)');
}
if (albumPath.includes('//')) {
errors.push('Album template cannot have consecutive slashes //');
}
// Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.)
const albumVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = albumPath.match(albumVarPattern) || [];
foundVars.forEach(v => {
// Normalize ${var} to $var for validation
const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
const lowerVar = normalized.toLowerCase();
// Check if lowercase version exists in valid vars
const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`);
} else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) {
// Variable is valid but has wrong case
errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}
// Validate single template
if (singlePath) {
if (singlePath.endsWith('/')) {
errors.push('Single template cannot end with /');
}
if (singlePath.startsWith('/')) {
errors.push('Single template cannot start with /');
}
// Note: single template is allowed to have no slash (flat file: "$artist - $title")
if (singlePath.includes('//')) {
errors.push('Single template cannot have consecutive slashes //');
}
const singleVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = singlePath.match(singleVarPattern) || [];
foundVars.forEach(v => {
const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
const lowerVar = normalized.toLowerCase();
const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`);
} else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) {
errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}
// Validate playlist template
if (playlistPath) {
if (playlistPath.endsWith('/')) {
errors.push('Playlist template cannot end with /');
}
if (playlistPath.startsWith('/')) {
errors.push('Playlist template cannot start with /');
}
if (!playlistPath.includes('/')) {
errors.push('Playlist template must include at least one folder (use / separator)');
}
if (playlistPath.includes('//')) {
errors.push('Playlist template cannot have consecutive slashes //');
}
const playlistVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = playlistPath.match(playlistVarPattern) || [];
foundVars.forEach(v => {
const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
const lowerVar = normalized.toLowerCase();
const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
} else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) {
errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}
return errors;
}
// Settings redesign — tab switching + service accordions
function switchSettingsTab(tab) {
// Update tab bar
document.querySelectorAll('.stg-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
// Show/hide settings groups and section headers by data-stg attribute
document.querySelectorAll('#settings-page [data-stg]').forEach(g => {
g.style.display = g.dataset.stg === tab ? '' : 'none';
});
// Re-apply collapsed state on section bodies (tab switch resets inline display)
document.querySelectorAll('#settings-page .settings-section-body.collapsed').forEach(b => {
b.style.display = 'none';
});
// Also hide/show the column wrappers if they're empty in this tab
document.querySelectorAll('#settings-page .settings-left-column, #settings-page .settings-right-column, #settings-page .settings-third-column').forEach(col => {
const hasVisible = Array.from(col.querySelectorAll('.settings-group[data-stg]')).some(g => g.style.display !== 'none');
col.style.display = hasVisible ? '' : 'none';
});
// Re-apply conditional visibility (quality profile, source containers, etc.)
if (typeof updateDownloadSourceUI === 'function') {
try { updateDownloadSourceUI(); } catch (e) { }
}
// Load DB maintenance info when switching to Advanced tab
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
try { loadDbMaintenanceInfo(); } catch (e) { }
}
// Initialize live log viewer when switching to Logs tab
if (tab === 'logs') {
_logViewerInit();
} else {
_logViewerStop();
}
// Refresh the green/yellow header gradient when arriving on Connections
if (tab === 'connections') {
try { applyServiceStatusGradients(); } catch (e) { }
}
}
// ── Settings → Connections: per-service status gradient + verify wiring ──
// Gradient shows green when the user has filled in credentials, yellow when empty.
// It's based purely on config presence (cheap, no API calls). The verify layer —
// which runs on expand / Expand All — surfaces whether those credentials actually
// work, via an inline warning bar inside the expanded panel.
let _stgServiceStatusState = {}; // service -> {configured: bool}
let _stgServiceVerifyInFlight = {}; // service -> true while a verify call is running
async function applyServiceStatusGradients() {
try {
const resp = await fetch('/api/settings/config-status');
if (!resp.ok) return;
const data = await resp.json();
_stgServiceStatusState = data || {};
document.querySelectorAll('#settings-page .stg-service[data-service]').forEach(card => {
const service = card.getAttribute('data-service');
const header = card.querySelector('.stg-service-header');
if (!service || !header) return;
const configured = !!(data[service] && data[service].configured);
header.classList.toggle('status-configured', configured);
header.classList.toggle('status-missing', !configured);
// Ensure the header has a spinner placeholder for the verify-checking state
if (!header.querySelector('.stg-service-verify-spinner')) {
const spinner = document.createElement('span');
spinner.className = 'stg-service-verify-spinner';
// Insert before the chevron on the right
const chevron = header.querySelector('.stg-service-chevron');
if (chevron) header.insertBefore(spinner, chevron);
else header.appendChild(spinner);
}
});
syncSpotifySettingsAuthState(_lastStatusPayload?.spotify || null);
} catch (e) {
console.warn('[Settings Status] Failed to apply gradients:', e);
}
}
function syncSpotifySettingsAuthState(statusData) {
if (!statusData) return;
const card = document.querySelector('#settings-page .stg-service[data-service="spotify"]');
if (!card) return;
const header = card.querySelector('.stg-service-header');
const dot = card.querySelector('.stg-service-dot');
if (!header && !dot) return;
const authenticated = statusData?.authenticated === true;
const rateLimited = !!(statusData?.rate_limited && statusData?.rate_limit);
const cooldown = !!(statusData?.post_ban_cooldown > 0);
const needsAttention = !authenticated || rateLimited || cooldown;
if (header) {
header.classList.toggle('status-configured', !needsAttention);
header.classList.toggle('status-missing', needsAttention);
}
if (dot) {
dot.style.color = needsAttention ? '#f1c40f' : '#1DB954';
}
}
function _stgSetCheckingState(service, isChecking) {
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
if (!card) return;
const header = card.querySelector('.stg-service-header');
const body = card.querySelector('.stg-service-body');
if (header) {
header.classList.toggle('status-checking', !!isChecking);
// Lazy-create the spinner element so it's there even if
// applyServiceStatusGradients() hasn't run yet.
if (!header.querySelector('.stg-service-verify-spinner')) {
const spinner = document.createElement('span');
spinner.className = 'stg-service-verify-spinner';
const chevron = header.querySelector('.stg-service-chevron');
if (chevron) header.insertBefore(spinner, chevron);
else header.appendChild(spinner);
}
}
if (!body) return;
const existing = body.querySelector('.stg-service-verify-status');
if (isChecking) {
if (!existing) {
const status = document.createElement('div');
status.className = 'stg-service-verify-status';
status.textContent = 'Testing connection…';
body.insertBefore(status, body.firstChild);
}
} else if (existing) {
existing.remove();
}
}
function _stgShowVerifyWarning(service, message) {
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
if (!card) return;
const body = card.querySelector('.stg-service-body');
if (!body) return;
const existing = body.querySelector('.stg-service-warning');
if (existing) existing.remove();
const warning = document.createElement('div');
warning.className = 'stg-service-warning';
warning.innerHTML = `
<span class="stg-service-warning-icon">&#9888;</span>
<span class="stg-service-warning-text"></span>
`;
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: 'lidarr', name: 'Lidarr', icon: null, emoji: '📦' },
{ id: 'soundcloud', name: 'SoundCloud', icon: 'https://www.svgrepo.com/show/452219/soundcloud.svg', emoji: '☁️' },
];
let _hybridSourceOrder = ['soulseek', 'youtube'];
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, lidarr: false, soundcloud: false };
let _hybridVisualOrder = null; // Full visual order including disabled sources
function buildHybridSourceList() {
const container = document.getElementById('hybrid-source-list');
if (!container) return;
container.innerHTML = '';
// Build visual order: use persisted visual order, or enabled first + disabled at bottom
if (!_hybridVisualOrder) {
_hybridVisualOrder = [..._hybridSourceOrder];
for (const src of HYBRID_SOURCES) {
if (!_hybridVisualOrder.includes(src.id)) _hybridVisualOrder.push(src.id);
}
}
const allIds = _hybridVisualOrder;
allIds.forEach((srcId, idx) => {
const src = HYBRID_SOURCES.find(s => s.id === srcId);
if (!src) return;
const enabled = _hybridSourceEnabled[srcId] !== false;
const isInOrder = _hybridSourceOrder.includes(srcId);
const priorityNum = isInOrder && enabled ? _hybridSourceOrder.indexOf(srcId) + 1 : '';
const item = document.createElement('div');
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
item.draggable = true;
item.dataset.sourceId = srcId;
item.innerHTML = `
<span class="hybrid-source-arrows">
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', -1)" title="Move up">▲</button>
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', 1)" title="Move down">▼</button>
</span>
${src.icon
? `<img class="hybrid-source-icon" src="${src.icon}" alt="${src.name}" onerror="this.outerHTML='<span class=\\'hybrid-source-icon emoji-icon\\'>${src.emoji}</span>'">`
: `<span class="hybrid-source-icon emoji-icon">${src.emoji}</span>`
}
<span class="hybrid-source-name">${src.name}</span>
<span class="hybrid-source-priority">${priorityNum}</span>
<label class="hybrid-source-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleHybridSource('${srcId}', this.checked)">
<span class="toggle-track"></span>
</label>
`;
container.appendChild(item);
});
// Sync hidden selects for backward compat
_syncHybridHiddenSelects();
}
function moveHybridSource(srcId, direction) {
if (!_hybridVisualOrder) return;
const idx = _hybridVisualOrder.indexOf(srcId);
if (idx < 0) return;
const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= _hybridVisualOrder.length) return;
// Swap in visual order
[_hybridVisualOrder[idx], _hybridVisualOrder[newIdx]] = [_hybridVisualOrder[newIdx], _hybridVisualOrder[idx]];
// Rebuild enabled order from visual order
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
buildHybridSourceList();
updateDownloadSourceUI();
debouncedAutoSaveSettings();
}
function toggleHybridSource(srcId, enabled) {
_hybridSourceEnabled[srcId] = enabled;
// Rebuild enabled order from visual order so priority matches position
if (_hybridVisualOrder) {
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
}
buildHybridSourceList();
updateDownloadSourceUI();
debouncedAutoSaveSettings();
}
function _syncHybridOrderFromDOM() {
const container = document.getElementById('hybrid-source-list');
if (!container) return;
const items = container.querySelectorAll('.hybrid-source-item');
const newOrder = [];
items.forEach(item => {
const id = item.dataset.sourceId;
if (_hybridSourceEnabled[id] !== false) {
newOrder.push(id);
}
});
_hybridSourceOrder = newOrder;
}
function _syncHybridHiddenSelects() {
// Keep hidden selects in sync for backward compat with saveSettings
const primary = document.getElementById('hybrid-primary-source');
const secondary = document.getElementById('hybrid-secondary-source');
if (primary && _hybridSourceOrder.length > 0) primary.value = _hybridSourceOrder[0];
if (secondary && _hybridSourceOrder.length > 1) secondary.value = _hybridSourceOrder[1];
}
function getHybridOrder() {
return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false);
}
function loadHybridSourceOrder(settings) {
const order = settings.download_source?.hybrid_order;
const sourceStatus = settings._source_status || {};
if (order && Array.isArray(order) && order.length > 0) {
_hybridSourceOrder = order;
_hybridSourceEnabled = {};
for (const src of HYBRID_SOURCES) {
_hybridSourceEnabled[src.id] = order.includes(src.id);
}
} else {
// Legacy: fall back to primary/secondary
const primary = settings.download_source?.hybrid_primary || 'soulseek';
const secondary = settings.download_source?.hybrid_secondary || 'youtube';
_hybridSourceOrder = [primary, secondary];
_hybridSourceEnabled = {};
for (const src of HYBRID_SOURCES) {
_hybridSourceEnabled[src.id] = src.id === primary || src.id === secondary;
}
}
// Auto-disable sources that aren't configured on the server
let changed = false;
for (const src of HYBRID_SOURCES) {
if (_hybridSourceEnabled[src.id] && sourceStatus[src.id] === false) {
_hybridSourceEnabled[src.id] = false;
changed = true;
}
}
if (changed) {
_hybridSourceOrder = _hybridSourceOrder.filter(id => _hybridSourceEnabled[id] !== false);
}
_hybridVisualOrder = null; // Reset so buildHybridSourceList rebuilds it
buildHybridSourceList();
}
function updateLossyBitrateOptions() {
const codec = document.getElementById('lossy-copy-codec')?.value || 'mp3';
const bitrateSelect = document.getElementById('lossy-copy-bitrate');
if (!bitrateSelect) return;
const opt320 = bitrateSelect.querySelector('option[value="320"]');
if (codec === 'opus') {
// Opus max is 256kbps per channel — hide 320 option
if (opt320) opt320.disabled = true;
if (bitrateSelect.value === '320') bitrateSelect.value = '256';
} else {
if (opt320) opt320.disabled = false;
}
}
function updatePlexConfigurationButtons() {
const plexUrl = document.getElementById('plex-url');
const plexToken = document.getElementById('plex-token');
const hasPlexConfig = Boolean((plexUrl?.value || '').trim() || (plexToken?.value || '').trim());
const plexViewConfigButton = document.getElementById('plex-view-config-button');
const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button');
const plexManualConfigButton = document.getElementById('plex-manual-config-button');
const plexUrlActions = document.getElementById('plex-url-actions');
const plexTokenActions = document.getElementById('plex-token-actions');
const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow');
if (plexViewConfigButton) plexViewConfigButton.style.display = hasPlexConfig ? '' : 'none';
if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = hasPlexConfig ? 'none' : '';
if (plexManualConfigButton) plexManualConfigButton.style.display = hasPlexConfig ? 'none' : '';
if (plexUrlActions) plexUrlActions.style.display = hasPlexConfig ? 'none' : 'flex';
if (plexTokenActions) plexTokenActions.style.display = hasPlexConfig ? 'none' : 'flex';
if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none';
}
async function loadSettingsData() {
try {
const response = await fetch(API.settings);
const settings = await response.json();
// Populate Spotify settings
document.getElementById('spotify-client-id').value = settings.spotify?.client_id || '';
document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || '';
document.getElementById('spotify-redirect-uri').value = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback';
document.getElementById('spotify-callback-display').textContent = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback';
// Populate Tidal settings
document.getElementById('tidal-client-id').value = settings.tidal?.client_id || '';
document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || '';
document.getElementById('tidal-redirect-uri').value = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback';
document.getElementById('tidal-callback-display').textContent = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback';
// Populate Deezer OAuth settings
document.getElementById('deezer-app-id').value = settings.deezer?.app_id || '';
document.getElementById('deezer-app-secret').value = settings.deezer?.app_secret || '';
document.getElementById('deezer-redirect-uri').value = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback';
document.getElementById('deezer-callback-display').textContent = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback';
// Add event listeners to update display URLs when input changes
document.getElementById('spotify-redirect-uri').addEventListener('input', function () {
document.getElementById('spotify-callback-display').textContent = this.value || 'http://127.0.0.1:8888/callback';
});
document.getElementById('tidal-redirect-uri').addEventListener('input', function () {
document.getElementById('tidal-callback-display').textContent = this.value || 'http://127.0.0.1:8889/tidal/callback';
});
document.getElementById('deezer-redirect-uri').addEventListener('input', function () {
document.getElementById('deezer-callback-display').textContent = this.value || 'http://127.0.0.1:8008/deezer/callback';
});
// Populate Plex settings
const plexUrlInput = document.getElementById('plex-url');
const plexTokenInput = document.getElementById('plex-token');
if (plexUrlInput) plexUrlInput.value = settings.plex?.base_url || '';
if (plexTokenInput) plexTokenInput.value = settings.plex?.token || '';
if (plexUrlInput) plexUrlInput.addEventListener('input', updatePlexConfigurationButtons);
if (plexTokenInput) plexTokenInput.addEventListener('input', updatePlexConfigurationButtons);
updatePlexConfigurationButtons();
// Populate Jellyfin settings
document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || '';
document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || '';
document.getElementById('jellyfin-timeout').value = settings.jellyfin?.api_timeout || 120;
// Populate Navidrome settings
document.getElementById('navidrome-url').value = settings.navidrome?.base_url || '';
document.getElementById('navidrome-username').value = settings.navidrome?.username || '';
document.getElementById('navidrome-password').value = settings.navidrome?.password || '';
// Set active server and toggle visibility
const activeServer = settings.active_media_server || 'plex';
toggleServer(activeServer);
// Load Plex music libraries if Plex is the active server
if (activeServer === 'plex') {
loadPlexMusicLibraries();
}
// Load Jellyfin users and music libraries if Jellyfin is the active server
if (activeServer === 'jellyfin') {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
}
// Load Navidrome music folders if Navidrome is the active server
if (activeServer === 'navidrome') {
loadNavidromeMusicFolders();
}
// Populate Soulseek settings
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
document.getElementById('soulseek-search-timeout').value = settings.soulseek?.search_timeout || 60;
document.getElementById('soulseek-search-timeout-buffer').value = settings.soulseek?.search_timeout_buffer || 15;
document.getElementById('soulseek-min-peer-speed').value = settings.soulseek?.min_peer_upload_speed || 0;
document.getElementById('soulseek-max-peer-queue').value = settings.soulseek?.max_peer_queue || 0;
document.getElementById('soulseek-download-timeout').value = Math.round((settings.soulseek?.download_timeout || 600) / 60);
document.getElementById('soulseek-auto-clear-searches').checked = settings.soulseek?.auto_clear_searches !== false;
// Populate ListenBrainz settings
document.getElementById('listenbrainz-base-url').value = settings.listenbrainz?.base_url || '';
document.getElementById('listenbrainz-token').value = settings.listenbrainz?.token || '';
// Populate AcoustID settings
document.getElementById('acoustid-api-key').value = settings.acoustid?.api_key || '';
document.getElementById('acoustid-enabled').checked = settings.acoustid?.enabled || false;
// Populate Last.fm settings
document.getElementById('lastfm-api-key').value = settings.lastfm?.api_key || '';
document.getElementById('lastfm-api-secret').value = settings.lastfm?.api_secret || '';
document.getElementById('lastfm-scrobble-enabled').checked = settings.lastfm?.scrobble_enabled === true;
const lfmStatus = document.getElementById('lastfm-scrobble-status');
if (lfmStatus) {
lfmStatus.textContent = settings.lastfm?.session_key ? 'Authorized' : 'Not authorized';
}
// Populate ListenBrainz scrobble toggle
document.getElementById('listenbrainz-scrobble-enabled').checked = settings.listenbrainz?.scrobble_enabled === true;
// Populate Genius settings
document.getElementById('genius-access-token').value = settings.genius?.access_token || '';
// Populate iTunes settings
document.getElementById('itunes-country').value = settings.itunes?.country || 'US';
// Populate Discogs settings
document.getElementById('discogs-token').value = settings.discogs?.token || '';
// Populate Metadata source setting
document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'itunes';
// Populate Hydrabase settings
const hbConfig = settings.hydrabase || {};
document.getElementById('hydrabase-url').value = hbConfig.url || '';
document.getElementById('hydrabase-api-key').value = hbConfig.api_key || '';
document.getElementById('hydrabase-auto-connect').checked = hbConfig.auto_connect || false;
// Check live connection status + add Hydrabase to fallback dropdown if connected
fetch('/api/hydrabase/status').then(r => r.json()).then(s => {
const btn = document.getElementById('hydrabase-connect-btn');
const statusEl = document.getElementById('hydrabase-settings-status');
if (s.connected) {
if (btn) btn.textContent = 'Disconnect';
if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; }
// Add Hydrabase to fallback source dropdown
const fbSelect = document.getElementById('metadata-fallback-source');
if (fbSelect && !fbSelect.querySelector('option[value="hydrabase"]')) {
const opt = document.createElement('option');
opt.value = 'hydrabase';
opt.textContent = 'Hydrabase (P2P)';
fbSelect.appendChild(opt);
}
// Restore selection if it was hydrabase
if ((settings.metadata?.fallback_source) === 'hydrabase') {
fbSelect.value = 'hydrabase';
}
}
}).catch(() => { });
// Populate Download settings (right column)
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
document.getElementById('staging-path').value = settings.import?.staging_path || './Staging';
document.getElementById('music-videos-path').value = settings.library?.music_videos_path || './MusicVideos';
// Populate Download Source settings
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
document.getElementById('stream-source').value = settings.download_source?.stream_source || 'youtube';
document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3';
loadHybridSourceOrder(settings);
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
document.getElementById('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false;
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false;
document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless';
document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false;
loadHiFiInstances();
document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac';
document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false;
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
document.getElementById('lidarr-url').value = settings.lidarr_download?.url || '';
document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || '';
// Sync ARL to connections tab field + bidirectional listeners
const _connArl = document.getElementById('deezer-connection-arl');
const _dlArl = document.getElementById('deezer-download-arl');
if (_connArl) _connArl.value = settings.deezer_download?.arl || '';
if (_connArl && _dlArl) {
_connArl.addEventListener('input', () => { _dlArl.value = _connArl.value; });
_dlArl.addEventListener('input', () => { _connArl.value = _dlArl.value; });
}
// Populate YouTube settings
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3;
// Update UI based on download source mode
updateDownloadSourceUI();
// Populate Database settings
document.getElementById('max-workers').value = settings.database?.max_workers || '5';
// Populate Post-Processing settings
document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false;
document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false;
document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false;
document.getElementById('prefer-caa-art').checked = settings.metadata_enhancement?.prefer_caa_art === true;
document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false;
document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true;
// Load service master toggles
document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false;
document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false;
document.getElementById('embed-musicbrainz').checked = settings.musicbrainz?.embed_tags !== false;
document.getElementById('embed-deezer').checked = settings.deezer?.embed_tags !== false;
document.getElementById('embed-audiodb').checked = settings.audiodb?.embed_tags !== false;
document.getElementById('embed-tidal').checked = settings.tidal?.embed_tags !== false;
document.getElementById('embed-qobuz').checked = settings.qobuz?.embed_tags !== false;
document.getElementById('embed-lastfm').checked = settings.lastfm?.embed_tags !== false;
document.getElementById('embed-genius').checked = settings.genius?.embed_tags !== false;
document.getElementById('embed-hifi').checked = settings.hifi?.embed_tags !== false;
// Load per-tag toggles from data-config attributes
document.querySelectorAll('[data-config]').forEach(cb => {
const path = cb.dataset.config.split('.');
let val = settings;
for (const key of path) { val = val?.[key]; }
cb.checked = val !== false;
});
// Apply service disabled state to child tags
['spotify', 'itunes', 'musicbrainz', 'deezer', 'audiodb', 'tidal', 'qobuz', 'lastfm', 'genius', 'hifi'].forEach(svc => {
const master = document.getElementById('embed-' + svc);
if (master) toggleServiceTags(master, svc);
});
document.getElementById('post-processing-options').style.display = settings.metadata_enhancement?.enabled !== false ? 'block' : 'none';
// Populate File Organization settings
document.getElementById('file-organization-enabled').checked = settings.file_organization?.enabled !== false;
document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title';
document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title';
document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title';
document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video';
document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc';
document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first';
document.getElementById('artist-separator').value = settings.metadata_enhancement?.tags?.artist_separator || ', ';
document.getElementById('write-multi-artist').checked = settings.metadata_enhancement?.tags?.write_multi_artist || false;
document.getElementById('feat-in-title').checked = settings.metadata_enhancement?.tags?.feat_in_title || false;
document.getElementById('allow-duplicate-tracks').checked = settings.wishlist?.allow_duplicate_tracks !== false;
// Populate Playlist Sync settings
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
// Populate Post-Download Conversion settings
document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true;
document.getElementById('lossy-copy-enabled').checked = settings.lossy_copy?.enabled === true;
document.getElementById('lossy-copy-codec').value = settings.lossy_copy?.codec || 'mp3';
document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320';
updateLossyBitrateOptions();
document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true;
// Populate Listening Stats settings
document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true;
document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30;
document.getElementById('lossy-copy-options').style.display =
settings.lossy_copy?.enabled ? 'block' : 'none';
// Populate Music Library Paths
const _musicPaths = settings.library?.music_paths || [];
renderMusicPaths(_musicPaths);
// Populate Content Filter settings
document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false;
// Populate Genre Whitelist
const gwEnabled = settings.genre_whitelist?.enabled === true;
document.getElementById('genre-whitelist-enabled').checked = gwEnabled;
const gwContainer = document.getElementById('genre-whitelist-container');
if (gwContainer) gwContainer.style.display = gwEnabled ? '' : 'none';
if (gwEnabled) {
_genreWhitelistRender(settings.genre_whitelist?.genres || []);
}
// Populate Import settings
document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true;
// Populate M3U Export settings
document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true;
document.getElementById('m3u-entry-base-path').value = settings.m3u_export?.entry_base_path || '';
// Populate UI Appearance settings
const accentPreset = settings.ui_appearance?.accent_preset || '#1db954';
const accentCustom = settings.ui_appearance?.accent_color || '#1db954';
const presetSelect = document.getElementById('accent-preset');
const customPicker = document.getElementById('accent-custom-color');
const customGroup = document.getElementById('custom-color-group');
if (presetSelect) {
// Check if the saved preset matches a dropdown option
const presetOptions = Array.from(presetSelect.options).map(o => o.value);
if (presetOptions.includes(accentPreset)) {
presetSelect.value = accentPreset;
} else {
presetSelect.value = 'custom';
}
if (presetSelect.value === 'custom') {
if (customGroup) customGroup.style.display = '';
if (customPicker) customPicker.value = accentCustom;
applyAccentColor(accentCustom);
} else {
if (customGroup) customGroup.style.display = 'none';
applyAccentColor(accentPreset);
}
}
// Sidebar visualizer type
const vizType = settings.ui_appearance?.sidebar_visualizer || 'bars';
const vizSelect = document.getElementById('sidebar-visualizer-type');
if (vizSelect) vizSelect.value = vizType;
sidebarVisualizerType = vizType;
// Background particles toggle
const particlesEnabled = settings.ui_appearance?.particles_enabled !== false; // default true
const particlesCheckbox = document.getElementById('particles-enabled');
if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled;
applyParticlesSetting(particlesEnabled);
// Worker orbs toggle
const workerOrbsEnabled = settings.ui_appearance?.worker_orbs_enabled !== false; // default true
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled;
applyWorkerOrbsSetting(workerOrbsEnabled);
// Reduce effects toggle
const reduceEffects = settings.ui_appearance?.reduce_effects === true; // default false
const reduceCheckbox = document.getElementById('reduce-effects-enabled');
if (reduceCheckbox) reduceCheckbox.checked = reduceEffects;
applyReduceEffects(reduceEffects);
// Populate Logging information
const logLevelSelect = document.getElementById('log-level-select');
if (logLevelSelect) logLevelSelect.value = settings.logging?.level || 'INFO';
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
// Load Discovery Lookback Period setting
try {
const lookbackResponse = await fetch('/api/discovery/lookback-period');
const lookbackData = await lookbackResponse.json();
if (lookbackData.period) {
document.getElementById('discovery-lookback-period').value = lookbackData.period;
}
} catch (error) {
console.error('Error loading discovery lookback period:', error);
}
// Load Hemisphere setting
try {
const hemiResponse = await fetch('/api/discovery/hemisphere');
const hemiData = await hemiResponse.json();
if (hemiData.hemisphere) {
document.getElementById('discovery-hemisphere').value = hemiData.hemisphere;
}
} catch (error) {
console.error('Error loading hemisphere setting:', error);
}
// Load current log level
try {
const logLevelResponse = await fetch('/api/settings/log-level');
const logLevelData = await logLevelResponse.json();
if (logLevelData.success && logLevelData.level) {
document.getElementById('log-level-select').value = logLevelData.level;
}
} catch (error) {
console.error('Error loading log level:', error);
}
// Load security settings
try {
const requirePin = settings.security?.require_pin_on_launch || false;
document.getElementById('security-require-pin').checked = requirePin;
// CORS origins — stored verbatim as the user typed (string).
const corsOrigins = settings.security?.cors_origins || '';
const corsField = document.getElementById('security-cors-origins');
if (corsField) corsField.value = corsOrigins;
// Check if admin has a PIN set
const profilesRes = await fetch('/api/profiles');
const profilesData = await profilesRes.json();
const adminProfile = (profilesData.profiles || []).find(p => p.is_admin);
const adminHasPin = adminProfile?.has_pin || false;
// Show/hide PIN setup vs change sections
document.getElementById('security-pin-setup').style.display = adminHasPin ? 'none' : 'block';
document.getElementById('security-change-pin-section').style.display = adminHasPin ? 'block' : 'none';
// If no PIN, disable the toggle
if (!adminHasPin) {
document.getElementById('security-require-pin').checked = false;
document.getElementById('security-require-pin').disabled = true;
}
} catch (error) {
console.error('Error loading security settings:', error);
}
// Check dev mode status
try {
const devResponse = await fetch('/api/dev-mode');
const devData = await devResponse.json();
if (devData.enabled) {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))';
document.getElementById('hydrabase-nav').style.display = '';
document.getElementById('hydrabase-button-container').style.display = '';
}
} catch (error) {
console.error('Error checking dev mode:', error);
}
} catch (error) {
console.error('Error loading settings:', error);
showToast('Failed to load settings', 'error');
}
}
async function changeLogLevel() {
const selector = document.getElementById('log-level-select');
const level = selector.value;
try {
const response = await fetch('/api/settings/log-level', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level: level })
});
const data = await response.json();
if (data.success) {
showToast(`Log level changed to ${level}`, 'success');
console.log(`Log level changed to: ${level}`);
} else {
showToast(`Failed to change log level: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error changing log level:', error);
showToast('Failed to change log level', 'error');
}
}
function updateMediaServerFields() {
const serverType = document.getElementById('media-server-type').value;
const urlInput = document.getElementById('media-server-url');
const tokenInput = document.getElementById('media-server-token');
if (serverType === 'plex') {
urlInput.placeholder = 'http://localhost:32400';
tokenInput.placeholder = 'Plex Token';
} else {
urlInput.placeholder = 'http://localhost:8096';
tokenInput.placeholder = 'Jellyfin API Key';
}
}
let _plexPinAuthRequestId = null;
let _plexPinAuthPollInterval = null;
function showPlexConfiguration(disableFields = false, isManualConfig = false) {
stopPlexPinAuthPolling();
const plexConfig = document.getElementById('plex-configuration');
const plexSetup = document.getElementById('plex-setup');
const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow');
const plexUrl = document.getElementById('plex-url');
const plexToken = document.getElementById('plex-token');
const plexLibraryContainer = document.getElementById('plex-library-selector-container');
if (plexConfig) plexConfig.style.display = '';
if (plexSetup) plexSetup.style.display = 'none';
if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none';
if (plexUrl) plexUrl.disabled = disableFields;
if (plexToken) plexToken.disabled = disableFields;
if (plexLibraryContainer && isManualConfig) {
plexLibraryContainer.style.display = 'none';
}
setPlexConfigActionButton(isManualConfig);
updatePlexConfigurationButtons();
}
function showPlexSetup() {
const plexConfig = document.getElementById('plex-configuration');
const plexSetup = document.getElementById('plex-setup');
const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow');
const plexLibraryContainer = document.getElementById('plex-library-selector-container');
if (plexConfig) plexConfig.style.display = 'none';
if (plexSetup) plexSetup.style.display = '';
if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none';
if (plexLibraryContainer) plexLibraryContainer.style.display = 'none';
setPlexConfigActionButton(false);
}
function setPlexConfigActionButton(isManualConfig) {
const actionButton = document.getElementById('plex-config-action-button');
if (!actionButton) return;
if (isManualConfig) {
actionButton.textContent = 'Cancel';
actionButton.onclick = showPlexSetup;
actionButton.title = 'Cancel manual Plex configuration';
} else {
actionButton.textContent = 'Clear Configuration';
actionButton.onclick = clearPlexConfiguration;
actionButton.title = 'Clear saved Plex configuration';
}
}
async function startPlexPinAuth() {
const setupButtons = document.getElementById('plex-setup-buttons');
const authFlow = document.getElementById('plex-pin-auth-flow');
const statusEl = document.getElementById('plex-pin-status');
if (setupButtons) setupButtons.style.display = 'none';
if (authFlow) authFlow.style.display = '';
if (statusEl) statusEl.textContent = 'Starting Plex authorization...';
try {
showLoadingOverlay('Starting Plex authorization...');
const response = await fetch('/api/plex/pin/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to start Plex PIN flow');
}
_plexPinAuthRequestId = result.request_id;
const pinCodeEl = document.getElementById('plex-pin-code');
if (pinCodeEl) pinCodeEl.textContent = result.code || '';
if (statusEl) {
statusEl.textContent = result.expires_in
? `Enter this code at plex.tv/link. Code expires in ${result.expires_in} seconds.`
: 'Enter this code at plex.tv/link. Waiting for authorization...';
}
startPlexPinAuthPolling();
} catch (error) {
console.error('Plex PIN auth start failed:', error);
showToast(error.message || 'Failed to start Plex authorization', 'error');
cancelPlexPinAuth();
} finally {
hideLoadingOverlay();
}
}
function startPlexPinAuthPolling() {
stopPlexPinAuthPolling();
if (!_plexPinAuthRequestId) return;
_plexPinAuthPollInterval = setInterval(pollPlexPinAuthStatus, 5000);
pollPlexPinAuthStatus();
}
function stopPlexPinAuthPolling() {
if (_plexPinAuthPollInterval) {
clearInterval(_plexPinAuthPollInterval);
_plexPinAuthPollInterval = null;
}
}
async function pollPlexPinAuthStatus() {
if (!_plexPinAuthRequestId) return;
try {
const response = await fetch(`/api/plex/pin/status?request_id=${encodeURIComponent(_plexPinAuthRequestId)}`);
const result = await response.json();
const statusEl = document.getElementById('plex-pin-status');
if (!result.success && result.expired) {
if (statusEl) statusEl.textContent = 'PIN code expired. Generate a new code to continue.';
stopPlexPinAuthPolling();
return;
}
if (result.success) {
stopPlexPinAuthPolling();
if (statusEl) statusEl.textContent = 'Authorization complete! Saving Plex configuration...';
document.getElementById('plex-url').value = result.found_url || '';
document.getElementById('plex-token').value = result.token || '';
if (typeof saveSettings === 'function') {
await saveSettings(true);
}
showToast('Plex successfully linked', 'success');
showPlexConfiguration(true);
await testConnection('plex');
return;
}
if (result.status) {
if (statusEl) statusEl.textContent = result.status;
return;
}
if (result.error) {
if (statusEl) statusEl.textContent = result.error;
return;
}
} catch (error) {
console.error('Error polling Plex PIN status:', error);
const statusEl = document.getElementById('plex-pin-status');
if (statusEl) statusEl.textContent = 'Unable to contact Plex auth status. Retrying...';
}
}
function cancelPlexPinAuth() {
stopPlexPinAuthPolling();
_plexPinAuthRequestId = null;
const setupButtons = document.getElementById('plex-setup-buttons');
const authFlow = document.getElementById('plex-pin-auth-flow');
if (setupButtons) setupButtons.style.display = '';
if (authFlow) authFlow.style.display = 'none';
}
function restartPlexPinAuth() {
cancelPlexPinAuth();
startPlexPinAuth();
}
async function clearPlexConfiguration() {
cancelPlexPinAuth();
const plexUrl = document.getElementById('plex-url');
const plexToken = document.getElementById('plex-token');
const plexConfig = document.getElementById('plex-configuration');
const plexSetup = document.getElementById('plex-setup');
const plexSetupButtons = document.getElementById('plex-setup-buttons');
const plexViewConfigButton = document.getElementById('plex-view-config-button');
const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button');
const plexManualConfigButton = document.getElementById('plex-manual-config-button');
if (plexUrl) plexUrl.value = '';
if (plexToken) plexToken.value = '';
if (plexConfig) plexConfig.style.display = 'none';
if (plexSetup) plexSetup.style.display = '';
if (plexSetupButtons) plexSetupButtons.style.display = '';
if (plexViewConfigButton) plexViewConfigButton.style.display = 'none';
if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = '';
if (plexManualConfigButton) plexManualConfigButton.style.display = '';
const plexLibraryContainer = document.getElementById('plex-library-selector-container');
const plexLibrarySelect = document.getElementById('plex-music-library');
if (plexLibrarySelect) {
plexLibrarySelect.innerHTML = '<option value="">Select a music library</option>';
}
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 lidarrContainer = document.getElementById('lidarr-download-settings-container');
const soundcloudContainer = document.getElementById('soundcloud-download-settings-container');
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
// Determine which sources are active
let activeSources = new Set();
if (mode === 'hybrid') {
const order = getHybridOrder();
for (const src of order) activeSources.add(src);
// Fallback: if no sources enabled, at least show soulseek
if (activeSources.size === 0) activeSources.add('soulseek');
} else {
activeSources.add(mode);
}
soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none';
tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none';
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none';
if (soundcloudContainer) soundcloudContainer.style.display = activeSources.has('soundcloud') ? 'block' : 'none';
// Quality profile is Soulseek-only and downloads-tab-only
const qualityProfileSection = document.getElementById('quality-profile-section');
if (qualityProfileSection) {
const activeTab = document.querySelector('.stg-tab.active');
const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads';
qualityProfileSection.style.display = (activeSources.has('soulseek') && onDownloadsTab) ? '' : 'none';
}
if (activeSources.has('tidal')) {
checkTidalDownloadAuthStatus();
}
if (activeSources.has('qobuz')) {
checkQobuzAuthStatus();
}
if (activeSources.has('hifi')) {
testHiFiConnection();
}
if (activeSources.has('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: 'lidarr', label: 'Lidarr' },
{ value: 'soundcloud', label: 'SoundCloud' },
];
secondary.innerHTML = '';
for (const source of allSources) {
if (source.value === primary) continue;
const opt = document.createElement('option');
opt.value = source.value;
opt.textContent = source.label;
secondary.appendChild(opt);
}
// Restore previous selection if still valid, otherwise pick first available
if (currentValue !== primary) {
secondary.value = currentValue;
}
// Refresh source-specific settings visibility based on new primary/secondary
updateDownloadSourceUI();
}
// ===============================
// QUALITY PROFILE FUNCTIONS
// ===============================
let currentQualityProfile = null;
async function loadQualityProfile() {
try {
const response = await fetch('/api/quality-profile');
const data = await response.json();
if (data.success) {
currentQualityProfile = data.profile;
populateQualityProfileUI(currentQualityProfile);
}
} catch (error) {
console.error('Error loading quality profile:', error);
}
}
function populateQualityProfileUI(profile) {
// Update preset buttons
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`);
if (activePresetBtn) {
activePresetBtn.classList.add('active');
}
// Populate each quality tier
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
qualities.forEach(quality => {
const config = profile.qualities[quality];
if (config) {
// Set enabled checkbox
const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`);
if (enabledCheckbox) {
enabledCheckbox.checked = config.enabled;
}
// Set min/max sliders
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
if (minSlider && maxSlider) {
minSlider.value = config.min_kbps;
maxSlider.value = config.max_kbps;
updateQualityRange(quality);
}
// Set priority display
const prioritySpan = document.getElementById(`priority-${quality}`);
if (prioritySpan) {
prioritySpan.textContent = `Priority: ${config.priority}`;
}
// Toggle sliders visibility
const sliders = document.getElementById(`sliders-${quality}`);
if (sliders) {
if (config.enabled) {
sliders.classList.remove('disabled');
} else {
sliders.classList.add('disabled');
}
}
// FLAC-specific: restore bit depth selector and fallback toggle
if (quality === 'flac') {
const bitDepthValue = config.bit_depth || 'any';
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue);
});
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
if (bitDepthSelector) {
if (config.enabled) {
bitDepthSelector.classList.remove('disabled');
} else {
bitDepthSelector.classList.add('disabled');
}
}
// Show/hide and restore fallback toggle
const fallbackToggle = document.getElementById('flac-fallback-toggle');
if (fallbackToggle) {
fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block';
}
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
if (fallbackCb) {
fallbackCb.checked = config.bit_depth_fallback !== false;
}
}
}
});
// Set fallback checkbox
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
if (fallbackCheckbox) {
fallbackCheckbox.checked = profile.fallback_enabled;
}
}
function updateQualityRange(quality) {
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
const minValue = document.getElementById(`${quality}-min-value`);
const maxValue = document.getElementById(`${quality}-max-value`);
if (!minSlider || !maxSlider || !minValue || !maxValue) return;
let min = parseInt(minSlider.value);
let max = parseInt(maxSlider.value);
// Ensure min doesn't exceed max
if (min > max) {
min = max;
minSlider.value = min;
}
// Ensure max doesn't go below min
if (max < min) {
max = min;
maxSlider.value = max;
}
minValue.textContent = `${min} kbps`;
maxValue.textContent = `${max} kbps`;
}
function toggleQuality(quality) {
const checkbox = document.getElementById(`quality-${quality}-enabled`);
const sliders = document.getElementById(`sliders-${quality}`);
if (checkbox && sliders) {
if (checkbox.checked) {
sliders.classList.remove('disabled');
} else {
sliders.classList.add('disabled');
}
}
// Also toggle FLAC bit depth selector
if (quality === 'flac') {
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
if (bitDepthSelector && checkbox) {
if (checkbox.checked) {
bitDepthSelector.classList.remove('disabled');
} else {
bitDepthSelector.classList.add('disabled');
}
}
}
// Mark preset as custom when manually changing
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
}
function setFlacBitDepth(value) {
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-value') === value);
});
// Show/hide fallback toggle — only relevant when a specific bit depth is selected
const fallbackToggle = document.getElementById('flac-fallback-toggle');
if (fallbackToggle) {
fallbackToggle.style.display = value === 'any' ? 'none' : 'block';
}
// Mark preset as custom when manually changing
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
debouncedAutoSaveSettings();
}
function setFlacBitDepthFallback(enabled) {
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
debouncedAutoSaveSettings();
}
async function applyQualityPreset(presetName) {
try {
showLoadingOverlay(`Applying ${presetName} preset...`);
const response = await fetch(`/api/quality-profile/preset/${presetName}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
currentQualityProfile = data.profile;
populateQualityProfileUI(currentQualityProfile);
showToast(`Applied '${presetName}' preset`, 'success');
} else {
showToast(`Failed to apply preset: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error applying quality preset:', error);
showToast('Failed to apply preset', 'error');
} finally {
hideLoadingOverlay();
}
}
function collectQualityProfileFromUI() {
const profile = {
version: 2,
preset: 'custom', // Will be overridden if a preset is active
qualities: {},
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true
};
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
qualities.forEach((quality, index) => {
const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false;
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
// Preserve priority from the currently loaded profile instead of using array order
const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1);
profile.qualities[quality] = {
enabled: enabled,
min_kbps: parseInt(minSlider?.value || 0),
max_kbps: parseInt(maxSlider?.value || 99999),
priority: existingPriority
};
// Add FLAC-specific bit_depth and fallback settings
if (quality === 'flac') {
const activeBtn = document.querySelector('.bit-depth-btn.active');
profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any';
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true;
}
});
// Check if current profile matches a preset
if (currentQualityProfile && currentQualityProfile.preset !== 'custom') {
profile.preset = currentQualityProfile.preset;
}
return profile;
}
async function saveQualityProfile() {
try {
const profile = collectQualityProfileFromUI();
const response = await fetch('/api/quality-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(profile)
});
const data = await response.json();
if (data.success) {
currentQualityProfile = profile;
console.log('Quality profile saved successfully');
return true;
} else {
console.error('Failed to save quality profile:', data.error);
return false;
}
} catch (error) {
console.error('Error saving quality profile:', error);
return false;
}
}
// ===============================
// END QUALITY PROFILE FUNCTIONS
// ===============================
async function toggleHydrabaseFromSettings() {
const statusEl = document.getElementById('hydrabase-settings-status');
const btn = document.getElementById('hydrabase-connect-btn');
const url = document.getElementById('hydrabase-url').value.trim();
const apiKey = document.getElementById('hydrabase-api-key').value.trim();
if (!url || !apiKey) {
if (statusEl) statusEl.textContent = 'URL and API Key required';
return;
}
// Save settings first
await saveSettings(true);
try {
// Check current status
const statusRes = await fetch('/api/hydrabase/status');
const statusData = await statusRes.json();
if (statusData.connected) {
// Disconnect
await fetch('/api/hydrabase/disconnect', { method: 'POST' });
if (btn) btn.textContent = 'Connect';
if (statusEl) { statusEl.textContent = 'Disconnected'; statusEl.style.color = 'rgba(255,255,255,0.4)'; }
// Remove from fallback dropdown + reset to iTunes if was selected
const fbSel2 = document.getElementById('metadata-fallback-source');
if (fbSel2) {
const hbOpt = fbSel2.querySelector('option[value="hydrabase"]');
if (hbOpt) {
if (fbSel2.value === 'hydrabase') fbSel2.value = 'itunes';
hbOpt.remove();
}
}
showToast('Hydrabase disconnected', 'info');
} else {
// Connect
const res = await fetch('/api/hydrabase/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, api_key: apiKey })
});
const data = await res.json();
if (data.success) {
if (btn) btn.textContent = 'Disconnect';
if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; }
// Add to fallback dropdown
const fbSel = document.getElementById('metadata-fallback-source');
if (fbSel && !fbSel.querySelector('option[value="hydrabase"]')) {
const opt = document.createElement('option');
opt.value = 'hydrabase';
opt.textContent = 'Hydrabase (P2P)';
fbSel.appendChild(opt);
}
showToast('Hydrabase connected', 'success');
} else {
if (statusEl) statusEl.textContent = data.error || 'Connection failed';
showToast('Hydrabase connection failed', 'error');
}
}
} catch (e) {
if (statusEl) statusEl.textContent = 'Error';
showToast('Hydrabase connection error', 'error');
}
}
// ── Music Library Paths ──
function renderMusicPaths(paths) {
const container = document.getElementById('music-paths-list');
if (!container) return;
if (!paths || paths.length === 0) {
container.innerHTML = '<div style="color: rgba(255,255,255,0.3); font-size: 0.85em; padding: 4px 0;">No paths configured. Click "Add Path" to add your music folder(s).</div>';
return;
}
container.innerHTML = paths.map((p, i) => `
<div class="form-group music-path-row" style="margin-bottom: 4px;">
<input type="text" class="music-path-input" value="${escapeHtml(p)}" placeholder="/music or C:\\Music" style="flex:1;">
<button class="test-button" onclick="_removeMusicPathRow(this)" style="padding: 8px 12px; color: #ef5350; border-color: rgba(239,83,80,0.3);">&times;</button>
</div>
`).join('');
// Attach auto-save to dynamically rendered inputs
container.querySelectorAll('.music-path-input').forEach(input => {
input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); });
});
}
function addMusicPathRow() {
const container = document.getElementById('music-paths-list');
if (!container) return;
// Clear the "no paths" message if present
const placeholder = container.querySelector('div[style*="color: rgba"]');
if (placeholder && !container.querySelector('.music-path-row')) placeholder.remove();
const row = document.createElement('div');
row.className = 'form-group music-path-row';
row.style.marginBottom = '4px';
row.innerHTML = `
<input type="text" class="music-path-input" value="" placeholder="/music or C:\\Music" style="flex:1;">
<button class="test-button" onclick="_removeMusicPathRow(this)" style="padding: 8px 12px; color: #ef5350; border-color: rgba(239,83,80,0.3);">&times;</button>
`;
container.appendChild(row);
const input = row.querySelector('input');
input.focus();
// Auto-save when the user finishes typing a path
input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); });
}
function _removeMusicPathRow(btn) {
btn.closest('.music-path-row').remove();
// Auto-save after removing a path
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
}
function collectMusicPaths() {
const inputs = document.querySelectorAll('.music-path-input');
const paths = [];
inputs.forEach(input => {
const val = input.value.trim();
if (val) paths.push(val);
});
return paths;
}
// ── Genre Whitelist ──
let _genreWhitelistCache = [];
function _genreWhitelistRender(genres) {
_genreWhitelistCache = genres && genres.length ? genres : [];
const container = document.getElementById('genre-whitelist-chips');
const countEl = document.getElementById('genre-whitelist-count');
if (!container) return;
if (!_genreWhitelistCache.length) {
container.innerHTML = '<div style="color:rgba(255,255,255,0.3);font-size:13px;padding:4px 0;">No genres configured. Click "Reset to Defaults" to load the default whitelist.</div>';
if (countEl) countEl.textContent = '';
return;
}
const searchVal = (document.getElementById('genre-whitelist-search')?.value || '').toLowerCase();
const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache;
container.innerHTML = filtered.map(g =>
`<span class="genre-chip">${escapeHtml(g)}<button class="genre-chip-x" data-genre="${escapeHtml(g)}">&times;</button></span>`
).join('');
if (countEl) countEl.textContent = `${_genreWhitelistCache.length} genres`;
_initGenreChipClickHandler();
}
function _initGenreChipClickHandler() {
const container = document.getElementById('genre-whitelist-chips');
if (!container) return;
container.onclick = (e) => {
const btn = e.target.closest('.genre-chip-x');
if (btn) {
e.preventDefault();
_genreWhitelistRemove(btn.dataset.genre);
}
};
}
function _genreWhitelistRemove(genre) {
_genreWhitelistCache = _genreWhitelistCache.filter(g => g !== genre);
_genreWhitelistRender(_genreWhitelistCache);
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
}
function _genreWhitelistAdd(genre) {
genre = genre.trim();
if (!genre) return;
if (_genreWhitelistCache.some(g => g.toLowerCase() === genre.toLowerCase())) return;
_genreWhitelistCache.push(genre);
_genreWhitelistCache.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
_genreWhitelistRender(_genreWhitelistCache);
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
}
async function _genreWhitelistReset() {
try {
const resp = await fetch('/api/genre-whitelist/defaults');
const data = await resp.json();
if (data.genres) {
_genreWhitelistCache = data.genres;
_genreWhitelistRender(_genreWhitelistCache);
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
showToast(`Loaded ${data.genres.length} default genres`, 'success');
}
} catch (e) {
showToast('Failed to load defaults', 'error');
}
}
// Toggle whitelist container visibility + init
document.addEventListener('change', (e) => {
if (e.target.id === 'genre-whitelist-enabled') {
const container = document.getElementById('genre-whitelist-container');
if (container) container.style.display = e.target.checked ? '' : 'none';
// Auto-populate with defaults on first enable if empty
if (e.target.checked && _genreWhitelistCache.length === 0) {
_genreWhitelistReset();
}
}
});
// Search/add handler
document.addEventListener('keydown', (e) => {
if (e.target.id === 'genre-whitelist-search' && e.key === 'Enter') {
e.preventDefault();
_genreWhitelistAdd(e.target.value);
e.target.value = '';
}
});
document.addEventListener('input', (e) => {
if (e.target.id === 'genre-whitelist-search') {
_genreWhitelistRender(_genreWhitelistCache);
}
});
function _collectGenreWhitelist() {
return _genreWhitelistCache;
}
// ── Live Log Viewer ──
let _logViewerActive = false;
let _logViewerFilter = '';
let _logViewerSource = 'app';
let _logViewerSearch = '';
const _LOG_MAX_LINES = 2000;
function _logClassify(line) {
// Exact logger format first
if (line.includes(' - DEBUG - ')) return 'DEBUG';
if (line.includes(' - INFO - ')) return 'INFO';
if (line.includes(' - WARNING - ')) return 'WARNING';
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'ERROR';
// Heuristic for print() output
const ll = line.toLowerCase();
if (ll.includes('error') || ll.includes('traceback') || ll.includes('exception') || ll.includes('failed')) return 'ERROR';
if (ll.includes('warning') || ll.includes('warn')) return 'WARNING';
if (ll.includes('debug')) return 'DEBUG';
return 'INFO';
}
function _logClassToCSS(level) {
return { DEBUG: 'log-debug', INFO: 'log-info', WARNING: 'log-warning', ERROR: 'log-error' }[level] || 'log-plain';
}
async function _logViewerInit() {
if (_logViewerActive) return;
_logViewerActive = true;
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
// Fetch initial tail
try {
const params = new URLSearchParams({ source: _logViewerSource, lines: 300 });
if (_logViewerFilter) params.set('level', _logViewerFilter);
if (_logViewerSearch) params.set('search', _logViewerSearch);
const resp = await fetch(`/api/logs/tail?${params}`);
const data = await resp.json();
if (data.lines) {
const container = document.getElementById('log-viewer-lines');
if (container) {
container.innerHTML = '';
_logViewerAppendLines(data.lines);
}
}
} catch (e) {
console.warn('Failed to load initial logs:', e);
}
// Subscribe to live updates
if (typeof socket !== 'undefined' && socket && socket.connected) {
socket.emit('logs:subscribe', { source: _logViewerSource });
socket.on('logs:live', _logViewerOnLive);
}
}
function _logViewerStop() {
if (!_logViewerActive) return;
_logViewerActive = false;
if (typeof socket !== 'undefined' && socket) {
socket.off('logs:live', _logViewerOnLive);
socket.emit('logs:unsubscribe', {});
}
}
function _logViewerOnLive(data) {
if (!_logViewerActive || !data.lines) return;
if (data.source !== _logViewerSource) return;
let lines = data.lines;
// Apply level filter client-side for live lines
if (_logViewerFilter) {
lines = lines.filter(l => _logClassify(l) === _logViewerFilter);
}
// Apply search filter
if (_logViewerSearch) {
const s = _logViewerSearch.toLowerCase();
lines = lines.filter(l => l.toLowerCase().includes(s));
}
if (lines.length > 0) _logViewerAppendLines(lines);
}
function _logViewerAppendLines(lines) {
const container = document.getElementById('log-viewer-lines');
if (!container) return;
const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked;
const terminal = document.getElementById('log-viewer-terminal');
const frag = document.createDocumentFragment();
for (const line of lines) {
const div = document.createElement('div');
div.className = 'log-line ' + _logClassToCSS(_logClassify(line));
div.textContent = line;
frag.appendChild(div);
}
container.appendChild(frag);
// Trim old lines
while (container.children.length > _LOG_MAX_LINES) {
container.removeChild(container.firstChild);
}
// Update count
const countEl = document.getElementById('log-viewer-line-count');
if (countEl) countEl.textContent = `${container.children.length} lines`;
// Auto-scroll
if (autoScroll && terminal) {
terminal.scrollTop = terminal.scrollHeight;
}
}
async function _logViewerChangeSource() {
_logViewerStop();
_logViewerSource = document.getElementById('log-viewer-source')?.value || 'app';
const container = document.getElementById('log-viewer-lines');
if (container) container.innerHTML = '<div class="log-line log-info">Loading...</div>';
await _logViewerInit();
}
function _logViewerFilterLevel(btn) {
document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_logViewerFilter = btn.dataset.level || '';
_logViewerReload();
}
let _logSearchDebounce = null;
function _logViewerOnSearch(input) {
clearTimeout(_logSearchDebounce);
_logSearchDebounce = setTimeout(() => {
_logViewerSearch = (input.value || '').trim();
_logViewerReload();
}, 300);
}
function _logViewerReload() {
_logViewerStop();
const container = document.getElementById('log-viewer-lines');
if (container) container.innerHTML = '<div class="log-line log-info">Loading...</div>';
_logViewerInit();
}
function _logViewerCopy() {
const container = document.getElementById('log-viewer-lines');
if (!container) return;
const text = Array.from(container.children).map(el => el.textContent).join('\n');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => showToast('Logs copied', 'success'));
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('Logs copied', 'success');
}
}
function _logViewerClear() {
const container = document.getElementById('log-viewer-lines');
if (container) container.innerHTML = '';
const countEl = document.getElementById('log-viewer-line-count');
if (countEl) countEl.textContent = '0 lines';
}
// ── Database Maintenance ──
async function loadDbMaintenanceInfo() {
try {
const resp = await fetch('/api/database/maintenance/info');
const data = await resp.json();
if (!data.success) return;
const sizeEl = document.getElementById('db-size-display');
const freeEl = document.getElementById('db-freepages-display');
const vacEl = document.getElementById('db-autovacuum-display');
if (sizeEl) sizeEl.textContent = data.total_size_display;
if (freeEl) freeEl.textContent = data.free_pages > 0
? `${data.free_pages.toLocaleString()} (${data.free_size_display} reclaimable)`
: 'None — database is fully compacted';
if (vacEl) vacEl.textContent = data.auto_vacuum_label;
// Hide enable button if already incremental
const incBtn = document.getElementById('db-incvacuum-btn');
if (incBtn && data.auto_vacuum === 2) {
incBtn.textContent = 'Incremental Vacuum Enabled';
incBtn.disabled = true;
incBtn.style.opacity = '0.5';
}
} catch (e) { console.error('Error loading DB maintenance info:', e); }
}
async function runDatabaseVacuum() {
const btn = document.getElementById('db-vacuum-btn');
const status = document.getElementById('db-vacuum-status');
if (!confirm('This will compact the database by rewriting it. The database will be locked during this operation. For large databases this may take over a minute. Continue?')) return;
btn.disabled = true;
btn.textContent = 'Compacting...';
if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Running VACUUM — this may take a while...'; }
try {
const resp = await fetch('/api/database/maintenance/vacuum', { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast(`Database compacted in ${data.elapsed_seconds}s — saved ${data.saved_display}`, 'success');
if (status) { status.style.color = '#4caf50'; status.textContent = `Done in ${data.elapsed_seconds}s. Saved ${data.saved_display}.`; }
loadDbMaintenanceInfo();
} else {
showToast('Vacuum failed: ' + (data.error || 'Unknown error'), 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); }
}
} catch (e) {
showToast('Vacuum failed: ' + e.message, 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; }
} finally {
btn.disabled = false;
btn.textContent = 'Compact Database (VACUUM)';
}
}
async function enableIncrementalVacuum() {
const btn = document.getElementById('db-incvacuum-btn');
const status = document.getElementById('db-vacuum-status');
if (!confirm('This will enable incremental vacuum mode. It requires a one-time full VACUUM to activate, which locks the database and may take over a minute on large databases. Continue?')) return;
btn.disabled = true;
btn.textContent = 'Enabling...';
if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Enabling incremental vacuum — this may take a while...'; }
try {
const resp = await fetch('/api/database/maintenance/enable-incremental-vacuum', { method: 'POST' });
const data = await resp.json();
if (data.success) {
const msg = data.already_enabled ? 'Already enabled' : `Enabled in ${data.elapsed_seconds}s — saved ${data.saved_display}`;
showToast(msg, 'success');
if (status) { status.style.color = '#4caf50'; status.textContent = msg; }
loadDbMaintenanceInfo();
} else {
showToast('Failed: ' + (data.error || 'Unknown error'), 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); }
}
} catch (e) {
showToast('Failed: ' + e.message, 'error');
if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; }
} finally {
btn.disabled = false;
btn.textContent = 'Enable Incremental Vacuum';
}
}
async function activateDevMode() {
const password = document.getElementById('dev-mode-password').value;
try {
const response = await fetch('/api/dev-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
document.getElementById('dev-mode-status').textContent = 'Active';
document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))';
document.getElementById('hydrabase-nav').style.display = '';
document.getElementById('hydrabase-button-container').style.display = '';
document.getElementById('dev-mode-password').value = '';
showToast('Dev mode activated', 'success');
} else {
showToast('Invalid password', 'error');
}
} catch (e) {
showToast('Failed to activate dev mode', 'error');
}
}
// ── Hydrabase Functions ──
let _hydrabaseConnected = false;
async function hydrabaseToggleConnection() {
if (_hydrabaseConnected) {
await hydrabaseDisconnect();
} else {
await hydrabaseConnect();
}
}
async function hydrabaseConnect() {
const url = document.getElementById('hydra-ws-url').value.trim();
const apiKey = document.getElementById('hydra-api-key').value.trim();
if (!url || !apiKey) {
showToast('URL and API key required', 'error');
return;
}
const statusEl = document.getElementById('hydra-connection-status');
const btn = document.getElementById('hydra-connect-btn');
statusEl.textContent = 'Connecting...';
statusEl.style.color = '#f0ad4e';
try {
const response = await fetch('/api/hydrabase/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, api_key: apiKey })
});
const data = await response.json();
if (data.success) {
_hydrabaseConnected = true;
statusEl.textContent = 'Connected';
statusEl.style.color = 'rgb(var(--accent-light-rgb))';
btn.textContent = 'Disconnect';
showToast('Connected to Hydrabase', 'success');
} else {
statusEl.textContent = 'Failed';
statusEl.style.color = '#f44336';
showToast(data.error || 'Connection failed', 'error');
}
} catch (e) {
statusEl.textContent = 'Error';
statusEl.style.color = '#f44336';
showToast('Connection error', 'error');
}
}
async function hydrabaseDisconnect() {
try {
await fetch('/api/hydrabase/disconnect', { method: 'POST' });
} catch (e) { }
_hydrabaseConnected = false;
document.getElementById('hydra-connection-status').textContent = 'Disconnected';
document.getElementById('hydra-connection-status').style.color = '#888';
document.getElementById('hydra-connect-btn').textContent = 'Connect';
// Dev mode is disabled on disconnect — hide Hydrabase nav and update settings status
document.getElementById('hydrabase-nav').style.display = 'none';
document.getElementById('hydrabase-button-container').style.display = 'none';
const devStatus = document.getElementById('dev-mode-status');
if (devStatus) {
devStatus.textContent = 'Inactive';
devStatus.style.color = '#888';
}
showToast('Disconnected — dev mode disabled', 'success');
navigateToPage('settings');
}
async function loadHydrabaseComparisons() {
const container = document.getElementById('hydra-comparisons-container');
if (!container) return;
try {
const response = await fetch('/api/hydrabase/comparisons');
const data = await response.json();
if (!data.success || !data.comparisons?.length) {
container.innerHTML = '<p style="color: #666; font-size: 13px;">No comparisons yet. Search with Hydrabase active to generate comparisons.</p>';
return;
}
let html = '';
for (const comp of data.comparisons) {
const time = new Date(comp.timestamp * 1000).toLocaleTimeString();
html += `<div style="background: rgba(30, 30, 30, 0.6); border-radius: 8px; padding: 10px; margin-bottom: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<strong style="color: #fff;">"${comp.query}"</strong>
<span style="color: #666; font-size: 11px;">${time}</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; font-size: 12px;">
<div style="padding: 6px 8px; border-radius: 6px; background: rgba(99, 102, 241, 0.15); border: 1px solid rgba(99, 102, 241, 0.3);">
<div style="color: rgba(139, 92, 246, 1); font-weight: 600; margin-bottom: 2px;">Hydrabase</div>
<div style="color: #aaa;">${comp.hydrabase?.tracks || 0}T / ${comp.hydrabase?.artists || 0}A / ${comp.hydrabase?.albums || 0}Al</div>
</div>
<div style="padding: 6px 8px; border-radius: 6px; background: rgba(29, 185, 84, 0.15); border: 1px solid rgba(29, 185, 84, 0.3);">
<div style="color: rgb(var(--accent-light-rgb)); font-weight: 600; margin-bottom: 2px;">Spotify</div>
<div style="color: #aaa;">${comp.spotify?.tracks || 0}T / ${comp.spotify?.artists || 0}A / ${comp.spotify?.albums || 0}Al</div>
</div>
<div style="padding: 6px 8px; border-radius: 6px; background: rgba(251, 93, 93, 0.15); border: 1px solid rgba(251, 93, 93, 0.3);">
<div style="color: #fb5d5d; font-weight: 600; margin-bottom: 2px;">${comp.fallback_source === 'deezer' ? 'Deezer' : 'iTunes'}</div>
<div style="color: #aaa;">${(comp.fallback || comp.itunes)?.tracks || 0}T / ${(comp.fallback || comp.itunes)?.artists || 0}A / ${(comp.fallback || comp.itunes)?.albums || 0}Al</div>
</div>
</div>
</div>`;
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<p style="color: #f44336; font-size: 13px;">Failed to load comparisons.</p>';
}
}
async function hydrabaseSendRaw(textareaId) {
const textarea = document.getElementById(textareaId);
const raw = textarea.value.trim();
if (!raw) {
showToast('Payload is empty', 'error');
return;
}
if (!_hydrabaseConnected) {
showToast('Not connected to Hydrabase', 'error');
return;
}
let payload;
try {
payload = JSON.parse(raw);
} catch (e) {
showToast('Invalid JSON payload', 'error');
return;
}
// Auto-inject a fresh nonce if not set or zero
if (!payload.nonce) {
payload.nonce = Date.now();
}
const responseArea = document.getElementById('hydra-response');
responseArea.textContent = 'Sending...';
try {
const response = await fetch('/api/hydrabase/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payload })
});
const data = await response.json();
if (data.success) {
responseArea.textContent = JSON.stringify(data.data, null, 2);
} else {
responseArea.textContent = 'Error: ' + (data.error || 'Unknown error');
if (data.error && data.error.includes('Not connected')) {
_hydrabaseConnected = false;
document.getElementById('hydra-connection-status').textContent = 'Disconnected';
document.getElementById('hydra-connection-status').style.color = '#888';
document.getElementById('hydra-connect-btn').textContent = 'Connect';
}
}
} catch (e) {
responseArea.textContent = 'Error: ' + e.message;
}
}
// ── Tag embedding accordion helpers ──
function toggleTagGroup(header) {
const body = header.nextElementSibling;
const arrow = header.querySelector('.tag-group-arrow');
if (body.style.display === 'none') {
body.style.display = 'block';
arrow.classList.add('open');
} else {
body.style.display = 'none';
arrow.classList.remove('open');
}
}
function toggleServiceTags(masterCheckbox, serviceName) {
const group = masterCheckbox.closest('.tag-service-group');
if (!group) return;
const body = group.querySelector('.tag-service-body');
if (!body) return;
const childCheckboxes = body.querySelectorAll('input[type="checkbox"]');
childCheckboxes.forEach(cb => {
const label = cb.closest('.checkbox-label');
if (masterCheckbox.checked) {
if (label) label.classList.remove('disabled-tag');
cb.disabled = false;
} else {
if (label) label.classList.add('disabled-tag');
cb.disabled = true;
}
});
}
function _collectServiceTags(serviceName) {
const tags = {};
document.querySelectorAll(`[data-config^="${serviceName}.tags."]`).forEach(cb => {
const key = cb.dataset.config.split('.').pop();
tags[key] = cb.checked;
});
return tags;
}
function _getTagConfig(path) {
const el = document.querySelector(`[data-config="${path}"]`);
return el ? el.checked : true;
}
async function saveSettings(quiet = false) {
// Validate file organization templates before saving
const validationErrors = validateFileOrganizationTemplates();
if (validationErrors.length > 0) {
if (!quiet) showToast('Template validation failed: ' + validationErrors.join(', '), 'error');
return;
}
// Determine active server from toggle buttons
let activeServer = 'plex';
if (document.getElementById('jellyfin-toggle').classList.contains('active')) {
activeServer = 'jellyfin';
} else if (document.getElementById('navidrome-toggle').classList.contains('active')) {
activeServer = 'navidrome';
} else if (document.getElementById('soulsync-toggle')?.classList.contains('active')) {
activeServer = 'soulsync';
}
const metadataSourceSelect = document.getElementById('metadata-fallback-source');
const discogsTokenInput = document.getElementById('discogs-token');
const discogsTokenPresent = !!discogsTokenInput?.value?.trim();
let metadataSource = metadataSourceSelect?.value || 'itunes';
const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true;
if (metadataSource === 'spotify' && !spotifySessionActive) {
metadataSource = 'deezer';
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
if (!quiet) {
showToast('Spotify is disconnected, so Deezer is used as the primary metadata source.', 'warning');
}
} else if (metadataSource === 'discogs' && !discogsTokenPresent) {
metadataSource = 'itunes';
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
if (!quiet) {
showToast('Discogs requires a personal access token before it can be selected as the primary metadata source.', 'warning');
}
}
const settings = {
active_media_server: activeServer,
spotify: {
client_id: document.getElementById('spotify-client-id').value,
client_secret: document.getElementById('spotify-client-secret').value,
redirect_uri: document.getElementById('spotify-redirect-uri').value,
embed_tags: document.getElementById('embed-spotify').checked,
tags: _collectServiceTags('spotify')
},
tidal: {
client_id: document.getElementById('tidal-client-id').value,
client_secret: document.getElementById('tidal-client-secret').value,
redirect_uri: document.getElementById('tidal-redirect-uri').value,
embed_tags: document.getElementById('embed-tidal').checked,
tags: _collectServiceTags('tidal')
},
plex: {
base_url: document.getElementById('plex-url').value,
token: document.getElementById('plex-token').value
},
jellyfin: {
base_url: document.getElementById('jellyfin-url').value,
api_key: document.getElementById('jellyfin-api-key').value,
api_timeout: parseInt(document.getElementById('jellyfin-timeout').value) || 30
},
navidrome: {
base_url: document.getElementById('navidrome-url').value,
username: document.getElementById('navidrome-username').value,
password: document.getElementById('navidrome-password').value
},
soulseek: {
slskd_url: document.getElementById('soulseek-url').value,
api_key: document.getElementById('soulseek-api-key').value,
download_path: document.getElementById('download-path').value,
transfer_path: document.getElementById('transfer-path').value,
search_timeout: parseInt(document.getElementById('soulseek-search-timeout').value) || 60,
search_timeout_buffer: parseInt(document.getElementById('soulseek-search-timeout-buffer').value) || 15,
min_peer_upload_speed: parseInt(document.getElementById('soulseek-min-peer-speed').value) || 0,
max_peer_queue: parseInt(document.getElementById('soulseek-max-peer-queue').value) || 0,
download_timeout: (parseInt(document.getElementById('soulseek-download-timeout').value) || 10) * 60,
auto_clear_searches: document.getElementById('soulseek-auto-clear-searches').checked
},
listenbrainz: {
base_url: document.getElementById('listenbrainz-base-url').value,
token: document.getElementById('listenbrainz-token').value,
scrobble_enabled: document.getElementById('listenbrainz-scrobble-enabled').checked,
},
acoustid: {
api_key: document.getElementById('acoustid-api-key').value,
enabled: document.getElementById('acoustid-enabled').checked
},
lastfm: {
api_key: document.getElementById('lastfm-api-key').value,
api_secret: document.getElementById('lastfm-api-secret').value,
scrobble_enabled: document.getElementById('lastfm-scrobble-enabled').checked,
embed_tags: document.getElementById('embed-lastfm').checked,
tags: _collectServiceTags('lastfm')
},
genius: {
access_token: document.getElementById('genius-access-token').value,
embed_tags: document.getElementById('embed-genius').checked,
tags: _collectServiceTags('genius')
},
itunes: {
country: document.getElementById('itunes-country').value || 'US',
embed_tags: document.getElementById('embed-itunes').checked,
tags: _collectServiceTags('itunes')
},
discogs: {
token: document.getElementById('discogs-token').value,
},
metadata: {
fallback_source: metadataSource
},
hydrabase: {
url: document.getElementById('hydrabase-url').value,
api_key: document.getElementById('hydrabase-api-key').value,
auto_connect: document.getElementById('hydrabase-auto-connect').checked
},
download_source: {
mode: document.getElementById('download-source-mode').value,
hybrid_primary: document.getElementById('hybrid-primary-source').value,
hybrid_secondary: document.getElementById('hybrid-secondary-source').value,
hybrid_order: getHybridOrder(),
stream_source: document.getElementById('stream-source').value,
max_concurrent: parseInt(document.getElementById('max-concurrent-downloads').value) || 3,
},
tidal_download: {
quality: document.getElementById('tidal-download-quality').value || 'lossless',
allow_fallback: document.getElementById('tidal-allow-fallback').checked,
},
hifi_download: {
quality: document.getElementById('hifi-download-quality').value || 'lossless',
allow_fallback: document.getElementById('hifi-allow-fallback').checked,
},
hifi: {
embed_tags: document.getElementById('embed-hifi').checked,
tags: _collectServiceTags('hifi')
},
deezer_download: {
quality: document.getElementById('deezer-download-quality').value || 'flac',
arl: document.getElementById('deezer-download-arl').value || '',
allow_fallback: document.getElementById('deezer-allow-fallback').checked,
},
lidarr_download: {
url: document.getElementById('lidarr-url').value || '',
api_key: document.getElementById('lidarr-api-key').value || '',
},
soundcloud_download: {
// No knobs yet — anonymous-only. Keeping the key present so
// future tier-2 OAuth wiring (Go+ session token) doesn't have
// to migrate existing configs.
},
qobuz: {
quality: document.getElementById('qobuz-quality').value || 'lossless',
embed_tags: document.getElementById('embed-qobuz').checked,
tags: _collectServiceTags('qobuz'),
allow_fallback: document.getElementById('qobuz-allow-fallback').checked,
},
database: {
max_workers: parseInt(document.getElementById('max-workers').value)
},
metadata_enhancement: {
enabled: document.getElementById('metadata-enabled').checked,
embed_album_art: document.getElementById('embed-album-art').checked,
cover_art_download: document.getElementById('cover-art-download').checked,
prefer_caa_art: document.getElementById('prefer-caa-art').checked,
lrclib_enabled: document.getElementById('lrclib-enabled').checked,
tags: {
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),
genre_merge: _getTagConfig('metadata_enhancement.tags.genre_merge'),
artist_separator: document.getElementById('artist-separator').value,
write_multi_artist: document.getElementById('write-multi-artist').checked,
feat_in_title: document.getElementById('feat-in-title').checked
}
},
musicbrainz: {
embed_tags: document.getElementById('embed-musicbrainz').checked,
tags: _collectServiceTags('musicbrainz')
},
deezer: {
app_id: document.getElementById('deezer-app-id').value,
app_secret: document.getElementById('deezer-app-secret').value,
redirect_uri: document.getElementById('deezer-redirect-uri').value,
embed_tags: document.getElementById('embed-deezer').checked,
tags: _collectServiceTags('deezer')
},
audiodb: {
embed_tags: document.getElementById('embed-audiodb').checked,
tags: _collectServiceTags('audiodb')
},
file_organization: {
enabled: document.getElementById('file-organization-enabled').checked,
disc_label: document.getElementById('disc-label').value,
collab_artist_mode: document.getElementById('collab-artist-mode').value,
templates: {
album_path: document.getElementById('template-album-path').value,
single_path: document.getElementById('template-single-path').value,
playlist_path: document.getElementById('template-playlist-path').value,
video_path: document.getElementById('template-video-path').value
}
},
wishlist: {
allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked
},
playlist_sync: {
create_backup: document.getElementById('create-backup').checked
},
content_filter: {
allow_explicit: document.getElementById('allow-explicit').checked
},
genre_whitelist: {
enabled: document.getElementById('genre-whitelist-enabled').checked,
genres: _collectGenreWhitelist(),
},
post_processing: {
replaygain_enabled: document.getElementById('replaygain-enabled').checked,
},
library: {
music_paths: collectMusicPaths(),
music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos'
},
import: {
replace_lower_quality: document.getElementById('import-replace-lower-quality').checked,
staging_path: document.getElementById('staging-path').value || './Staging'
},
lossy_copy: {
enabled: document.getElementById('lossy-copy-enabled').checked,
codec: document.getElementById('lossy-copy-codec').value,
bitrate: document.getElementById('lossy-copy-bitrate').value,
delete_original: document.getElementById('lossy-copy-delete-original').checked,
downsample_hires: document.getElementById('downsample-hires').checked
},
listening_stats: {
enabled: document.getElementById('listening-stats-enabled').checked,
poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30,
},
m3u_export: {
enabled: document.getElementById('m3u-export-enabled').checked,
entry_base_path: document.getElementById('m3u-entry-base-path').value || ''
},
ui_appearance: {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars',
particles_enabled: document.getElementById('particles-enabled')?.checked !== false,
worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false,
reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true
},
youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value,
download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3,
},
security: {
require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false,
cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '',
}
};
// Validate cors_origins entries — backend silently filters malformed
// values, so warn the user up-front if any line doesn't look like a
// URL (or the special '*' token). One-shot toast; doesn't block save.
const corsRaw = settings.security.cors_origins;
if (corsRaw) {
const entries = corsRaw.replace(/\n/g, ',').split(',')
.map(s => s.trim())
.filter(s => s);
const invalid = entries.filter(e => {
if (e === '*') return false;
// Accept scheme://host[:port] only — no path, query, or fragment.
// Engineio compares Origin against {scheme}://{host} exactly.
return !/^https?:\/\/[^\s/?#]+$/i.test(e);
});
if (invalid.length) {
showToast(
`Allowed Origins: ${invalid.length} entr${invalid.length === 1 ? 'y looks' : 'ies look'} malformed (need full URL like https://soulsync.example.com, no trailing slash). Saving anyway — they\'ll be ignored.`,
'warning'
);
}
}
try {
if (!quiet) showLoadingOverlay('Saving settings...');
// Save main settings
const response = await fetch(API.settings, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
const result = await response.json();
// Save quality profile
const qualityProfileSaved = await saveQualityProfile();
// Save discovery lookback period
let lookbackSaved = true;
try {
const lookbackPeriod = document.getElementById('discovery-lookback-period').value;
const lookbackResponse = await fetch('/api/discovery/lookback-period', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ period: lookbackPeriod })
});
const lookbackResult = await lookbackResponse.json();
lookbackSaved = lookbackResult.success === true;
} catch (error) {
console.error('Error saving discovery lookback period:', error);
lookbackSaved = false;
}
// Save hemisphere setting
try {
const hemisphere = document.getElementById('discovery-hemisphere').value;
await fetch('/api/discovery/hemisphere', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hemisphere })
});
} catch (error) {
console.error('Error saving hemisphere setting:', error);
}
if (result.success && qualityProfileSaved && lookbackSaved) {
showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success');
_forceServiceStatusRefresh();
_stgRefreshAfterSave();
} else if (result.success && qualityProfileSaved && !lookbackSaved) {
showToast('Settings saved, but discovery lookback period failed to save', 'warning');
_forceServiceStatusRefresh();
_stgRefreshAfterSave();
} else if (result.success && !qualityProfileSaved) {
showToast('Settings saved, but quality profile failed to save', 'warning');
_forceServiceStatusRefresh();
_stgRefreshAfterSave();
} else {
showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services');
}
} catch (error) {
console.error('Error saving settings:', error);
showToast('Failed to save settings', 'error', 'set-services');
} finally {
if (!quiet) hideLoadingOverlay();
}
}
async function authorizeLastfmScrobbling() {
try {
// Save settings first so API secret is stored
await saveSettings();
const resp = await fetch('/api/lastfm/auth-url');
const data = await resp.json();
if (data.success && data.url) {
window.open(data.url, '_blank', 'width=600,height=500');
showToast('Authorize SoulSync in the Last.fm window that opened', 'info');
} else {
showToast(data.error || 'Could not generate auth URL', 'error');
}
} catch (e) {
showToast('Failed to start Last.fm authorization', 'error');
}
}
async function testConnection(service) {
try {
showLoadingOverlay(`Testing ${service} connection...`);
const response = await fetch(API.testConnection, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service })
});
const result = await response.json();
if (result.success) {
// Use backend's message which contains dynamic source name
showToast(result.message || `${service} connection successful`, 'success');
// Load music libraries after successful connection
if (service === 'plex') {
loadPlexMusicLibraries();
} else if (service === 'jellyfin') {
loadJellyfinUsers().then(() => loadJellyfinMusicLibraries());
} else if (service === 'navidrome') {
loadNavidromeMusicFolders();
}
} else {
showToast(`${service} connection failed: ${result.error}`, 'error', 'gs-connecting');
}
} catch (error) {
console.error(`Error testing ${service} connection:`, error);
showToast(`Failed to test ${service} connection`, 'error', 'gs-connecting');
} finally {
hideLoadingOverlay();
}
}
async function clearQuarantine() {
if (!await showConfirmDialog({ title: 'Clear Quarantine', message: 'Delete all files in the quarantine folder? This cannot be undone.', confirmText: 'Delete', destructive: true })) return;
try {
showLoadingOverlay('Clearing quarantine folder...');
const response = await fetch('/api/quarantine/clear', { method: 'POST' });
const result = await response.json();
if (result.success) {
showToast(result.message || 'Quarantine cleared', 'success');
} else {
showToast(`Failed to clear quarantine: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error clearing quarantine:', error);
showToast('Failed to clear quarantine', 'error');
} finally {
hideLoadingOverlay();
}
}
// ======================== API Key Management ========================
async function loadApiKeys() {
const container = document.getElementById('api-keys-list');
if (!container) return;
try {
const response = await fetch('/api/v1/api-keys-internal');
if (response.ok) {
const data = await response.json();
renderApiKeys(data.data?.keys || []);
} else {
container.innerHTML = '<div style="color: #666; font-size: 13px;">No API keys configured.</div>';
}
} catch (e) {
container.innerHTML = '<div style="color: #666; font-size: 13px;">No API keys configured.</div>';
}
}
function renderApiKeys(keys) {
const container = document.getElementById('api-keys-list');
if (!container) return;
if (!keys || keys.length === 0) {
container.innerHTML = '<div style="color: #666; font-size: 13px; padding: 4px 0;">No API keys yet. Generate one below.</div>';
return;
}
container.innerHTML = keys.map(k => `
<div style="display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; margin-bottom: 4px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px;">
<div style="flex: 1; min-width: 0;">
<div style="font-size: 13px; color: #e0e0e0; font-weight: 500;">${escapeHtml(k.label || 'Unnamed')}</div>
<div style="font-size: 11px; color: #666; margin-top: 2px;">
<code>${escapeHtml(k.key_prefix || 'sk_...')}...</code>
&middot; Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'}
${k.last_used_at ? '&middot; Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''}
</div>
</div>
<button class="revoke-api-key-btn" data-key-id="${escapeHtml(k.id)}" data-key-label="${escapeHtml(k.label || 'this key')}"
style="padding: 4px 10px; background: rgba(255,82,82,0.1); border: 1px solid rgba(255,82,82,0.2); color: #ff5252; border-radius: 4px; cursor: pointer; font-size: 11px; white-space: nowrap;">
Revoke
</button>
</div>
`).join('');
_initApiKeyClickHandler();
}
function _initApiKeyClickHandler() {
const container = document.getElementById('api-keys-list');
if (!container) return;
container.onclick = (e) => {
const btn = e.target.closest('.revoke-api-key-btn');
if (btn) {
e.preventDefault();
revokeApiKey(btn.dataset.keyId, btn.dataset.keyLabel);
}
};
}
async function generateApiKey() {
const labelInput = document.getElementById('api-key-label');
const label = labelInput ? labelInput.value.trim() : '';
try {
const response = await fetch('/api/v1/api-keys-internal/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: label || 'Default' })
});
const data = await response.json();
if (data.success && data.data?.key) {
const keyDisplay = document.getElementById('api-key-generated');
const keyValue = document.getElementById('api-key-value');
if (keyDisplay && keyValue) {
keyValue.textContent = data.data.key;
keyDisplay.style.display = 'block';
}
if (labelInput) labelInput.value = '';
showToast('API key generated! Copy it now.', 'success');
loadApiKeys();
} else {
showToast(data.error?.message || 'Failed to generate API key', 'error');
}
} catch (error) {
console.error('Error generating API key:', error);
showToast('Failed to generate API key', 'error');
}
}
function copyApiKey() {
const keyValue = document.getElementById('api-key-value');
if (keyValue) {
navigator.clipboard.writeText(keyValue.textContent).then(() => {
showToast('API key copied to clipboard', 'success');
}).catch(() => {
// Fallback for older browsers
const range = document.createRange();
range.selectNode(keyValue);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand('copy');
showToast('API key copied', 'success');
});
}
}
async function revokeApiKey(keyId, label) {
if (!await showConfirmDialog({ title: 'Revoke API Key', message: `Revoke API key "${label}"? Any apps using this key will stop working.`, confirmText: 'Revoke', destructive: true })) return;
try {
const response = await fetch(`/api/v1/api-keys-internal/revoke/${keyId}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showToast('API key revoked', 'success');
loadApiKeys();
} else {
showToast(data.error?.message || 'Failed to revoke key', 'error');
}
} catch (error) {
console.error('Error revoking API key:', error);
showToast('Failed to revoke key', 'error');
}
}
// Dashboard-specific test functions that create activity items
async function testDashboardConnection(service) {
try {
showLoadingOverlay(`Testing ${service} service...`);
const response = await fetch(API.testDashboardConnection, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service })
});
const result = await response.json();
if (result.success) {
// Use backend's message which contains dynamic source name
showToast(result.message || `${service} service verified`, 'success');
// Refresh status indicators immediately so UI reflects the new state
fetchAndUpdateServiceStatus();
} else {
showToast(`${service} service check failed: ${result.error}`, 'error');
}
} catch (error) {
console.error(`Error testing ${service} service:`, error);
showToast(`Failed to test ${service} service`, 'error');
} finally {
hideLoadingOverlay();
}
}
// Individual Auto-detect functions - same as GUI
async function autoDetectPlex() {
try {
showLoadingOverlay('Auto-detecting Plex server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'plex' })
});
const result = await response.json();
if (result.success) {
document.getElementById('plex-url').value = result.found_url;
showToast(`Plex server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Plex:', error);
showToast('Failed to auto-detect Plex server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectJellyfin() {
try {
showLoadingOverlay('Auto-detecting Jellyfin server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'jellyfin' })
});
const result = await response.json();
if (result.success) {
document.getElementById('jellyfin-url').value = result.found_url;
showToast(`Jellyfin server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Jellyfin:', error);
showToast('Failed to auto-detect Jellyfin server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectNavidrome() {
try {
showLoadingOverlay('Auto-detecting Navidrome server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'navidrome' })
});
const result = await response.json();
if (result.success) {
document.getElementById('navidrome-url').value = result.found_url;
showToast(`Navidrome server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Navidrome:', error);
showToast('Failed to auto-detect Navidrome server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectSlskd() {
try {
showLoadingOverlay('Auto-detecting Soulseek (slskd) server...');
const response = await fetch('/api/detect-soulseek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
document.getElementById('soulseek-url').value = result.found_url;
showToast(`Soulseek server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Soulseek:', error);
showToast('Failed to auto-detect Soulseek server', 'error');
} finally {
hideLoadingOverlay();
}
}
function cancelDetection(service) {
const progressDiv = document.getElementById(`${service}-detection-progress`);
progressDiv.classList.add('hidden');
showToast(`${service} detection cancelled`, 'error');
}
function updateStatusDisplays() {
// Update status displays based on current service status
// This would be called after status updates
const services = ['spotify', 'media-server', 'soulseek'];
services.forEach(service => {
const display = document.getElementById(`${service}-status-display`);
if (display) {
// Status will be updated by the regular status monitoring
}
});
}
async function authenticateSpotify() {
try {
showLoadingOverlay('Saving credentials and starting Spotify authentication...');
// Save settings first to ensure client_id/client_secret are persisted
await saveSettings();
showToast('Spotify authentication started', 'success');
window._spotifyAuthWindow = window.open('/auth/spotify', '_blank');
} catch (error) {
console.error('Error authenticating Spotify:', error);
showToast('Failed to start Spotify authentication', 'error', 'gs-connecting');
} finally {
hideLoadingOverlay();
}
}
async function disconnectSpotify() {
if (!await showConfirmDialog({
title: 'Disconnect Spotify',
message: 'Disconnect Spotify? Spotify-specific actions will stop until you reauthenticate.'
})) {
return;
}
try {
showLoadingOverlay('Disconnecting Spotify...');
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast(data.message || 'Spotify disconnected.', 'success');
syncMetadataSourceSelection(data.source || 'deezer');
// Immediately refresh status to update UI
await fetchAndUpdateServiceStatus();
} else {
showToast(`Failed to disconnect: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error disconnecting Spotify:', error);
showToast('Failed to disconnect Spotify', 'error');
} finally {
hideLoadingOverlay();
}
}
// ── Spotify Rate Limit Handling ───────────────────────────────────────────
let _spotifyRateLimitShown = false;
let _spotifyInCooldown = false;
let _rateLimitModalOpen = false;
let _rateLimitCountdownInterval = null;
let _rateLimitExpiresAt = 0;
function handleSpotifyRateLimit(rateLimitInfo) {
if (!rateLimitInfo || !rateLimitInfo.active) {
if (_spotifyRateLimitShown) {
_spotifyRateLimitShown = false;
closeRateLimitModal();
showToast('Spotify access restored', 'success');
// Refresh discover page if user is on it — data source switched back to Spotify
if (currentPage === 'discover') {
console.log('Spotify restored — refreshing discover page data');
loadDiscoverPage();
}
}
return;
}
// Update countdown if modal is open (status pushes every 10s keep it accurate)
if (_rateLimitModalOpen && rateLimitInfo.remaining_seconds) {
_rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000);
}
if (!_spotifyRateLimitShown) {
_spotifyRateLimitShown = true;
_spotifyInCooldown = false;
showRateLimitModal(rateLimitInfo);
// Refresh discover page if user is on it — data source switched to iTunes
if (currentPage === 'discover') {
console.log('Spotify rate limited — refreshing discover page with iTunes data');
loadDiscoverPage();
}
}
}
function showRateLimitModal(rateLimitInfo) {
const overlay = document.getElementById('rate-limit-modal-overlay');
if (!overlay) return;
// Populate details
const banDuration = document.getElementById('rate-limit-ban-duration');
const endpoint = document.getElementById('rate-limit-endpoint');
const countdown = document.getElementById('rate-limit-countdown');
banDuration.textContent = formatRateLimitDuration(rateLimitInfo.retry_after || rateLimitInfo.remaining_seconds);
endpoint.textContent = rateLimitInfo.endpoint || 'unknown';
countdown.textContent = formatRateLimitDuration(rateLimitInfo.remaining_seconds);
// Set expiry for live countdown
_rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000);
// Start live countdown timer
if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = setInterval(() => {
const remaining = Math.max(0, Math.round((_rateLimitExpiresAt - Date.now()) / 1000));
countdown.textContent = formatRateLimitDuration(remaining);
if (remaining <= 0) {
clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = null;
}
}, 1000);
overlay.classList.remove('hidden');
_rateLimitModalOpen = true;
}
function closeRateLimitModal() {
const overlay = document.getElementById('rate-limit-modal-overlay');
if (overlay) overlay.classList.add('hidden');
if (_rateLimitCountdownInterval) {
clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = null;
}
_rateLimitModalOpen = false;
}
async function disconnectSpotifyFromRateLimit() {
closeRateLimitModal();
try {
showLoadingOverlay('Disconnecting Spotify...');
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
_spotifyRateLimitShown = false;
showToast(data.message || 'Spotify disconnected.', 'success');
syncMetadataSourceSelection(data.source || 'deezer');
await fetchAndUpdateServiceStatus();
if (currentPage === 'discover') {
loadDiscoverPage();
}
} else {
showToast(`Failed to disconnect: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error disconnecting Spotify:', error);
showToast('Failed to disconnect Spotify', 'error');
} finally {
hideLoadingOverlay();
}
}
function formatRateLimitDuration(seconds) {
if (!seconds || seconds <= 0) return '0s';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
async function authenticateTidal() {
try {
showLoadingOverlay('Saving credentials and starting Tidal authentication...');
// Save settings first to ensure credentials are persisted
await saveSettings();
showToast('Tidal authentication started', 'success');
window.open('/auth/tidal', '_blank');
} catch (error) {
console.error('Error authenticating Tidal:', error);
showToast('Failed to start Tidal authentication', 'error');
} finally {
hideLoadingOverlay();
}
}
async function disconnectTidal() {
// Clear saved Tidal token. Use when re-authentication doesn't pick
// up newly-added scopes (existing token predates a scope expansion
// and `prompt=consent` alone isn't forcing fresh consent on this
// user's auth flow). After disconnect, click Authenticate again
// for a clean grant.
if (!confirm('Disconnect Tidal? Saved token will be cleared and you\'ll need to re-authenticate.')) {
return;
}
try {
showLoadingOverlay('Disconnecting Tidal...');
const resp = await fetch('/api/tidal/disconnect', { method: 'POST' });
const data = await resp.json();
if (resp.ok && data.success) {
showToast('Tidal disconnected. Click Authenticate to reconnect with current scopes.', 'success');
} else {
showToast(`Disconnect failed: ${data.error || 'unknown error'}`, 'error');
}
} catch (error) {
console.error('Error disconnecting Tidal:', error);
showToast('Failed to disconnect Tidal', 'error');
} finally {
hideLoadingOverlay();
}
}
async function authenticateDeezer() {
try {
showLoadingOverlay('Saving credentials and starting Deezer authentication...');
await saveSettings();
showToast('Deezer authentication started', 'success');
window.open('/auth/deezer', '_blank');
} catch (error) {
console.error('Error authenticating Deezer:', error);
showToast('Failed to start Deezer authentication', 'error');
} finally {
hideLoadingOverlay();
}
}
// ===== Tidal Download Auth (Device Flow) =====
async function testHiFiConnection() {
const statusEl = document.getElementById('hifi-connection-status');
const btn = document.getElementById('hifi-test-btn');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
const resp = await fetch('/api/hifi/status');
const data = await resp.json();
if (data.available) {
statusEl.textContent = `Connected (v${data.version || '?'})`;
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = 'No instances reachable';
statusEl.style.color = '#ff9800';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function testSoundcloudConnection() {
const statusEl = document.getElementById('soundcloud-connection-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
const resp = await fetch('/api/soundcloud/status');
const data = await resp.json();
if (data.available && data.reachable) {
statusEl.textContent = 'Connected (anonymous)';
statusEl.style.color = '#4caf50';
} else if (data.available) {
// Client up but the live probe failed — likely a SoundCloud
// outage or a transient yt-dlp parse error. Surface plainly.
statusEl.textContent = 'Reachable check failed — try again';
statusEl.style.color = '#ff9800';
} else {
statusEl.textContent = data.error || 'Unavailable';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function testLidarrConnection() {
const statusEl = document.getElementById('lidarr-connection-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
// Save settings first so the backend has the URL/key
await saveSettings();
const resp = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'lidarr' })
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = 'Connected';
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = data.error || 'Connection failed';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function loadHiFiInstances() {
const listEl = document.getElementById('hifi-instances-list');
if (!listEl) return;
try {
const resp = await fetch('/api/hifi/instances/list');
const data = await resp.json();
if (!data.instances || data.instances.length === 0) {
listEl.innerHTML = '<div style="color: rgba(255,255,255,0.4); font-size: 0.85em;">No instances configured.</div>';
return;
}
listEl.innerHTML = data.instances.map((inst, i) => {
const enabledClass = inst.enabled ? '' : 'hifi-instance-disabled';
const checkHtml = inst.enabled
? `<span class="hifi-instance-toggle on" data-url="${escapeHtml(inst.url)}" title="Click to disable">&#x2714;</span>`
: `<span class="hifi-instance-toggle off" data-url="${escapeHtml(inst.url)}" title="Click to enable">&#x2718;</span>`;
return `<div class="hifi-instance-item${inst.enabled ? '' : ' disabled'}" draggable="true" data-url="${escapeHtml(inst.url)}">
<span class="hifi-instance-grip">&#x2630;</span>
<span class="hifi-instance-url">${escapeHtml(inst.url)}</span>
${checkHtml}
<span class="hifi-instance-remove" data-url="${escapeHtml(inst.url)}" title="Remove instance">&#x2716;</span>
</div>`;
}).join('');
_initHiFiDragDrop();
_initHiFiClickHandlers();
} catch (e) {
listEl.innerHTML = `<div style="color:#f44336;font-size:0.85em;">Error loading instances: ${escapeHtml(e.message)}</div>`;
}
}
function _initHiFiDragDrop() {
const listEl = document.getElementById('hifi-instances-list');
if (!listEl) return;
let dragIdx = null;
listEl.querySelectorAll('.hifi-instance-item').forEach((item, idx) => {
item.addEventListener('dragstart', (e) => {
dragIdx = idx;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
dragIdx = null;
listEl.querySelectorAll('.hifi-instance-item').forEach(i => i.classList.remove('drag-over'));
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
item.classList.add('drag-over');
});
item.addEventListener('dragleave', () => {
item.classList.remove('drag-over');
});
item.addEventListener('drop', async (e) => {
e.preventDefault();
item.classList.remove('drag-over');
if (dragIdx === null) return;
const items = [...listEl.querySelectorAll('.hifi-instance-item')];
const dragged = items[dragIdx];
if (dragIdx !== idx) {
if (dragIdx < idx) {
item.after(dragged);
} else {
item.before(dragged);
}
const urls = [...listEl.querySelectorAll('.hifi-instance-item')].map(el => el.dataset.url);
await _saveHiFiInstanceOrder(urls);
}
});
});
}
function _initHiFiClickHandlers() {
const listEl = document.getElementById('hifi-instances-list');
if (!listEl) return;
listEl.onclick = (e) => {
const toggle = e.target.closest('.hifi-instance-toggle');
if (toggle) {
e.preventDefault();
toggleHiFiInstance(toggle.dataset.url);
return;
}
const remove = e.target.closest('.hifi-instance-remove');
if (remove) {
e.preventDefault();
removeHiFiInstance(remove.dataset.url);
}
};
}
async function _saveHiFiInstanceOrder(urls) {
try {
await fetch('/api/hifi/instances/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls })
});
} catch (e) {
console.error('Failed to save HiFi instance order:', e);
}
}
async function toggleHiFiInstance(url) {
const listEl = document.getElementById('hifi-instances-list');
if (!listEl) return;
const item = listEl.querySelector(`.hifi-instance-item[data-url="${url}"]`);
const toggle = item?.querySelector('.hifi-instance-toggle');
const currentlyEnabled = toggle?.classList.contains('on');
const newEnabled = !currentlyEnabled;
try {
const resp = await fetch('/api/hifi/instances/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, enabled: newEnabled })
});
const data = await resp.json();
if (data.success) {
loadHiFiInstances();
} else {
alert(data.error || 'Failed to toggle instance');
}
} catch (e) {
alert(`Error: ${e.message}`);
}
}
async function addHiFiInstance() {
const input = document.getElementById('hifi-new-instance');
if (!input) return;
const url = input.value.trim();
if (!url) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
alert('URL must start with http:// or https://');
return;
}
try {
const resp = await fetch('/api/hifi/instances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await resp.json();
if (data.success) {
input.value = '';
loadHiFiInstances();
} else {
alert(data.error || 'Failed to add instance');
}
} catch (e) {
alert(`Error: ${e.message}`);
}
}
async function removeHiFiInstance(url) {
try {
const resp = await fetch(`/api/hifi/instances?url=${encodeURIComponent(url)}`, {
method: 'DELETE'
});
const data = await resp.json();
if (data.success) {
loadHiFiInstances();
} else {
alert(data.error || 'Failed to remove instance');
}
} catch (e) {
alert(`Error: ${e.message}`);
}
}
async function checkHiFiInstances() {
const panel = document.getElementById('hifi-instances-status-panel');
const btn = document.getElementById('hifi-instances-check-btn');
if (!panel) return;
panel.style.display = 'block';
panel.innerHTML = '<div style="color: rgba(255,255,255,0.4); font-size: 0.85em; padding: 8px 0;">Checking instances...</div>';
if (btn) { btn.disabled = true; btn.textContent = 'Checking...'; }
try {
const resp = await fetch('/api/hifi/instances');
const data = await resp.json();
if (!data.instances || data.instances.length === 0) {
panel.innerHTML = '<div style="color: #ff9800; font-size: 0.85em;">No instances configured.</div>';
return;
}
const _statusIcon = (inst) => {
if (inst.can_download) return '<span style="color:#4caf50">● Download</span>';
if (inst.can_search) return '<span style="color:#ff9800">● Search only</span>';
if (inst.status === 'online') return '<span style="color:#ff9800">● Online (limited)</span>';
if (inst.status === 'ssl_error') return '<span style="color:#f44336">● SSL error</span>';
if (inst.status === 'timeout') return '<span style="color:#f44336">● Timeout</span>';
if (inst.status === 'offline') return '<span style="color:#f44336">● Offline</span>';
return `<span style="color:#f44336">● ${escapeHtml(inst.status)}</span>`;
};
panel.innerHTML = data.instances.map(inst => {
const isActive = inst.url === data.active;
const ver = inst.version ? ` v${inst.version}` : '';
const activeTag = isActive ? ' <span style="color:rgb(var(--accent-rgb));font-weight:600;font-size:0.75em;">(ACTIVE)</span>' : '';
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-size:0.82em;">
<span style="color:rgba(255,255,255,0.6);font-family:monospace;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(inst.url)}${ver}${activeTag}</span>
<span style="flex-shrink:0;margin-left:12px;">${_statusIcon(inst)}</span>
</div>`;
}).join('');
} catch (e) {
panel.innerHTML = `<div style="color:#f44336;font-size:0.85em;">Error checking instances: ${escapeHtml(e.message)}</div>`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Check All Instances'; }
}
}
async function testDeezerDownloadConnection() {
const statusEl = document.getElementById('deezer-download-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
// Save the ARL first so the backend can use it
const arl = document.getElementById('deezer-download-arl')?.value || '';
if (!arl) {
statusEl.textContent = 'No ARL token provided';
statusEl.style.color = '#ff9800';
return;
}
const resp = await fetch('/api/deezer-download/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ arl }),
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = `Connected as ${data.user || 'Unknown'} (${data.tier || 'Free'})`;
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = data.error || 'Authentication failed';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function checkTidalDownloadAuthStatus() {
const statusEl = document.getElementById('tidal-download-auth-status');
const btn = document.getElementById('tidal-download-auth-btn');
try {
const resp = await fetch('/api/tidal/download/auth/status');
const data = await resp.json();
if (data.authenticated) {
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.textContent = 'Re-link Tidal Account';
} else {
statusEl.textContent = 'Not authenticated';
statusEl.style.color = '#ff9800';
btn.textContent = 'Link Tidal Account';
}
} catch (e) {
statusEl.textContent = '';
}
}
let _tidalAuthPollTimer = null;
async function startTidalDownloadAuth() {
const btn = document.getElementById('tidal-download-auth-btn');
const statusEl = document.getElementById('tidal-download-auth-status');
const codeEl = document.getElementById('tidal-download-auth-code');
btn.disabled = true;
btn.textContent = 'Starting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/tidal/download/auth/start', { method: 'POST' });
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || 'Failed to start auth');
}
// Show the link/code to the user
const uri = data.verification_uri || '';
const code = data.user_code || '';
codeEl.style.display = 'block';
codeEl.innerHTML = `Go to <a href="${uri}" target="_blank" style="color:rgb(var(--accent-rgb));">${uri}</a> and enter code: <strong>${code}</strong>`;
btn.textContent = 'Waiting for approval...';
statusEl.textContent = 'Waiting...';
statusEl.style.color = '#ff9800';
// Poll for completion
if (_tidalAuthPollTimer) clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = setInterval(async () => {
try {
const checkResp = await fetch('/api/tidal/download/auth/check');
const checkData = await checkResp.json();
if (checkData.status === 'completed') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.disabled = false;
btn.textContent = 'Re-link Tidal Account';
showToast('Tidal download account linked successfully', 'success');
} else if (checkData.status === 'error') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Auth failed';
statusEl.style.color = '#f44336';
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
showToast('Tidal auth failed: ' + (checkData.message || 'Unknown error'), 'error');
}
// status === 'pending' — keep polling
} catch (pollErr) {
console.error('Tidal auth poll error:', pollErr);
}
}, 3000);
} catch (error) {
console.error('Tidal download auth error:', error);
showToast('Failed to start Tidal auth: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
codeEl.style.display = 'none';
}
}
// ===============================
// QOBUZ AUTH FUNCTIONS
// ===============================
async function checkQobuzAuthStatus() {
try {
const resp = await fetch('/api/qobuz/auth/status');
const data = await resp.json();
// Update downloads tab section
const formEl = document.getElementById('qobuz-auth-form');
const loggedInEl = document.getElementById('qobuz-auth-logged-in');
const userInfoEl = document.getElementById('qobuz-auth-user-info');
// Update connections tab section
const connFormEl = document.getElementById('qobuz-connection-form');
const connLoggedInEl = document.getElementById('qobuz-connection-logged-in');
const connUserInfoEl = document.getElementById('qobuz-connection-user-info');
if (data.authenticated) {
const user = data.user || {};
const label = `Connected: ${user.display_name || 'Qobuz User'} (${user.subscription || 'Active'})`;
if (userInfoEl) { userInfoEl.textContent = label; }
if (loggedInEl) loggedInEl.style.display = 'flex';
if (formEl) formEl.style.display = 'none';
if (connUserInfoEl) { connUserInfoEl.textContent = label; }
if (connLoggedInEl) connLoggedInEl.style.display = 'flex';
if (connFormEl) connFormEl.style.display = 'none';
} else {
if (loggedInEl) loggedInEl.style.display = 'none';
if (formEl) formEl.style.display = 'block';
if (connLoggedInEl) connLoggedInEl.style.display = 'none';
if (connFormEl) connFormEl.style.display = 'block';
}
} catch (e) {
console.error('Qobuz auth status check failed:', e);
}
}
async function loginQobuzFromConnections() {
const btn = document.getElementById('qobuz-connection-login-btn');
const statusEl = document.getElementById('qobuz-connection-status');
const email = document.getElementById('qobuz-connection-email').value.trim();
const password = document.getElementById('qobuz-connection-password').value;
if (!email || !password) {
showToast('Please enter your Qobuz email and password', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected successfully!', 'success');
document.getElementById('qobuz-connection-password').value = '';
checkQobuzAuthStatus();
} else {
statusEl.textContent = data.error || 'Login failed';
statusEl.style.color = '#ff5555';
showToast(data.error || 'Qobuz login failed', 'error');
}
} catch (error) {
console.error('Qobuz login error:', error);
statusEl.textContent = 'Connection error';
statusEl.style.color = '#ff5555';
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect Qobuz';
}
}
async function loginQobuzWithToken() {
const btn = document.getElementById('qobuz-token-login-btn');
const statusEl = document.getElementById('qobuz-token-status');
const token = document.getElementById('qobuz-connection-token').value.trim();
if (!token) {
showToast('Please paste your Qobuz auth token', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
if (statusEl) statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected via token!', 'success');
document.getElementById('qobuz-connection-token').value = '';
checkQobuzAuthStatus();
} else {
if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; }
showToast(data.error || 'Qobuz token login failed', 'error');
}
} catch (error) {
console.error('Qobuz token login error:', error);
if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; }
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect with Token';
}
}
async function loginQobuzWithTokenFromDownloads() {
const btn = document.getElementById('qobuz-download-token-btn');
const statusEl = document.getElementById('qobuz-download-token-status');
const token = document.getElementById('qobuz-download-token').value.trim();
if (!token) {
showToast('Please paste your Qobuz auth token', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
if (statusEl) statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected via token!', 'success');
document.getElementById('qobuz-download-token').value = '';
checkQobuzAuthStatus();
} else {
if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; }
showToast(data.error || 'Qobuz token login failed', 'error');
}
} catch (error) {
console.error('Qobuz token login error:', error);
if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; }
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect with Token';
}
}
async function loginQobuz() {
const btn = document.getElementById('qobuz-login-btn');
const statusEl = document.getElementById('qobuz-auth-status');
const email = document.getElementById('qobuz-email').value.trim();
const password = document.getElementById('qobuz-password').value;
if (!email || !password) {
showToast('Please enter your Qobuz email and password', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected successfully!', 'success');
// Clear password field
document.getElementById('qobuz-password').value = '';
checkQobuzAuthStatus();
} else {
statusEl.textContent = data.error || 'Login failed';
statusEl.style.color = '#ff5555';
showToast(data.error || 'Qobuz login failed', 'error');
}
} catch (error) {
console.error('Qobuz login error:', error);
statusEl.textContent = 'Connection error';
statusEl.style.color = '#ff5555';
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect Qobuz';
}
}
async function logoutQobuz() {
try {
await fetch('/api/qobuz/auth/logout', { method: 'POST' });
showToast('Qobuz disconnected', 'success');
checkQobuzAuthStatus();
} catch (e) {
console.error('Qobuz logout error:', e);
}
}
const PATH_INPUT_IDS = {
download: 'download-path',
transfer: 'transfer-path',
staging: 'staging-path',
'music-videos': 'music-videos-path',
'm3u-entry-base': 'm3u-entry-base-path'
};
function togglePathLock(pathType, btn) {
const input = document.getElementById(PATH_INPUT_IDS[pathType]);
if (!input) return;
const isLocked = input.hasAttribute('readonly');
if (isLocked) {
input.removeAttribute('readonly');
input.focus();
btn.textContent = 'Lock';
btn.classList.remove('locked');
} else {
input.setAttribute('readonly', '');
btn.textContent = 'Unlock';
btn.classList.add('locked');
}
}
// ===============================