// 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 `
`;
})
.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(" ")}
|
`;
})
.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;
}
}
}