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.
4091 lines
173 KiB
JavaScript
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">⚠</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);">×</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);">×</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)}">×</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>
|
|
· Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'}
|
|
${k.last_used_at ? '· 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">✔</span>`
|
|
: `<span class="hifi-instance-toggle off" data-url="${escapeHtml(inst.url)}" title="Click to enable">✘</span>`;
|
|
return `<div class="hifi-instance-item${inst.enabled ? '' : ' disabled'}" draggable="true" data-url="${escapeHtml(inst.url)}">
|
|
<span class="hifi-instance-grip">☰</span>
|
|
<span class="hifi-instance-url">${escapeHtml(inst.url)}</span>
|
|
${checkHtml}
|
|
<span class="hifi-instance-remove" data-url="${escapeHtml(inst.url)}" title="Remove instance">✖</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');
|
|
}
|
|
}
|
|
|
|
|
|
// ===============================
|