(function () { if (typeof window === "undefined") { return; } var body = document.body; if (!body) { return; } var dashboardState = { taskRows: [], expandedTaskIds: {}, detailCache: {}, expandedPlaylistKeys: {}, playlistSongCache: {}, playlistSongsInFlight: {}, refreshTimer: null, taskRowsRefreshTimer: null, refreshInFlight: false, }; var TASK_ROWS_REFRESH_INTERVAL_MS = 1000; var DONE_TASK_STATUSES = { completed: true, completed_with_errors: true, failed: true, canceled: true, }; function splitCsv(value) { if (!value) { return []; } return String(value) .split(",") .map(function (part) { return part.trim(); }) .filter(Boolean); } function formToJson(form) { var payload = {}; var formData = new window.FormData(form); formData.forEach(function (value, key) { var normalizedValue = typeof value === "string" ? value.trim() : value; if (normalizedValue === "") { return; } if (key === "sources" || key === "download_sources") { payload[key] = splitCsv(normalizedValue); return; } if (key === "target_item_id") { payload[key] = Number(normalizedValue); return; } payload[key] = normalizedValue; }); return payload; } function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function showMessage(message, isError) { window.alert((isError ? "Error: " : "") + message); } function postJson(path, payload) { return window .fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload || {}), }) .then(function (response) { return response.json().then(function (data) { if (!response.ok) { throw new Error(data.detail || "request failed"); } return data; }); }); } function fetchJson(path) { return window.fetch(path).then(function (response) { return response.json().then(function (data) { if (!response.ok) { throw new Error(data.detail || "request failed"); } return data; }); }); } function renderLocalDuplicateExecutionSummary(execution) { if (!execution) { return ""; } return ( '

' + "Deduped " + escapeHtml(execution.deduped_group_count || 0) + " groups" + " | inactive " + escapeHtml(execution.inactive_location_count || 0) + " | deleted files " + escapeHtml(execution.deleted_file_count || 0) + " | released " + escapeHtml(formatFileSizeBytes(execution.released_bytes || 0)) + " | repointed upload tasks " + escapeHtml(execution.repointed_upload_task_count || 0) + " | repointed job items " + escapeHtml(execution.repointed_job_item_count || 0) + "

" ); } function renderLocalDuplicateGroups(groups) { if (!groups || !groups.length) { return '

No duplicate local copies were found.

'; } var rowsHtml = groups .map(function (group) { var duplicateHtml = (group.duplicates || []) .map(function (location) { var sizeText = location.actual_file_size_bytes || location.file_size_bytes; return ( '
' + '' + escapeHtml(location.locator || "-") + "" + '' + escapeHtml(formatFileSizeBytes(sizeText)) + " | exists " + escapeHtml(location.file_exists ? "yes" : "no") + "" + "
" ); }) .join(""); return ( "" + "" + escapeHtml(group.song_name || "-") + '
' + escapeHtml((group.singers || "") + (group.backend_name ? " | " + group.backend_name : "")) + "
" + '
' + escapeHtml((group.keep && group.keep.locator) || "-") + "
" + "" + duplicateHtml + "" + "" ); }) .join(""); return ( "" + rowsHtml + "
SongKeepDuplicates
" ); } function localDuplicateSummaryText(payload) { var summary = (payload && payload.summary) || {}; return ( String(summary.duplicate_group_count || 0) + " groups / " + String(summary.duplicate_location_count || 0) + " extra copies / " + String(summary.scanned_active_local_location_count || 0) + " active local locations" ); } function bindMaintenancePanel() { var panel = document.querySelector('[data-maintenance-panel="local-duplicates"]'); if (!panel || panel.getAttribute("data-bound") === "1") { return; } panel.setAttribute("data-bound", "1"); var scanButton = panel.querySelector('[data-maintenance-action="scan"]'); var dedupeButton = panel.querySelector('[data-maintenance-action="dedupe"]'); var statusNode = panel.querySelector("[data-maintenance-status]"); var resultNode = panel.querySelector("[data-maintenance-result]"); var scanApi = panel.getAttribute("data-scan-api") || "/api/maintenance/local-duplicates"; var dedupeApi = panel.getAttribute("data-dedupe-api") || "/api/maintenance/local-duplicates/dedupe"; function setMaintenanceBusy(busy) { if (scanButton) { scanButton.disabled = busy; } if (dedupeButton) { dedupeButton.disabled = busy; } } function applyMaintenancePayload(payload, actionLabel) { if (statusNode) { statusNode.textContent = actionLabel + ": " + localDuplicateSummaryText(payload); statusNode.className = "muted"; } if (resultNode) { resultNode.innerHTML = renderLocalDuplicateExecutionSummary(payload && payload.execution) + renderLocalDuplicateGroups((payload && payload.groups) || []); } } function handleMaintenanceError(error) { if (statusNode) { statusNode.textContent = (error && error.message) || "request failed"; statusNode.className = ""; } } if (scanButton) { scanButton.addEventListener("click", function (event) { if (event && typeof event.preventDefault === "function") { event.preventDefault(); } setMaintenanceBusy(true); if (statusNode) { statusNode.textContent = "Scanning duplicate local copies..."; statusNode.className = "muted"; } fetchJson(scanApi) .then(function (payload) { applyMaintenancePayload(payload, "Scan"); }) .catch(handleMaintenanceError) .finally(function () { setMaintenanceBusy(false); }); }); } if (dedupeButton) { dedupeButton.addEventListener("click", function (event) { if (event && typeof event.preventDefault === "function") { event.preventDefault(); } setMaintenanceBusy(true); if (statusNode) { statusNode.textContent = "Running local dedupe..."; statusNode.className = "muted"; } postJson(dedupeApi, {}) .then(function (payload) { applyMaintenancePayload(payload, "Dedupe"); }) .catch(handleMaintenanceError) .finally(function () { setMaintenanceBusy(false); }); }); } } function renderRows(container, rows, columns, emptyText) { if (!container) { return; } if (!rows || !rows.length) { container.innerHTML = '' + emptyText + ""; return; } container.innerHTML = rows .map(function (row) { return "" + row.join("") + ""; }) .join(""); } function normalizePercent(value) { var numberValue = Number(value || 0); if (!window.Number.isFinite(numberValue)) { return 0; } if (numberValue < 0) { return 0; } if (numberValue > 100) { return 100; } return Math.round(numberValue * 10) / 10; } function progressCellHtml(progressText, progressPercent) { var normalizedPercent = normalizePercent(progressPercent); return ( '
' + escapeHtml(progressText || "-") + "" + escapeHtml(normalizedPercent) + '%
' ); } function commandTypeForRow(row) { if (row && row.can_resume) { return "resume"; } if (row && row.can_pause) { return "pause"; } var status = String((row && row.status) || "").toLowerCase(); if (status === "paused" || status === "pause_requested") { return "resume"; } if (status === "queued" || status === "running") { return "pause"; } return null; } function isDoneTaskStatus(status) { return Boolean(DONE_TASK_STATUSES[String(status || "").toLowerCase()]); } function splitTaskRows(rows) { var groups = (rows || []).reduce( function (groups, row) { if (isDoneTaskStatus(row && row.status)) { groups.done.push(row); } else { groups.doing.push(row); } return groups; }, { doing: [], done: [] } ); groups.done.sort(function (left, right) { var leftKey = String( (left && (left.ended_at || left.started_at || left.created_at)) || "" ); var rightKey = String( (right && (right.ended_at || right.started_at || right.created_at)) || "" ); if (leftKey === rightKey) { return Number((right && right.id) || 0) - Number((left && left.id) || 0); } return rightKey.localeCompare(leftKey); }); groups.done = groups.done.slice(0, 10); return groups; } function taskRowsById() { var index = {}; (dashboardState.taskRows || []).forEach(function (row) { index[String(row.id)] = row; }); return index; } function pruneTaskState(rows) { var activeIds = {}; (rows || []).forEach(function (row) { activeIds[String(row.id)] = true; }); Object.keys(dashboardState.expandedTaskIds).forEach(function (jobId) { if (!activeIds[jobId]) { delete dashboardState.expandedTaskIds[jobId]; } }); Object.keys(dashboardState.detailCache).forEach(function (jobId) { if (!activeIds[jobId]) { delete dashboardState.detailCache[jobId]; } }); Object.keys(dashboardState.expandedPlaylistKeys).forEach(function (key) { var jobId = String(key).split(":")[0]; if (!activeIds[jobId]) { delete dashboardState.expandedPlaylistKeys[key]; } }); Object.keys(dashboardState.playlistSongCache).forEach(function (key) { var jobId = String(key).split(":")[0]; if (!activeIds[jobId]) { delete dashboardState.playlistSongCache[key]; } }); Object.keys(dashboardState.playlistSongsInFlight).forEach(function (key) { var jobId = String(key).split(":")[0]; if (!activeIds[jobId]) { delete dashboardState.playlistSongsInFlight[key]; } }); } function renderTaskCenterRows(rows) { var bodyNode = document.querySelector("[data-task-center-body]"); if (!bodyNode) { return; } if (!rows || !rows.length) { bodyNode.innerHTML = 'No tasks yet.'; return; } bodyNode.innerHTML = rows .map(function (row) { var toggleCommand = commandTypeForRow(row) || ""; var toggleLabel = toggleCommand === "resume" ? ">" : "||"; var actions = ['
']; if (toggleCommand) { actions.push( '" ); } else { actions.push('-'); } if ( row && (row.can_cancel || ["queued", "running", "paused", "pause_requested"].indexOf( String(row.status || "").toLowerCase() ) >= 0) ) { actions.push( '' ); } actions.push("
"); return [ '', '', "" + escapeHtml(row.id) + "", "" + escapeHtml(row.display_name || row.job_type || "-") + '
' + escapeHtml(row.job_type || "-") + "
", "" + escapeHtml(row.status || "-") + "", "" + escapeHtml(row.scope_summary || "-") + "", '' + progressCellHtml( row.primary_progress_text || "-", row.primary_progress_percent || 0 ) + "", "" + escapeHtml(row.active_worker_count || 0) + "", "" + escapeHtml(row.queue_label || row.lane_type || "-") + "", "" + actions.join("") + "", "", '
Loading task detail...
', ].join(""); }) .join(""); } function renderDetailTable(headers, rows, emptyText) { if (!rows || !rows.length) { return '

' + escapeHtml(emptyText) + "

"; } var headHtml = headers .map(function (header) { return "" + escapeHtml(header) + ""; }) .join(""); var bodyHtml = rows .map(function (row) { return ( "" + row .map(function (cell) { return "" + cell + ""; }) .join("") + "" ); }) .join(""); return ( "" + headHtml + "" + bodyHtml + "
" ); } function playlistKey(jobId, playlistId) { return String(jobId) + ":" + String(playlistId); } function renderSongStatusCell(song) { var status = String((song && song.status) || "pending").toLowerCase(); var labelMap = { downloaded: "已下载", running: "下载中", pending: "排队中", failed: "失败", skipped: "跳过", }; var parts = [ '' + escapeHtml(labelMap[status] || status) + "", ]; if (song && song.is_non_music_resource) { parts.push('非音乐资源'); } return parts.join(" "); } function renderSongProgressTable(songRows) { if (!songRows || !songRows.length) { return '

No songs in this playlist yet.

'; } var bodyHtml = songRows .map(function (song) { var position = song.position === null || song.position === undefined ? "-" : song.position; var sourceText = [song.platform || "", song.remote_song_id || ""] .filter(Boolean) .join(" #"); var metaParts = [song.singers || "", sourceText].filter(Boolean).join(" | "); return ( "" + "" + escapeHtml(position) + "" + "" + escapeHtml(song.song_name || "-") + '
' + escapeHtml(metaParts || "-") + "
" + "" + renderSongStatusCell(song) + "" + '' + escapeHtml(song.status_note || "-") + "" + "" ); }) .join(""); return ( '' + "" + "" + bodyHtml + "
#SongStatusNote
" ); } function renderPlaylistProgressTree(jobId, playlistRows) { if (!playlistRows || !playlistRows.length) { return '

No playlist progress.

'; } var rowHtml = playlistRows .map(function (row) { var playlistId = String(row.playlist_id || ""); if (!playlistId) { return ""; } var key = playlistKey(jobId, playlistId); var isExpanded = Boolean(dashboardState.expandedPlaylistKeys[key]); var doneText = String(row.downloaded_songs || 0) + " / " + String(row.total_songs || 0); var subtitleParts = [row.platform || "", row.remote_playlist_id || ""].filter(Boolean); var stateSummary = [ "running " + String(row.running_songs || 0), "pending " + String(row.pending_songs || 0), "failed " + String(row.failed_songs || 0), "skipped " + String(row.skipped_songs || 0), ].join(" | "); var detailContent = '

Expand to load songs...

'; if ( isExpanded && Object.prototype.hasOwnProperty.call(dashboardState.playlistSongCache, key) ) { detailContent = renderSongProgressTable(dashboardState.playlistSongCache[key] || []); } else if (isExpanded) { detailContent = '

Loading songs...

'; } return [ '', "", '", "", "" + escapeHtml(row.playlist_name || "-") + '
' + escapeHtml(subtitleParts.join(" #") || "-") + "
", '' + progressCellHtml(doneText, row.progress_percent || 0) + "", "" + escapeHtml(stateSummary) + "", "", '
" + detailContent + "
", ].join(""); }) .join(""); return ( '' + "" + "" + rowHtml + "
PlaylistProgressState
" ); } function renderTaskDetail(payload) { var job = payload.job || {}; var jobId = String(job.id || ""); var stageRows = (payload.stages || []).map(function (stage) { return [ escapeHtml(stage.stage_type || "-"), escapeHtml(stage.status || "-"), escapeHtml(String(stage.success_items || 0) + " / " + String(stage.total_items || 0)), escapeHtml(stage.failed_items || 0), ]; }); var workerRows = (payload.workers || []).map(function (worker) { return [ escapeHtml(worker.worker_name || "-"), escapeHtml(worker.status || "-"), escapeHtml(worker.display_text || "-"), escapeHtml(worker.speed_text || worker.last_progress_text || "-"), ]; }); var runningItemRows = (payload.running_items || []).map(function (item) { return [ escapeHtml(item.worker_name || "-"), escapeHtml(item.stage_type || "-"), escapeHtml(item.display_name || "-"), escapeHtml(item.started_at || "-"), ]; }); return [ '
', '

Summary

', "", "", "", "", "", "
ID" + escapeHtml(job.id || "-") + "
Type" + escapeHtml(job.job_type || "-") + "
Status" + escapeHtml(job.status || "-") + "
Started" + escapeHtml(job.started_at || "-") + "
Ended" + escapeHtml(job.ended_at || "-") + "
", '

Stages

' + renderDetailTable( ["Stage", "Status", "Done", "Failed"], stageRows, "No stages." ) + "
", '

Workers

' + renderDetailTable( ["Worker", "Status", "Current Item", "Progress"], workerRows, "No workers." ) + "
", '

Running Items

' + renderDetailTable( ["Worker", "Stage", "Item", "Started"], runningItemRows, "No running items." ) + "
", "
", '

Playlist Progress

' + renderPlaylistProgressTree(jobId, payload.playlist_progress || []) + "
", ].join(""); } function taskTreeRoot(bucket) { var normalizedBucket = String(bucket || "doing"); return ( document.querySelector('[data-task-tree-root="' + normalizedBucket + '"]') || (normalizedBucket === "doing" ? document.querySelector("[data-task-tree-root]") : null) ); } function taskTreeRoots() { var roots = Array.prototype.slice.call( document.querySelectorAll('[data-task-tree-root="doing"], [data-task-tree-root="done"], [data-task-tree-root]') ); if (!roots.length) { var fallbackRoot = taskTreeRoot("doing"); if (fallbackRoot) { roots.push(fallbackRoot); } } return roots; } function taskChildrenNode(jobId) { return document.querySelector('[data-task-children="' + String(jobId) + '"]'); } function playlistSongsNode(key) { return document.querySelector('[data-playlist-songs="' + key + '"]'); } function isTreeNodeExpanded(node) { return Boolean(node) && !node.hasAttribute("hidden"); } function eventTargetElement(event) { var target = event && event.target; if (!target) { return null; } if (typeof target.closest === "function") { return target; } if (target.nodeType === 3 && target.parentElement) { return target.parentElement; } if (target.parentElement && typeof target.parentElement.closest === "function") { return target.parentElement; } return null; } function setTreeToggle(button, expanded) { if (!button) { return; } button.textContent = expanded ? "-" : "+"; button.setAttribute("aria-expanded", expanded ? "true" : "false"); } function setStatusTag(node, status) { if (!node) { return; } var normalizedStatus = String(status || "-").toLowerCase(); node.className = "status-tag status-" + normalizedStatus; node.textContent = normalizedStatus; } function taskSubtitleText(row) { var parts = [ "#" + String(row.id || "-"), row.job_type || "-", row.scope_summary || "-", row.queue_label || row.lane_type || "-", "workers " + String(row.active_worker_count || 0), ]; if (row.speed_text) { parts.push(row.speed_text); } return parts.join(" · "); } function taskMetaInlineText(row) { var parts = [ "#" + String(row.id || "-"), row.job_type || "-", row.scope_summary || "-", row.queue_label || row.lane_type || "-", "workers " + String(row.active_worker_count || 0), ]; if (row && row.speed_text) { parts.push(row.speed_text); } return parts.join(" / "); } function playlistMetaInlineText(row) { return [row.platform || "", row.remote_playlist_id || ""].filter(Boolean).join(" #") || "-"; } function taskShowsSongNodes(jobId) { var row = taskRowsById()[String(jobId)]; return !isDoneTaskStatus(row && row.status); } function taskActionsHtml(row) { var toggleCommand = commandTypeForRow(row) || ""; var toggleLabel = toggleCommand === "resume" ? ">" : "||"; var actions = ['
']; if (toggleCommand) { actions.push( '" ); } else { actions.push('-'); } if ( row && (row.can_cancel || ["queued", "running", "paused", "pause_requested"].indexOf( String(row.status || "").toLowerCase() ) >= 0) ) { actions.push( '' ); } actions.push("
"); return actions.join(""); } function createTaskNode(row) { var node = document.createElement("section"); node.className = "task-tree-node task-tree-node-task"; node.setAttribute("data-task-node", String(row.id)); node.innerHTML = [ '
', '', '
', '
', "", '', '', "
", "
", '
', '
', "
", '', ].join(""); return node; } function patchTaskNode(node, row) { var jobId = String(row.id || ""); node.setAttribute("data-task-node", jobId); var childrenNode = node.querySelector("[data-task-children]"); var isExpanded = Boolean(dashboardState.expandedTaskIds[jobId]) || isTreeNodeExpanded(childrenNode); if (isExpanded) { dashboardState.expandedTaskIds[jobId] = true; } else { delete dashboardState.expandedTaskIds[jobId]; } if (childrenNode) { childrenNode.setAttribute("data-task-children", jobId); if (isExpanded) { childrenNode.removeAttribute("hidden"); } else { childrenNode.setAttribute("hidden", "hidden"); } } var button = node.querySelector("[data-task-toggle]"); if (button) { button.setAttribute("data-task-toggle", jobId); button.setAttribute("aria-label", "Expand task " + jobId); setTreeToggle(button, isExpanded); } var nameNode = node.querySelector("[data-task-name]"); if (nameNode) { nameNode.textContent = String(row.display_name || row.job_type || "-"); } setStatusTag(node.querySelector("[data-task-status]"), row.status || "-"); var metaNode = node.querySelector("[data-task-meta-inline]"); if (metaNode) { metaNode.textContent = taskMetaInlineText(row); } var progressNode = node.querySelector("[data-task-progress]"); if (progressNode) { progressNode.innerHTML = progressCellHtml( row.primary_progress_text || "-", row.primary_progress_percent || 0 ); } var actionsNode = node.querySelector("[data-task-actions]"); if (actionsNode) { actionsNode.innerHTML = taskActionsHtml(row); } } function emptyTaskTreeText(bucket) { return bucket === "done" ? "No recently finished tasks." : "No active tasks."; } function upsertTaskTree(root, rows, bucket) { if (!root) { return; } if (!rows || !rows.length) { root.innerHTML = '

' + escapeHtml(emptyTaskTreeText(bucket)) + "

"; return; } Array.prototype.slice.call(root.querySelectorAll("[data-task-tree-empty]")).forEach( function (node) { node.remove(); } ); var seen = {}; rows.forEach(function (row) { var jobId = String(row.id || ""); if (!jobId) { return; } seen[jobId] = true; var node = root.querySelector('[data-task-node="' + jobId + '"]'); if (!node) { node = createTaskNode(row); } patchTaskNode(node, row); root.appendChild(node); }); Array.prototype.slice.call(root.querySelectorAll("[data-task-node]")).forEach(function (node) { var jobId = String(node.getAttribute("data-task-node") || ""); if (!seen[jobId]) { node.remove(); } }); } function playlistSubtitleText(row) { return [row.platform || "", row.remote_playlist_id || ""].filter(Boolean).join(" #") || "-"; } function playlistStateSummary(row) { return [ "running " + String(row.running_songs || 0), "pending " + String(row.pending_songs || 0), "failed " + String(row.failed_songs || 0), "skipped " + String(row.skipped_songs || 0), ].join(" | "); } function createPlaylistNode(jobId, row) { var playlistId = String(row.playlist_id || ""); var key = playlistKey(jobId, playlistId); var showSongs = taskShowsSongNodes(jobId); var node = document.createElement("section"); node.className = "task-tree-node task-tree-node-playlist"; node.setAttribute("data-playlist-node", key); node.setAttribute("data-playlist-song-tree", showSongs ? "1" : "0"); node.innerHTML = showSongs ? [ '
', '', '
', '
', "
", '
', '
', "
", '', ].join("") : [ '
', '', '
', '
', "
", '
', '
', "
", ].join(""); return node; } function patchPlaylistNode(node, jobId, row) { var playlistId = String(row.playlist_id || ""); var key = playlistKey(jobId, playlistId); var showSongs = taskShowsSongNodes(jobId); node.setAttribute("data-playlist-node", key); node.setAttribute("data-playlist-song-tree", showSongs ? "1" : "0"); var button = node.querySelector("[data-playlist-toggle]"); if (button) { button.setAttribute("data-playlist-toggle", key); button.setAttribute("data-job-id", String(jobId)); button.setAttribute("data-playlist-id", playlistId); button.setAttribute("aria-label", "Expand playlist " + playlistId); setTreeToggle( button, Boolean(dashboardState.expandedPlaylistKeys[key]) || isTreeNodeExpanded(node.querySelector("[data-playlist-songs]")) ); } var nameNode = node.querySelector("[data-playlist-name]"); if (nameNode) { nameNode.textContent = String(row.playlist_name || "-"); } var metaNode = node.querySelector("[data-playlist-meta-inline]"); if (metaNode) { metaNode.textContent = playlistMetaInlineText(row); } var progressNode = node.querySelector("[data-playlist-progress]"); if (progressNode) { progressNode.innerHTML = progressCellHtml( String(row.downloaded_songs || 0) + " / " + String(row.total_songs || 0), row.progress_percent || 0 ); } var stateNode = node.querySelector("[data-playlist-state]"); if (stateNode) { stateNode.textContent = playlistStateSummary(row); } var songsNode = node.querySelector("[data-playlist-songs]"); if (songsNode) { songsNode.setAttribute("data-playlist-songs", key); if (Boolean(dashboardState.expandedPlaylistKeys[key]) || isTreeNodeExpanded(songsNode)) { dashboardState.expandedPlaylistKeys[key] = true; songsNode.removeAttribute("hidden"); } else { delete dashboardState.expandedPlaylistKeys[key]; songsNode.setAttribute("hidden", "hidden"); } } else { delete dashboardState.expandedPlaylistKeys[key]; } } function patchPlaylistTree(jobId, playlistRows) { var container = taskChildrenNode(jobId); if (!container) { return; } if (!playlistRows || !playlistRows.length) { container.innerHTML = '

No playlist progress.

'; return; } Array.prototype.slice.call(container.children).forEach(function (child) { if (!child.hasAttribute("data-playlist-node")) { child.remove(); } }); var seen = {}; var showSongs = taskShowsSongNodes(jobId); playlistRows.forEach(function (row) { var playlistId = String(row.playlist_id || ""); if (!playlistId) { return; } var key = playlistKey(jobId, playlistId); seen[key] = true; var node = container.querySelector('[data-playlist-node="' + key + '"]'); if ( !node || String(node.getAttribute("data-playlist-song-tree") || "0") !== (showSongs ? "1" : "0") ) { if (node) { node.remove(); } node = createPlaylistNode(jobId, row); } patchPlaylistNode(node, jobId, row); container.appendChild(node); }); Array.prototype.slice.call(container.querySelectorAll("[data-playlist-node]")).forEach(function (node) { var key = String(node.getAttribute("data-playlist-node") || ""); if (!seen[key]) { node.remove(); } }); } function shouldHideIgnoredSong(song) { var status = String((song && song.status) || "").toLowerCase(); return Boolean(song && song.is_non_music_resource && (status === "skipped" || status === "failed")); } function renderSongTree(songs) { var visibleSongs = (songs || []).filter(function (song) { return !shouldHideIgnoredSong(song); }); if (!visibleSongs.length) { return '

No songs in this playlist yet.

'; } return visibleSongs .map(function (song) { var position = song.position === null || song.position === undefined ? "-" : song.position; var sourceText = [song.platform || "", song.remote_song_id || ""] .filter(Boolean) .join(" #"); var metaParts = [song.singers || "", sourceText].filter(Boolean).join(" | "); return [ '
', '
' + escapeHtml(position) + "
", '
', '
' + escapeHtml(song.song_name || "-") + '' + escapeHtml(metaParts || "-") + "
", "
", '
' + renderSongStatusCell(song) + "
", '
' + escapeHtml(song.status_note || "-") + "
", "
", ].join(""); }) .join(""); } function showPlaylistSongs(key) { var songsNode = playlistSongsNode(key); if (songsNode) { songsNode.removeAttribute("hidden"); } setTreeToggle( document.querySelector('[data-playlist-toggle="' + key + '"]'), true ); } function hidePlaylistSongs(key) { var songsNode = playlistSongsNode(key); if (songsNode) { songsNode.setAttribute("hidden", "hidden"); } setTreeToggle( document.querySelector('[data-playlist-toggle="' + key + '"]'), false ); } function applyPlaylistSongs(jobId, playlistId, songs) { var key = playlistKey(jobId, playlistId); var songsNode = playlistSongsNode(key); if (!songsNode) { return; } songsNode.innerHTML = renderSongTree(songs || []); } function loadPlaylistSongs(jobId, playlistId, forceRefresh) { var key = playlistKey(jobId, playlistId); var songsNode = playlistSongsNode(key); if (!songsNode) { return Promise.resolve(null); } if ( !forceRefresh && Object.prototype.hasOwnProperty.call(dashboardState.playlistSongCache, key) ) { applyPlaylistSongs(jobId, playlistId, dashboardState.playlistSongCache[key] || []); return Promise.resolve(dashboardState.playlistSongCache[key] || []); } if (dashboardState.playlistSongsInFlight[key]) { return dashboardState.playlistSongsInFlight[key]; } songsNode.innerHTML = '

Loading songs...

'; dashboardState.playlistSongsInFlight[key] = fetchJson( "/api/jobs/" + encodeURIComponent(String(jobId)) + "/playlists/" + encodeURIComponent(String(playlistId)) + "/songs" ) .then(function (payload) { var items = (payload && payload.items) || []; dashboardState.playlistSongCache[key] = items; applyPlaylistSongs(jobId, playlistId, items); return items; }) .catch(function (error) { if (songsNode) { songsNode.textContent = error.message || "request failed"; } }) .finally(function () { delete dashboardState.playlistSongsInFlight[key]; }); return dashboardState.playlistSongsInFlight[key]; } function restoreExpandedPlaylistRows(jobId) { var normalizedJobId = String(jobId); var prefix = normalizedJobId + ":"; Object.keys(dashboardState.expandedPlaylistKeys).forEach(function (key) { if (!dashboardState.expandedPlaylistKeys[key] || key.indexOf(prefix) !== 0) { return; } var playlistId = key.slice(prefix.length); if (!playlistId) { return; } showPlaylistSongs(key); loadPlaylistSongs(normalizedJobId, playlistId, false); }); } function clearExpandedPlaylistRows(jobId) { var normalizedJobId = String(jobId); var prefix = normalizedJobId + ":"; Object.keys(dashboardState.expandedPlaylistKeys).forEach(function (key) { if (key.indexOf(prefix) === 0) { delete dashboardState.expandedPlaylistKeys[key]; } }); } function showTaskChildren(jobId) { var childrenNode = taskChildrenNode(jobId); if (childrenNode) { childrenNode.removeAttribute("hidden"); } setTreeToggle(document.querySelector('[data-task-toggle="' + String(jobId) + '"]'), true); } function hideTaskChildren(jobId) { var childrenNode = taskChildrenNode(jobId); if (childrenNode) { childrenNode.setAttribute("hidden", "hidden"); } setTreeToggle(document.querySelector('[data-task-toggle="' + String(jobId) + '"]'), false); } function applyTaskDetail(jobId, payload) { var normalizedJobId = String(jobId); var childrenNode = taskChildrenNode(normalizedJobId); if (!childrenNode) { return; } dashboardState.detailCache[normalizedJobId] = payload; patchPlaylistTree(normalizedJobId, payload.playlist_progress || []); if (taskShowsSongNodes(normalizedJobId)) { restoreExpandedPlaylistRows(normalizedJobId); } else { clearExpandedPlaylistRows(normalizedJobId); } } function loadTaskDetail(jobId, forceRefresh) { var normalizedJobId = String(jobId); showTaskChildren(normalizedJobId); var childrenNode = taskChildrenNode(normalizedJobId); if (!childrenNode) { delete dashboardState.expandedTaskIds[normalizedJobId]; delete dashboardState.detailCache[normalizedJobId]; return Promise.resolve(null); } if (!forceRefresh && dashboardState.detailCache[normalizedJobId]) { applyTaskDetail(normalizedJobId, dashboardState.detailCache[normalizedJobId]); return Promise.resolve(dashboardState.detailCache[normalizedJobId]); } if (!forceRefresh || !String(childrenNode.innerHTML || "").trim()) { childrenNode.innerHTML = '

Loading playlists...

'; } return fetchJson("/api/jobs/" + normalizedJobId) .then(function (payload) { if (!dashboardState.expandedTaskIds[normalizedJobId]) { return payload; } if (!taskChildrenNode(normalizedJobId)) { delete dashboardState.expandedTaskIds[normalizedJobId]; delete dashboardState.detailCache[normalizedJobId]; return payload; } applyTaskDetail(normalizedJobId, payload); return payload; }) .catch(function (error) { if (childrenNode) { childrenNode.textContent = error.message || "request failed"; } }); } function restoreExpandedTaskRows() { Object.keys(dashboardState.expandedTaskIds).forEach(function (jobId) { if (!dashboardState.expandedTaskIds[jobId]) { return; } loadTaskDetail(jobId, true); }); } function bindTaskCenter() { taskTreeRoots().forEach(function (root) { if (!root || root.dataset.bound === "1") { return; } root.dataset.bound = "1"; root.addEventListener("click", function (event) { var eventTarget = eventTargetElement(event); if (!eventTarget) { return; } var taskToggle = eventTarget.closest("[data-task-toggle]"); if (taskToggle) { var toggleJobId = String(taskToggle.getAttribute("data-task-toggle") || ""); if (!toggleJobId) { return; } var taskChildren = taskChildrenNode(toggleJobId); var taskExpanded = taskChildren ? !taskChildren.hasAttribute("hidden") : Boolean(dashboardState.expandedTaskIds[toggleJobId]); if (taskExpanded) { delete dashboardState.expandedTaskIds[toggleJobId]; hideTaskChildren(toggleJobId); return; } dashboardState.expandedTaskIds[toggleJobId] = true; loadTaskDetail(toggleJobId, true); return; } var playlistToggle = eventTarget.closest("[data-playlist-toggle]"); if (playlistToggle) { var normalizedJobId = String(playlistToggle.getAttribute("data-job-id") || ""); var playlistId = String(playlistToggle.getAttribute("data-playlist-id") || ""); if (!normalizedJobId || !playlistId) { return; } var key = playlistKey(normalizedJobId, playlistId); var playlistSongs = playlistSongsNode(key); var playlistExpanded = playlistSongs ? !playlistSongs.hasAttribute("hidden") : Boolean(dashboardState.expandedPlaylistKeys[key]); if (playlistExpanded) { delete dashboardState.expandedPlaylistKeys[key]; hidePlaylistSongs(key); return; } dashboardState.expandedPlaylistKeys[key] = true; showPlaylistSongs(key); loadPlaylistSongs(normalizedJobId, playlistId, false); return; } var commandToggle = eventTarget.closest("[data-task-command-toggle]"); if (commandToggle) { var commandJobId = String(commandToggle.getAttribute("data-task-command-toggle") || ""); var rowMap = taskRowsById(); var commandType = commandToggle.getAttribute("data-task-command-type") || commandTypeForRow(rowMap[commandJobId]); if (!commandJobId || !commandType) { return; } postJson("/api/jobs/" + commandJobId + "/commands", { command_type: commandType, }) .then(function () { refreshDashboardFromApi(true, true); }) .catch(function (error) { showMessage(error.message || "request failed", true); }); return; } var cancelButton = eventTarget.closest("[data-task-command-cancel]"); if (cancelButton) { var cancelJobId = String(cancelButton.getAttribute("data-task-command-cancel") || ""); if (!cancelJobId) { return; } postJson("/api/jobs/" + cancelJobId + "/commands", { command_type: "cancel", }) .then(function () { refreshDashboardFromApi(true, true); }) .catch(function (error) { showMessage(error.message || "request failed", true); }); } }); }); } function bindJsonForms() { var forms = document.querySelectorAll("form[data-json-form]"); forms.forEach(function (form) { form.addEventListener("submit", function (event) { event.preventDefault(); var payload = formToJson(form); postJson(form.getAttribute("action"), payload) .then(function (data) { var successMode = form.getAttribute("data-success"); if (successMode === "redirect-job" && data.job && data.job.id) { window.location.href = "/jobs/" + data.job.id; return; } if (successMode === "reload") { if (body.getAttribute("data-dashboard-api")) { refreshDashboardFromApi(true); return; } window.location.reload(); return; } showMessage("Operation completed.", false); }) .catch(function (error) { showMessage(error.message || "request failed", true); }); }); }); bindMaintenancePanel(); } function setTaskRows(rows) { dashboardState.taskRows = rows || []; pruneTaskState(dashboardState.taskRows); bindTaskCenter(); var groups = splitTaskRows(dashboardState.taskRows); upsertTaskTree(taskTreeRoot("doing"), groups.doing, "doing"); upsertTaskTree(taskTreeRoot("done"), groups.done, "done"); restoreExpandedTaskRows(); } function updateDashboard(payload) { if (!payload) { return; } var summary = payload.summary || {}; Object.keys(summary).forEach(function (key) { var node = document.querySelector('[data-summary-field="' + key + '"]'); if (node) { node.textContent = String(summary[key]); } }); var downloadStats = payload.download_stats || {}; Object.keys(downloadStats).forEach(function (key) { var node = document.querySelector('[data-download-field="' + key + '"]'); if (node) { node.textContent = String(downloadStats[key]); } }); var transferStats = payload.transfer_stats || {}; var transferNode = document.querySelector("[data-task-center-transfer]"); if (transferNode) { var downloadSpeedText = String(transferStats.download_speed_text || "0 B/s"); var uploadSpeedText = String(transferStats.upload_speed_text || "-"); transferNode.textContent = "Down " + downloadSpeedText + " | Up " + uploadSpeedText; } var statusNode = document.querySelector("[data-live-status]"); if (statusNode) { statusNode.textContent = "Live snapshot: queued download=" + Number(summary.queued_download_jobs || 0) + ", running=" + Number(summary.running_jobs || 0) + ", downloaded songs=" + Number(downloadStats.downloaded_songs || 0); } renderRows( document.querySelector("[data-workers-body]"), (payload.workers || []).map(function (worker) { return [ "" + escapeHtml(worker.worker_name || "-") + "", "" + escapeHtml(worker.status || "-") + "", "" + escapeHtml(worker.stage_type || "-") + "", "" + escapeHtml(worker.display_text || "-") + "", "" + escapeHtml(worker.speed_text || worker.last_progress_text || "-") + "", ]; }), 5, "No active workers." ); renderRows( document.querySelector("[data-running-items-body]"), (payload.running_items || []).map(function (item) { var jobId = escapeHtml(item.job_run_id || "-"); return [ '' + jobId + "", "" + escapeHtml(item.worker_name || "-") + "", "" + escapeHtml(item.stage_type || "-") + "", "" + escapeHtml(item.display_name || "-") + "", "" + escapeHtml(item.started_at || "-") + "", ]; }), 5, "No running items." ); renderRows( document.querySelector("[data-playlist-sources-body]"), (payload.playlist_sources || []).map(function (row) { return [ "" + escapeHtml(row.platform || "-") + "", "" + escapeHtml(row.pool_kind || "-") + "", "" + escapeHtml(row.pool_name || "-") + "", "" + escapeHtml(row.playlist_count || 0) + "", ]; }), 4, "No playlist sources collected yet." ); if (Object.prototype.hasOwnProperty.call(payload, "task_rows")) { setTaskRows(payload.task_rows || []); } } function buildDashboardApiUrl(includeTaskRows) { var dashboardApiUrl = body.getAttribute("data-dashboard-api"); if (!dashboardApiUrl) { return ""; } if (includeTaskRows === false) { return ( dashboardApiUrl + (dashboardApiUrl.indexOf("?") >= 0 ? "&" : "?") + "include_task_rows=false" ); } return dashboardApiUrl; } function refreshDashboardFromApi(force, includeTaskRows) { var dashboardApiUrl = buildDashboardApiUrl(includeTaskRows); if (!dashboardApiUrl) { return; } if (!force && dashboardState.refreshInFlight) { return; } dashboardState.refreshInFlight = true; fetchJson(dashboardApiUrl) .then(function (payload) { updateDashboard(payload); }) .catch(function (error) { var statusNode = document.querySelector("[data-live-status]"); if (statusNode) { statusNode.textContent = error.message || "dashboard refresh failed"; } }) .finally(function () { dashboardState.refreshInFlight = false; }); } function scheduleDashboardRefresh() { if (!body.getAttribute("data-dashboard-api")) { return; } if (dashboardState.refreshTimer !== null) { return; } dashboardState.refreshTimer = window.setTimeout(function () { dashboardState.refreshTimer = null; refreshDashboardFromApi(false, false); }, 400); } function scheduleTaskRowsRefresh() { if (!body.getAttribute("data-dashboard-api")) { return; } if (dashboardState.taskRowsRefreshTimer !== null) { return; } dashboardState.taskRowsRefreshTimer = window.setTimeout(function () { dashboardState.taskRowsRefreshTimer = null; refreshDashboardFromApi(false, true); }, TASK_ROWS_REFRESH_INTERVAL_MS); } function bindLiveSnapshot() { if (typeof window.EventSource === "undefined") { return; } var sseUrl = body.getAttribute("data-sse-url"); if (!sseUrl) { return; } var source = new window.EventSource(sseUrl); function handleSnapshot(event) { try { updateDashboard(JSON.parse(event.data || "{}")); scheduleDashboardRefresh(); scheduleTaskRowsRefresh(); } catch (_error) { var statusNode = document.querySelector("[data-live-status]"); if (statusNode) { statusNode.textContent = "Live snapshot received"; } } } source.addEventListener("snapshot", handleSnapshot); source.onmessage = handleSnapshot; source.onerror = function () { var statusNode = document.querySelector("[data-live-status]"); if (statusNode) { statusNode.textContent = "Live snapshot reconnecting..."; } }; } function formatFileSizeBytes(value) { var numberValue = Number(value || 0); if (!window.Number.isFinite(numberValue) || numberValue <= 0) { return "-"; } var units = ["B", "KB", "MB", "GB", "TB"]; var unitIndex = 0; while (numberValue >= 1024 && unitIndex < units.length - 1) { numberValue = numberValue / 1024; unitIndex += 1; } if (unitIndex === 0) { return String(Math.round(numberValue)) + " " + units[unitIndex]; } return String(numberValue.toFixed(1)) + " " + units[unitIndex]; } function yamlScalar(value) { if (value === null || value === undefined || value === "") { return "null"; } if (typeof value === "number") { return window.Number.isFinite(value) ? String(value) : "null"; } if (typeof value === "boolean") { return value ? "true" : "false"; } var text = String(value); if ( /^[A-Za-z0-9_./%+\\\- :]+$/.test(text) && text.indexOf(": ") === -1 && text.indexOf("#") === -1 && text.indexOf("[") === -1 && text.indexOf("]") === -1 && text.indexOf("{") === -1 && text.indexOf("}") === -1 && text.indexOf(",") === -1 && text.indexOf("&") === -1 && text.indexOf("*") === -1 && text.indexOf("!") === -1 && text.indexOf("|") === -1 && text.indexOf(">") === -1 && text.indexOf("'") === -1 && text.indexOf('"') === -1 && text.indexOf("@") === -1 && text.indexOf("`") === -1 ) { return text; } return JSON.stringify(text); } function serializePlaylistExportYaml(payload) { var playlist = (payload && payload.playlist) || {}; var items = (payload && payload.items) || []; var lines = [ "playlist_id: " + yamlScalar(playlist.id), "playlist_name: " + yamlScalar(playlist.name), "platform: " + yamlScalar(playlist.platform), "play_count: " + yamlScalar(playlist.play_count), ]; if (!items.length) { lines.push("songs: []"); return lines.join("\n") + "\n"; } lines.push("songs:"); items.forEach(function (song) { var uploadedLocations = (song && song.uploaded_locations) || []; lines.push(" - local_song_id: " + yamlScalar(song.song_id)); lines.push(" platform_song_id: " + yamlScalar(song.remote_song_id)); lines.push(" platform: " + yamlScalar(song.platform)); lines.push(" name: " + yamlScalar(song.name)); lines.push(" singers: " + yamlScalar(song.singers)); lines.push(" ext: " + yamlScalar(song.ext)); lines.push(" file_size_bytes: " + yamlScalar(song.file_size_bytes)); lines.push(" local_file_path: " + yamlScalar(song.local_file_path)); if (!uploadedLocations.length) { lines.push(" uploaded_locations: []"); return; } lines.push(" uploaded_locations:"); uploadedLocations.forEach(function (location) { lines.push(" - backend_name: " + yamlScalar(location.backend_name)); lines.push(" backend_type: " + yamlScalar(location.backend_type)); lines.push(" uploaded_url: " + yamlScalar(location.url)); lines.push(" container_name: " + yamlScalar(location.container_name)); lines.push(" locator: " + yamlScalar(location.locator)); }); }); return lines.join("\n") + "\n"; } function sanitizeFilenamePart(value) { return String(value || "") .trim() .replace(/[<>:"/\\|?*\u0000-\u001f]+/g, "_") .replace(/\s+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 80); } function buildPlaylistExportFilename(playlist) { var platform = sanitizeFilenamePart(playlist && playlist.platform); var playlistId = sanitizeFilenamePart(playlist && playlist.id); var name = sanitizeFilenamePart(playlist && playlist.name); var parts = ["playlist"]; if (platform) { parts.push(platform); } if (playlistId) { parts.push(playlistId); } if (name) { parts.push(name); } return parts.join("-") + ".yaml"; } function downloadTextFile(filename, content) { if ( !window.Blob || !window.URL || typeof window.URL.createObjectURL !== "function" || typeof document.createElement !== "function" ) { throw new Error("export is not supported in this browser"); } var blob = new window.Blob([String(content || "")], { type: "text/yaml;charset=utf-8", }); var objectUrl = window.URL.createObjectURL(blob); var link = document.createElement("a"); link.href = objectUrl; link.download = filename; if (document.body && typeof document.body.appendChild === "function") { document.body.appendChild(link); } if (typeof link.click === "function") { link.click(); } if (typeof link.remove === "function") { link.remove(); } else if ( document.body && typeof document.body.removeChild === "function" && link.parentNode === document.body ) { document.body.removeChild(link); } window.setTimeout(function () { window.URL.revokeObjectURL(objectUrl); }, 0); } function bindPlaylistPage() { var root = document.querySelector("[data-playlists-page]"); if (!root) { return; } var endpointMap = { sync: "/api/playlists/sync", download: "/api/playlists/download", "export-selected": "/api/playlists/export-zip", "mark-wanted": "/api/playlists/mark-wanted", "unmark-wanted": "/api/playlists/unmark-wanted", }; var checkboxNodes = Array.prototype.slice.call( root.querySelectorAll("[data-playlist-checkbox]") ); var selectAllButton = root.querySelector("[data-playlist-select-all]"); var clearSelectionButton = root.querySelector("[data-playlist-clear-selection]"); var selectionCountNode = root.querySelector("[data-playlist-selection-count]"); var actionButtons = root.querySelectorAll("[data-playlist-action]"); var modalNode = root.querySelector("[data-playlist-songs-modal]"); var modalTitleNode = root.querySelector("[data-playlist-modal-title]"); var modalMetaNode = root.querySelector("[data-playlist-modal-meta]"); var modalStateNode = root.querySelector("[data-playlist-modal-state]"); var modalTableWrapNode = root.querySelector("[data-playlist-modal-table-wrap]"); var modalSongsBodyNode = root.querySelector("[data-playlist-songs-body]"); var exportButton = root.querySelector("[data-playlist-export]"); var playlistActionInFlight = false; var playlistPreviewCache = {}; var playlistPreviewInFlight = {}; var currentPlaylistPreviewId = ""; var currentPlaylistPreviewPayload = null; function getSelectedPlaylistIds() { return checkboxNodes .filter(function (node) { return Boolean(node.checked); }) .map(function (node) { return Number(node.value); }) .filter(function (value) { return Number.isInteger(value) && value > 0; }); } function updateSelectionCount() { if (!selectionCountNode) { return; } selectionCountNode.textContent = String(getSelectedPlaylistIds().length); } function setPlaylistActionDisabled(disabled) { actionButtons.forEach(function (node) { node.disabled = disabled; }); } function playlistJobScopeCount(job) { var playlistScope = (job && job.playlist_scope) || {}; var playlistIds = Array.isArray(playlistScope.playlist_ids) ? playlistScope.playlist_ids : []; return playlistIds.length; } function startBrowserDownload(url) { var target = String(url || "").trim(); if (!target) { showMessage("Download URL is missing.", true); return; } window.location.href = target; } function showQueuedPlaylistJobs(messages) { showMessage(messages.join("; "), false); window.setTimeout(function () { window.location.reload(); }, 1200); } function handleDownloadSelectedResponse(data) { var downloadJob = data && data.download_job; var syncDownloadJob = data && data.sync_download_job; if (!downloadJob && !syncDownloadJob) { return false; } var messages = []; if (downloadJob && downloadJob.id) { messages.push( "download job #" + String(downloadJob.id) + " for " + String(playlistJobScopeCount(downloadJob)) + " playlists" ); } if (syncDownloadJob && syncDownloadJob.id) { messages.push( "sync+download job #" + String(syncDownloadJob.id) + " for " + String(playlistJobScopeCount(syncDownloadJob)) + " playlists" ); } if (!messages.length) { messages.push("No playlists were queued for download."); } showQueuedPlaylistJobs(messages); return true; } function handleExportSelectedResponse(data) { if (data && data.status === "ready" && data.download_url) { startBrowserDownload(data.download_url); return; } var messages = []; var downloadJob = data && data.download_job; var syncDownloadJob = data && data.sync_download_job; if (data && data.message) { messages.push(String(data.message)); } if (downloadJob && downloadJob.id) { messages.push( "download job #" + String(downloadJob.id) + " for " + String(playlistJobScopeCount(downloadJob)) + " playlists" ); } if (syncDownloadJob && syncDownloadJob.id) { messages.push( "sync+download job #" + String(syncDownloadJob.id) + " for " + String(playlistJobScopeCount(syncDownloadJob)) + " playlists" ); } if (!messages.length) { messages.push("Export queued."); } showQueuedPlaylistJobs(messages); } function setPlaylistModalOpen(opened) { if (!modalNode) { return; } if (opened) { modalNode.removeAttribute("hidden"); return; } modalNode.setAttribute("hidden", "hidden"); } function setPlaylistModalHeader(playlist) { if (modalTitleNode) { modalTitleNode.textContent = String( ((playlist && playlist.name) || "Playlist Songs") ); } if (modalMetaNode) { var metaParts = []; if (playlist && playlist.id !== undefined && playlist.id !== null) { metaParts.push("ID " + String(playlist.id)); } if (playlist && playlist.platform) { metaParts.push(String(playlist.platform)); } if (playlist && playlist.remote_playlist_id) { metaParts.push("#" + String(playlist.remote_playlist_id)); } if (playlist && playlist.play_count !== undefined && playlist.play_count !== null) { metaParts.push("热度 " + String(playlist.play_count)); } modalMetaNode.textContent = metaParts.join(" | ") || "-"; } } function setPlaylistModalState(message, isError) { if (modalStateNode) { modalStateNode.textContent = String(message || ""); modalStateNode.className = isError ? "" : "muted"; } if (modalTableWrapNode) { modalTableWrapNode.setAttribute("hidden", "hidden"); } if (exportButton) { exportButton.disabled = true; } } function renderPlaylistLocationHtml(location) { if (!location) { return "-"; } var urlText = String(location.url || location.locator || "-"); var labelParts = [location.backend_name || "", location.backend_type || ""].filter(Boolean); return ( '
' + '' + escapeHtml(labelParts.join(" | ") || "-") + "" + '' + escapeHtml(urlText) + "" + "
" ); } function renderPlaylistSongs(items) { if (!modalSongsBodyNode) { return; } if (!items || !items.length) { modalSongsBodyNode.innerHTML = 'No songs.'; return; } modalSongsBodyNode.innerHTML = items .map(function (song) { var uploadedLocations = (song && song.uploaded_locations) || []; var localPathHtml = song && song.local_file_path ? '
' + escapeHtml(song.local_file_path) + "
" : "-"; var uploadedHtml = uploadedLocations.length ? uploadedLocations.map(renderPlaylistLocationHtml).join("") : "-"; var songMeta = [song && song.platform ? song.platform : "", song && song.remote_song_id ? "#" + song.remote_song_id : ""] .filter(Boolean) .join(" "); return ( "" + '' + escapeHtml(song && song.song_id ? song.song_id : "-") + "" + "" + escapeHtml((song && song.name) || "-") + '
' + escapeHtml(songMeta || "-") + "
" + "" + escapeHtml((song && song.singers) || "-") + "" + '' + escapeHtml(formatFileSizeBytes(song && song.file_size_bytes)) + "" + "" + escapeHtml((song && song.ext) || "-") + "" + "" + localPathHtml + "" + "" + uploadedHtml + "" + "" ); }) .join(""); } function playlistPreviewLooksExportReady(payload) { var items = (payload && payload.items) || []; if (!items.length) { return false; } return items.every(function (song) { return Boolean(String((song && song.local_file_path) || "").trim()); }); } function applyPlaylistPreviewPayload(payload) { var normalizedPayload = payload || {}; var playlist = normalizedPayload.playlist || {}; var items = normalizedPayload.items || []; currentPlaylistPreviewPayload = normalizedPayload; setPlaylistModalHeader(playlist); renderPlaylistSongs(items); if (modalStateNode) { modalStateNode.textContent = items.length ? "" : "No songs found for this playlist."; modalStateNode.className = "muted"; } if (modalTableWrapNode) { if (items.length) { modalTableWrapNode.removeAttribute("hidden"); } else { modalTableWrapNode.setAttribute("hidden", "hidden"); } } if (exportButton) { exportButton.disabled = false; } setPlaylistModalOpen(true); } function openPlaylistPreview(trigger) { if (!trigger) { return; } var playlistId = String(trigger.getAttribute("data-playlist-open-songs") || ""); if (!playlistId) { return; } currentPlaylistPreviewId = playlistId; setPlaylistModalHeader({ id: playlistId, name: trigger.getAttribute("data-playlist-name") || "Playlist Songs", platform: trigger.getAttribute("data-playlist-platform") || "", remote_playlist_id: trigger.getAttribute("data-playlist-remote-id") || "", }); setPlaylistModalOpen(true); setPlaylistModalState("Loading songs...", false); if (Object.prototype.hasOwnProperty.call(playlistPreviewCache, playlistId)) { applyPlaylistPreviewPayload(playlistPreviewCache[playlistId]); return; } if (playlistPreviewInFlight[playlistId]) { return; } playlistPreviewInFlight[playlistId] = fetchJson( "/api/playlists/" + encodeURIComponent(playlistId) + "/songs" ) .then(function (payload) { playlistPreviewCache[playlistId] = payload || {}; if (currentPlaylistPreviewId === playlistId) { applyPlaylistPreviewPayload(payload || {}); } }) .catch(function (error) { if (currentPlaylistPreviewId === playlistId) { currentPlaylistPreviewPayload = null; setPlaylistModalState(error.message || "request failed", true); } }) .finally(function () { delete playlistPreviewInFlight[playlistId]; }); } function closePlaylistPreview() { currentPlaylistPreviewId = ""; currentPlaylistPreviewPayload = null; setPlaylistModalOpen(false); } checkboxNodes.forEach(function (node) { node.addEventListener("change", updateSelectionCount); }); if (selectAllButton) { selectAllButton.addEventListener("click", function () { checkboxNodes.forEach(function (node) { node.checked = true; }); updateSelectionCount(); }); } if (clearSelectionButton) { clearSelectionButton.addEventListener("click", function () { checkboxNodes.forEach(function (node) { node.checked = false; }); updateSelectionCount(); }); } root.addEventListener("click", function (event) { var eventTarget = eventTargetElement(event); if (!eventTarget) { return; } var previewTrigger = eventTarget.closest("[data-playlist-open-songs]"); if (previewTrigger) { openPlaylistPreview(previewTrigger); return; } var closeTrigger = eventTarget.closest("[data-playlist-modal-close]"); if (closeTrigger) { closePlaylistPreview(); return; } var exportTrigger = eventTarget.closest("[data-playlist-export]"); if (exportTrigger) { if (!currentPlaylistPreviewPayload) { showMessage("No playlist preview is loaded yet.", true); return; } var exportPlaylist = currentPlaylistPreviewPayload.playlist || {}; var exportPlaylistId = exportPlaylist.id; if (!exportPlaylistId) { showMessage("Playlist id is missing.", true); return; } if (!playlistPreviewLooksExportReady(currentPlaylistPreviewPayload)) { showMessage("Playlist is not ready for export yet.", true); return; } startBrowserDownload( "/api/playlists/" + encodeURIComponent(exportPlaylistId) + "/export.zip" ); } }); actionButtons.forEach(function (button) { button.addEventListener("click", function () { if (playlistActionInFlight) { return; } var action = button.getAttribute("data-playlist-action"); var endpoint = endpointMap[action || ""]; if (!endpoint) { showMessage( "Unsupported playlist action in current script. Please hard refresh (Ctrl+F5).", true ); return; } var playlistIds = getSelectedPlaylistIds(); if (!playlistIds.length) { showMessage("Please select at least one playlist.", true); return; } playlistActionInFlight = true; setPlaylistActionDisabled(true); postJson(endpoint, { playlist_ids: playlistIds }) .then(function (data) { if (action === "export-selected") { handleExportSelectedResponse(data); return; } if (action === "download" && handleDownloadSelectedResponse(data)) { return; } if (data.job && data.job.id) { window.location.href = "/jobs/" + data.job.id; return; } window.location.reload(); }) .catch(function (error) { showMessage(error.message || "request failed", true); }) .finally(function () { playlistActionInFlight = false; setPlaylistActionDisabled(false); }); }); }); updateSelectionCount(); } bindJsonForms(); bindTaskCenter(); bindLiveSnapshot(); bindPlaylistPage(); refreshDashboardFromApi(false); })();