mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
896 lines
34 KiB
JavaScript
896 lines
34 KiB
JavaScript
// 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
|
||
? `<div class="guidance-detail">${escapeHtml(entry.detail)}</div>`
|
||
: "";
|
||
|
||
return `
|
||
<div class="guidance-banner ${tone}">
|
||
<span>${icon}</span>
|
||
<div class="guidance-content">
|
||
<div class="guidance-title">${title}</div>
|
||
${detail}
|
||
</div>
|
||
</div>
|
||
`;
|
||
})
|
||
.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 = '<span class="status-dot"></span>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 = '<span class="status-dot"></span>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 = '<span class="status-dot"></span>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 =
|
||
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
|
||
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 = [
|
||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||
`<span class="status-pill info">${summary.externalMatched} External</span>`,
|
||
];
|
||
|
||
if (summary.externalMissing > 0) {
|
||
breakdownBadges.push(
|
||
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
|
||
);
|
||
}
|
||
|
||
return `
|
||
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
||
<td>
|
||
<div class="name-cell">
|
||
<strong>${escapeHtml(playlist.name)}</strong>
|
||
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
|
||
<div class="meta-text">${summary.completionPct}% playable</div>
|
||
</td>
|
||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||
<td class="row-controls">
|
||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
|
||
<div class="row-actions-wrap">
|
||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
||
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
||
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
|
||
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
|
||
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
|
||
<hr>
|
||
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
||
<td colspan="4">
|
||
<div class="details-panel">
|
||
<div class="details-grid">
|
||
<div class="detail-item">
|
||
<span class="detail-label">Sync Schedule</span>
|
||
<span class="detail-value mono">
|
||
${escapeHtml(syncSchedule)}
|
||
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
|
||
</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">Cache Age</span>
|
||
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">Track Breakdown</span>
|
||
<span class="detail-value">${breakdownBadges.join(" ")}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">Completion</span>
|
||
<div class="completion-bar">
|
||
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.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 =
|
||
'<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
const externalMappings = data.mappings.filter((m) => m.type === "external");
|
||
|
||
if (externalMappings.length === 0) {
|
||
tbody.innerHTML =
|
||
'<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = externalMappings
|
||
.map((m) => {
|
||
const typeColor = "var(--success)";
|
||
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
|
||
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
|
||
const createdDate = m.createdAt
|
||
? new Date(m.createdAt).toLocaleString()
|
||
: "-";
|
||
|
||
return `
|
||
<tr>
|
||
<td><strong>${escapeHtml(m.playlist)}</strong></td>
|
||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
|
||
<td>${typeBadge}</td>
|
||
<td>${targetDisplay}</td>
|
||
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
|
||
<td>
|
||
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.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 =
|
||
'<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = data.files
|
||
.map((f) => {
|
||
return `
|
||
<tr data-path="${escapeHtml(f.path)}">
|
||
<td><strong>${escapeHtml(f.artist)}</strong></td>
|
||
<td>${escapeHtml(f.album)}</td>
|
||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||
<td>
|
||
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.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 =
|
||
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
|
||
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
|
||
? `
|
||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||
<button class="danger-item" data-action="unlinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Unlink from Spotify</button>
|
||
`
|
||
: `
|
||
<button data-action="openLinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Link to Spotify</button>
|
||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||
`;
|
||
|
||
return `
|
||
<tr class="compact-row" data-details-row="${detailsRowId}">
|
||
<td>
|
||
<div class="name-cell">
|
||
<strong>${escapeHtml(playlist.name)}</strong>
|
||
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
|
||
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
|
||
</td>
|
||
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
||
<td class="row-controls">
|
||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false">Details</button>
|
||
<div class="row-actions-wrap">
|
||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||
${actionButtons}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr id="${detailsRowId}" class="details-row" hidden>
|
||
<td colspan="4">
|
||
<div class="details-panel">
|
||
<div class="details-grid">
|
||
<div class="detail-item">
|
||
<span class="detail-label">Local Tracks</span>
|
||
<span class="detail-value">${statsPending ? "..." : localCount}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">External Tracks</span>
|
||
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">Linked Spotify ID</span>
|
||
<span class="detail-value mono">${escapeHtml(playlist.linkedSpotifyId || "-")}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
export function updateJellyfinUsersUI(data, preferredUserId = null) {
|
||
const select = document.getElementById("jellyfin-user-select");
|
||
if (!select) {
|
||
return;
|
||
}
|
||
|
||
const normalizedPreferredUserId = preferredUserId?.trim() || "";
|
||
select.innerHTML =
|
||
'<option value="">All Users</option>' +
|
||
data.users
|
||
.map((u) => `<option value="${u.id}">${escapeHtml(u.name)}</option>`)
|
||
.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 =
|
||
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet.</td></tr>';
|
||
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 = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
|
||
} else if (ep.endpoint.includes("/Playing")) {
|
||
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
|
||
} else if (ep.endpoint.includes("/Search")) {
|
||
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
|
||
} else {
|
||
endpointDisplay = escapeHtml(ep.endpoint);
|
||
}
|
||
|
||
return `
|
||
<tr>
|
||
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
|
||
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
|
||
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
|
||
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
export function showErrorState(message) {
|
||
const statusBadge = document.getElementById("spotify-status");
|
||
if (statusBadge) {
|
||
statusBadge.className = "status-badge error";
|
||
statusBadge.innerHTML = '<span class="status-dot"></span>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 =
|
||
'<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
|
||
card.style.position = "relative";
|
||
card.appendChild(indicator);
|
||
|
||
setTimeout(() => {
|
||
indicator.remove();
|
||
}, 30000);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|