// UI updates and DOM manipulation import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js"; let rowMenuHandlersBound = false; let tableRowHandlersBound = false; const expandedInjectedPlaylistDetails = new Set(); function bindRowMenuHandlers() { if (rowMenuHandlersBound) { return; } document.addEventListener("click", () => { closeAllRowMenus(); }); rowMenuHandlersBound = true; } function bindTableRowHandlers() { if (tableRowHandlersBound) { return; } document.addEventListener("click", (event) => { const detailsTrigger = event.target.closest?.( "button.details-trigger[data-details-target]", ); if (detailsTrigger) { const target = detailsTrigger.getAttribute("data-details-target"); if (target) { toggleDetailsRow(event, target); } return; } const row = event.target.closest?.("tr.compact-row[data-details-row]"); if (!row) { return; } if (event.target.closest("button, a, .row-actions-menu")) { return; } const detailsRowId = row.getAttribute("data-details-row"); if (detailsRowId) { toggleDetailsRow(null, detailsRowId); } }); tableRowHandlersBound = true; } function closeAllRowMenus(exceptId = null) { document.querySelectorAll(".row-actions-menu.open").forEach((menu) => { if (!exceptId || menu.id !== exceptId) { menu.classList.remove("open"); } }); } function closeRowMenu(event, menuId) { if (event) { event.stopPropagation(); } const menu = document.getElementById(menuId); if (menu) { menu.classList.remove("open"); } } function toggleRowMenu(event, menuId) { if (event) { event.stopPropagation(); } const menu = document.getElementById(menuId); if (!menu) { return; } const isOpen = menu.classList.contains("open"); closeAllRowMenus(menuId); menu.classList.toggle("open", !isOpen); } function toggleDetailsRow(event, detailsRowId) { if (event) { event.stopPropagation(); } const detailsRow = document.getElementById(detailsRowId); if (!detailsRow) { return; } const isHidden = detailsRow.hasAttribute("hidden"); if (isHidden) { detailsRow.removeAttribute("hidden"); } else { detailsRow.setAttribute("hidden", ""); } const isExpanded = isHidden; document .querySelectorAll(`[data-details-target="${detailsRowId}"]`) .forEach((trigger) => { trigger.setAttribute("aria-expanded", String(isExpanded)); if (trigger.classList.contains("details-trigger")) { trigger.textContent = isExpanded ? "Hide" : "Details"; } }); const parentRow = document.querySelector( `tr[data-details-row="${detailsRowId}"]`, ); if (parentRow) { parentRow.classList.toggle("expanded", isExpanded); // Persist Injected Playlists details expansion across auto-refreshes. if (parentRow.closest("#playlist-table-body")) { const detailsKey = parentRow.getAttribute("data-details-key"); if (detailsKey) { if (isExpanded) { expandedInjectedPlaylistDetails.add(detailsKey); } else { expandedInjectedPlaylistDetails.delete(detailsKey); } } } } } function onCompactRowClick(event, detailsRowId) { if (event.target.closest("button, a, .row-actions-menu")) { return; } toggleDetailsRow(null, detailsRowId); } function renderGuidance(containerId, entries) { const container = document.getElementById(containerId); if (!container) { return; } if (!entries || entries.length === 0) { container.innerHTML = ""; return; } container.innerHTML = entries .map((entry) => { const tone = entry.tone === "warning" ? "warning" : entry.tone === "success" ? "success" : "info"; const defaultIcon = tone === "warning" ? "⚠️" : tone === "success" ? "✔" : "ℹ️"; const icon = escapeHtml(entry.icon || defaultIcon); const title = escapeHtml(entry.title || ""); const detail = entry.detail ? `
${escapeHtml(entry.detail)}
` : ""; return `
${icon}
${title}
${detail}
`; }) .join(""); } function getPlaylistStatusSummary(playlist) { const spotifyTotal = playlist.trackCount || 0; const localCount = playlist.localTracks || 0; const externalMatched = playlist.externalMatched || 0; const externalMissing = playlist.externalMissing || 0; const totalPlayable = playlist.totalPlayable || localCount + externalMatched; const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0; let statusClass = "info"; let statusLabel = "In Progress"; if (spotifyTotal === 0) { statusClass = "neutral"; statusLabel = "No Tracks"; } else if (externalMissing > 0) { statusClass = "warning"; statusLabel = `${externalMissing} Missing`; } else if (completionPct >= 100) { statusClass = "success"; statusLabel = "Complete"; } else { statusClass = "info"; statusLabel = `${completionPct}% Matched`; } const completionClass = completionPct >= 100 ? "success" : externalMissing > 0 ? "warning" : "info"; return { spotifyTotal, localCount, externalMatched, externalMissing, totalPlayable, completionPct, statusClass, statusLabel, completionClass, }; } if (typeof window !== "undefined") { window.toggleRowMenu = toggleRowMenu; window.closeRowMenu = closeRowMenu; window.toggleDetailsRow = toggleDetailsRow; window.onCompactRowClick = onCompactRowClick; } bindRowMenuHandlers(); bindTableRowHandlers(); export function updateStatusUI(data) { const versionEl = document.getElementById("version"); if (versionEl) versionEl.textContent = "v" + data.version; const sidebarVersionEl = document.getElementById("sidebar-version"); if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version; const backendTypeEl = document.getElementById("backend-type"); if (backendTypeEl) backendTypeEl.textContent = data.backendType; const jellyfinUrlEl = document.getElementById("jellyfin-url"); if (jellyfinUrlEl) jellyfinUrlEl.textContent = data.jellyfinUrl || "-"; const playlistCountEl = document.getElementById("playlist-count"); if (playlistCountEl) { playlistCountEl.textContent = data.spotifyImport.playlistCount; } const cacheDurationEl = document.getElementById("cache-duration"); if (cacheDurationEl) { cacheDurationEl.textContent = data.spotify.cacheDurationMinutes + " min"; } const isrcMatchingEl = document.getElementById("isrc-matching"); if (isrcMatchingEl) { isrcMatchingEl.textContent = data.spotify.preferIsrcMatching ? "Enabled" : "Disabled"; } const spotifyUserEl = document.getElementById("spotify-user"); if (spotifyUserEl) spotifyUserEl.textContent = data.spotify.user || "-"; const statusBadge = document.getElementById("spotify-status"); const authStatus = document.getElementById("spotify-auth-status"); const guidance = []; if (data.spotify.authStatus === "configured") { if (statusBadge) { statusBadge.className = "status-badge success"; statusBadge.innerHTML = 'Spotify Ready'; } if (authStatus) { authStatus.textContent = "Cookie Set"; authStatus.className = "stat-value success"; } guidance.push({ tone: "success", title: "Spotify is connected and ready.", detail: "Use Rebuild only when Spotify playlist content changes.", }); } else if (data.spotify.authStatus === "missing_cookie") { if (statusBadge) { statusBadge.className = "status-badge warning"; statusBadge.innerHTML = 'Cookie Missing'; } if (authStatus) { authStatus.textContent = "No Cookie"; authStatus.className = "stat-value warning"; } guidance.push({ tone: "warning", title: "Spotify session cookie is missing.", detail: "Open Configuration > Spotify API Settings and add sp_dc.", }); } else { if (statusBadge) { statusBadge.className = "status-badge info"; statusBadge.innerHTML = 'Not Configured'; } if (authStatus) { authStatus.textContent = "Not Configured"; authStatus.className = "stat-value info"; } guidance.push({ tone: "info", title: "Spotify is not configured yet.", detail: "Enable Spotify API and set a valid session cookie to link playlists.", }); } renderGuidance("dashboard-guidance", guidance); } export function updatePlaylistsUI(data) { const tbody = document.getElementById("playlist-table-body"); const playlists = data.playlists || []; if (playlists.length === 0) { expandedInjectedPlaylistDetails.clear(); tbody.innerHTML = 'No playlists configured. Link playlists from the Link Playlists tab.'; renderGuidance("playlists-guidance", [ { tone: "info", title: "No injected playlists yet.", detail: "Go to Link Playlists and connect a Jellyfin playlist to Spotify.", }, ]); return; } const missingTotal = playlists.reduce( (total, playlist) => total + (playlist.externalMissing || 0), 0, ); const incompleteCount = playlists.reduce((total, playlist) => { const summary = getPlaylistStatusSummary(playlist); return total + (summary.completionPct < 100 ? 1 : 0); }, 0); const guidance = []; if (missingTotal > 0) { const playlistsWithMissing = playlists.filter( (playlist) => (playlist.externalMissing || 0) > 0, ).length; guidance.push({ tone: "warning", title: `${missingTotal} tracks still need attention across ${playlistsWithMissing} playlists.`, detail: "Open a row and use ... > Rematch, then map any tracks that still cannot be matched.", }); } else if (incompleteCount > 0) { guidance.push({ tone: "info", title: `${incompleteCount} playlists are still syncing.`, detail: "Use Rematch when your local library changed.", }); } else { guidance.push({ tone: "success", title: "All injected playlists are fully matched.", detail: "No action needed right now.", }); } guidance.push({ tone: "info", title: "Use Rebuild only when Spotify playlist content changed.", detail: "Use Rematch when your local library changed.", }); renderGuidance("playlists-guidance", guidance); tbody.innerHTML = playlists .map((playlist, index) => { const summary = getPlaylistStatusSummary(playlist); const detailsRowId = `playlist-details-${index}`; const menuId = `playlist-menu-${index}`; const detailsKey = `${playlist.id || playlist.name || index}`; const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey); const syncSchedule = playlist.syncSchedule || "0 8 * * *"; const escapedPlaylistName = escapeHtml(playlist.name); const escapedSyncSchedule = escapeHtml(syncSchedule); const escapedDetailsKey = escapeHtml(detailsKey); const breakdownBadges = [ `${summary.localCount} Local`, `${summary.externalMatched} External`, ]; if (summary.externalMissing > 0) { breakdownBadges.push( `${summary.externalMissing} Missing`, ); } return `
${escapeHtml(playlist.name)} ${escapeHtml(playlist.id || "-")}
${summary.totalPlayable}/${summary.spotifyTotal}
${summary.completionPct}% playable
${summary.statusLabel}
Sync Schedule ${escapeHtml(syncSchedule)}
Cache Age ${escapeHtml(playlist.cacheAge || "-")}
Track Breakdown ${breakdownBadges.join(" ")}
Completion
`; }) .join(""); } export function updateTrackMappingsUI(data) { document.getElementById("mappings-total").textContent = data.externalCount || 0; document.getElementById("mappings-external").textContent = data.externalCount || 0; const tbody = document.getElementById("mappings-table-body"); if (data.mappings.length === 0) { tbody.innerHTML = 'No manual mappings found.'; return; } const externalMappings = data.mappings.filter((m) => m.type === "external"); if (externalMappings.length === 0) { tbody.innerHTML = 'No external mappings found.'; return; } tbody.innerHTML = externalMappings .map((m) => { const typeColor = "var(--success)"; const typeBadge = `external`; const targetDisplay = `${m.externalProvider}/${m.externalId}`; const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : "-"; return ` ${escapeHtml(m.playlist)} ${m.spotifyId} ${typeBadge} ${targetDisplay} ${createdDate} `; }) .join(""); } export function updateDownloadsUI(data) { const tbody = document.getElementById("downloads-table-body"); document.getElementById("downloads-count").textContent = data.count; document.getElementById("downloads-size").textContent = data.totalSizeFormatted; if (data.count === 0) { tbody.innerHTML = 'No downloaded files found.'; return; } tbody.innerHTML = data.files .map((f) => { return ` ${escapeHtml(f.artist)} ${escapeHtml(f.album)} ${escapeHtml(f.fileName)} ${f.sizeFormatted} `; }) .join(""); } export function updateConfigUI(data) { document.getElementById("config-backend-type").textContent = data.backendType || "Jellyfin"; document.getElementById("config-music-service").textContent = data.musicService || "SquidWTF"; document.getElementById("config-storage-mode").textContent = data.library?.storageMode || "Cache"; document.getElementById("config-cache-duration-hours").textContent = data.library?.cacheDurationHours || "24"; document.getElementById("config-download-mode").textContent = data.library?.downloadMode || "Track"; document.getElementById("config-explicit-filter").textContent = data.explicitFilter || "All"; document.getElementById("config-enable-external-playlists").textContent = data.enableExternalPlaylists ? "Yes" : "No"; document.getElementById("config-playlists-directory").textContent = data.playlistsDirectory || "(not set)"; document.getElementById("config-redis-enabled").textContent = data.redisEnabled ? "Yes" : "No"; document.getElementById("config-debug-log-requests").textContent = data.debug ?.logAllRequests ? "Enabled" : "Disabled"; document.getElementById("config-admin-bind-any-ip").textContent = data.admin ?.bindAnyIp ? "Enabled" : "Disabled"; document.getElementById("config-admin-trusted-subnets").textContent = data.admin?.trustedSubnets?.trim() || "(localhost only)"; document.getElementById("config-spotify-enabled").textContent = data .spotifyApi.enabled ? "Yes" : "No"; document.getElementById("config-spotify-cookie").textContent = data.spotifyApi.sessionCookie; document.getElementById("config-cache-duration").textContent = data.spotifyApi.cacheDurationMinutes + " minutes"; document.getElementById("config-isrc-matching").textContent = data.spotifyApi .preferIsrcMatching ? "Enabled" : "Disabled"; document.getElementById("config-deezer-arl").textContent = data.deezer.arl || "(not set)"; document.getElementById("config-deezer-quality").textContent = data.deezer.quality; document.getElementById("config-deezer-ratelimit").textContent = (data.deezer.minRequestIntervalMs || 200) + " ms"; document.getElementById("config-squid-quality").textContent = data.squidWtf.quality; document.getElementById("config-squid-ratelimit").textContent = (data.squidWtf.minRequestIntervalMs || 200) + " ms"; document.getElementById("config-musicbrainz-enabled").textContent = data .musicBrainz.enabled ? "Yes" : "No"; document.getElementById("config-qobuz-token").textContent = data.qobuz.userAuthToken || "(not set)"; document.getElementById("config-qobuz-quality").textContent = data.qobuz.quality || "FLAC"; document.getElementById("config-qobuz-ratelimit").textContent = (data.qobuz.minRequestIntervalMs || 200) + " ms"; document.getElementById("config-jellyfin-url").textContent = data.jellyfin.url || "-"; document.getElementById("config-jellyfin-api-key").textContent = data.jellyfin.apiKey; document.getElementById("config-jellyfin-user-id").textContent = data.jellyfin.userId || "(not set)"; document.getElementById("config-jellyfin-library-id").textContent = data.jellyfin.libraryId || "-"; document.getElementById("config-download-path").textContent = data.library?.downloadPath || "./downloads"; document.getElementById("config-kept-path").textContent = data.library?.keptPath || "/app/kept"; document.getElementById("config-spotify-import-enabled").textContent = data .spotifyImport?.enabled ? "Yes" : "No"; document.getElementById("config-matching-interval").textContent = (data.spotifyImport?.matchingIntervalHours || 24) + " hours"; if (data.cache) { document.getElementById("config-cache-playlist-images").textContent = data.cache.playlistImagesHours || "168"; document.getElementById("config-cache-spotify-items").textContent = data.cache.spotifyPlaylistItemsHours || "168"; document.getElementById("config-cache-matched-tracks").textContent = data.cache.spotifyMatchedTracksDays || "30"; document.getElementById("config-cache-lyrics").textContent = data.cache.lyricsDays || "14"; document.getElementById("config-cache-genres").textContent = data.cache.genreDays || "30"; document.getElementById("config-cache-metadata").textContent = data.cache.metadataDays || "7"; document.getElementById("config-cache-odesli").textContent = data.cache.odesliLookupDays || "60"; document.getElementById("config-cache-proxy-images").textContent = data.cache.proxyImagesDays || "14"; } } export function updateJellyfinPlaylistsUI(data) { const tbody = document.getElementById("jellyfin-playlist-table-body"); const playlists = data.playlists || []; if (playlists.length === 0) { tbody.innerHTML = 'No playlists found in Jellyfin'; renderGuidance("jellyfin-guidance", [ { tone: "info", title: "No Jellyfin playlists found.", detail: "Create playlists in Jellyfin first, then link them here.", }, ]); return; } const unlinkedCount = playlists.filter( (playlist) => !playlist.isConfigured, ).length; renderGuidance( "jellyfin-guidance", unlinkedCount > 0 ? [ { tone: "warning", title: `${unlinkedCount} playlists are not linked to Spotify yet.`, detail: "Open a row, then use ... > Link to Spotify.", }, ] : [ { tone: "success", title: "All visible Jellyfin playlists are linked.", detail: "No linking action needed right now.", }, ], ); tbody.innerHTML = playlists .map((playlist, index) => { const detailsRowId = `jellyfin-details-${index}`; const menuId = `jellyfin-menu-${index}`; const statsPending = Boolean(playlist.statsPending); const localCount = playlist.localTracks || 0; const externalCount = playlist.externalTracks || 0; const externalAvailable = playlist.externalAvailable || 0; const escapedId = escapeHtml(playlist.id); const escapedName = escapeHtml(playlist.name); const statusClass = playlist.isConfigured ? "success" : "info"; const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked"; const actionButtons = playlist.isConfigured ? ` ` : ` `; return `
${escapeHtml(playlist.name)} ${escapeHtml(playlist.id || "-")}
${statsPending ? "..." : localCount + externalAvailable}
${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}
${statusLabel}
Local Tracks ${statsPending ? "..." : localCount}
External Tracks ${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}
Linked Spotify ID ${escapeHtml(playlist.linkedSpotifyId || "-")}
`; }) .join(""); } export function updateJellyfinUsersUI(data, preferredUserId = null) { const select = document.getElementById("jellyfin-user-select"); if (!select) { return; } const normalizedPreferredUserId = preferredUserId?.trim() || ""; select.innerHTML = '' + data.users .map((u) => ``) .join(""); if (normalizedPreferredUserId) { const matchingOption = Array.from(select.options).find( (option) => option.value === normalizedPreferredUserId, ); if (matchingOption) { select.value = normalizedPreferredUserId; return; } } select.value = ""; } export function updateEndpointUsageUI(data) { document.getElementById("endpoints-total-requests").textContent = data.totalRequests?.toLocaleString() || "0"; document.getElementById("endpoints-unique-count").textContent = data.totalEndpoints?.toLocaleString() || "0"; const mostCalled = data.endpoints && data.endpoints.length > 0 ? data.endpoints[0].endpoint : "-"; document.getElementById("endpoints-most-called").textContent = mostCalled; const tbody = document.getElementById("endpoints-table-body"); if (!data.endpoints || data.endpoints.length === 0) { tbody.innerHTML = 'No endpoint usage data available yet.'; return; } tbody.innerHTML = data.endpoints .map((ep, index) => { const percentage = data.totalRequests > 0 ? ((ep.count / data.totalRequests) * 100).toFixed(1) : "0.0"; let countColor = "var(--text-primary)"; if (ep.count > 1000) countColor = "var(--error)"; else if (ep.count > 100) countColor = "var(--warning)"; else if (ep.count > 10) countColor = "var(--accent)"; let endpointDisplay = ep.endpoint; if (ep.endpoint.includes("/stream")) { endpointDisplay = `${escapeHtml(ep.endpoint)}`; } else if (ep.endpoint.includes("/Playing")) { endpointDisplay = `${escapeHtml(ep.endpoint)}`; } else if (ep.endpoint.includes("/Search")) { endpointDisplay = `${escapeHtml(ep.endpoint)}`; } else { endpointDisplay = escapeHtml(ep.endpoint); } return ` ${index + 1} ${endpointDisplay} ${ep.count.toLocaleString()} ${percentage}% `; }) .join(""); } export function showErrorState(message) { const statusBadge = document.getElementById("spotify-status"); if (statusBadge) { statusBadge.className = "status-badge error"; statusBadge.innerHTML = 'Connection Error'; } const authStatus = document.getElementById("spotify-auth-status"); if (authStatus) authStatus.textContent = "Error"; renderGuidance("dashboard-guidance", [ { tone: "warning", title: "Unable to load dashboard status.", detail: "Check connectivity and refresh the page.", }, ]); } export function showPlaylistRebuildingIndicator(playlistName) { const playlistCards = document.querySelectorAll(".playlist-card"); for (const card of playlistCards) { const nameEl = card.querySelector("h3"); if (nameEl && nameEl.textContent.trim() === playlistName) { const existingIndicator = card.querySelector(".rebuilding-indicator"); if (!existingIndicator) { const indicator = document.createElement("div"); indicator.className = "rebuilding-indicator"; indicator.style.cssText = ` position: absolute; top: 8px; right: 8px; background: var(--warning); color: white; padding: 4px 8px; border-radius: 12px; font-size: 0.7rem; font-weight: 500; display: flex; align-items: center; gap: 4px; z-index: 10; `; indicator.innerHTML = 'Rebuilding...'; card.style.position = "relative"; card.appendChild(indicator); setTimeout(() => { indicator.remove(); }, 30000); } break; } } }