Files

2309 lines
74 KiB
JavaScript

(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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 (
'<p class="muted">' +
"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) +
"</p>"
);
}
function renderLocalDuplicateGroups(groups) {
if (!groups || !groups.length) {
return '<p class="muted">No duplicate local copies were found.</p>';
}
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 (
'<div class="playlist-song-locations">' +
'<span class="mono">' +
escapeHtml(location.locator || "-") +
"</span>" +
'<span class="muted">' +
escapeHtml(formatFileSizeBytes(sizeText)) +
" | exists " +
escapeHtml(location.file_exists ? "yes" : "no") +
"</span>" +
"</div>"
);
})
.join("");
return (
"<tr>" +
"<td><strong>" +
escapeHtml(group.song_name || "-") +
'</strong><div class="muted">' +
escapeHtml((group.singers || "") + (group.backend_name ? " | " + group.backend_name : "")) +
"</div></td>" +
'<td><div class="playlist-song-locations"><span class="mono">' +
escapeHtml((group.keep && group.keep.locator) || "-") +
"</span></div></td>" +
"<td>" +
duplicateHtml +
"</td>" +
"</tr>"
);
})
.join("");
return (
"<table><thead><tr><th>Song</th><th>Keep</th><th>Duplicates</th></tr></thead><tbody>" +
rowsHtml +
"</tbody></table>"
);
}
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 =
'<tr><td colspan="' + columns + '">' + emptyText + "</td></tr>";
return;
}
container.innerHTML = rows
.map(function (row) {
return "<tr>" + row.join("") + "</tr>";
})
.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 (
'<div class="progress-meta"><span>' +
escapeHtml(progressText || "-") +
"</span><strong>" +
escapeHtml(normalizedPercent) +
'%</strong></div><div class="progress-bar"><div class="progress-fill" style="width: ' +
escapeHtml(normalizedPercent) +
'%;"></div></div>'
);
}
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 = '<tr><td colspan="9">No tasks yet.</td></tr>';
return;
}
bodyNode.innerHTML = rows
.map(function (row) {
var toggleCommand = commandTypeForRow(row) || "";
var toggleLabel = toggleCommand === "resume" ? "&gt;" : "||";
var actions = ['<div class="button-grid">'];
if (toggleCommand) {
actions.push(
'<button type="button" data-task-command-toggle="' +
escapeHtml(row.id) +
'" data-task-command-type="' +
escapeHtml(toggleCommand) +
'">' +
toggleLabel +
"</button>"
);
} else {
actions.push('<span class="muted">-</span>');
}
if (
row &&
(row.can_cancel ||
["queued", "running", "paused", "pause_requested"].indexOf(
String(row.status || "").toLowerCase()
) >= 0)
) {
actions.push(
'<button type="button" class="secondary" data-task-command-cancel="' +
escapeHtml(row.id) +
'">x</button>'
);
}
actions.push("</div>");
return [
'<tr data-task-row="' + escapeHtml(row.id) + '">',
'<td><button type="button" data-task-row-expand="' +
escapeHtml(row.id) +
'" aria-label="Expand task ' +
escapeHtml(row.id) +
'">+</button></td>',
"<td>" + escapeHtml(row.id) + "</td>",
"<td><strong>" +
escapeHtml(row.display_name || row.job_type || "-") +
'</strong><div class="muted">' +
escapeHtml(row.job_type || "-") +
"</div></td>",
"<td>" + escapeHtml(row.status || "-") + "</td>",
"<td>" + escapeHtml(row.scope_summary || "-") + "</td>",
'<td class="progress-cell">' +
progressCellHtml(
row.primary_progress_text || "-",
row.primary_progress_percent || 0
) +
"</td>",
"<td>" + escapeHtml(row.active_worker_count || 0) + "</td>",
"<td>" + escapeHtml(row.queue_label || row.lane_type || "-") + "</td>",
"<td>" + actions.join("") + "</td>",
"</tr>",
'<tr data-task-row-detail="' +
escapeHtml(row.id) +
'" hidden><td colspan="9"><div data-task-row-detail-body="' +
escapeHtml(row.id) +
'">Loading task detail...</div></td></tr>',
].join("");
})
.join("");
}
function renderDetailTable(headers, rows, emptyText) {
if (!rows || !rows.length) {
return '<p class="muted">' + escapeHtml(emptyText) + "</p>";
}
var headHtml = headers
.map(function (header) {
return "<th>" + escapeHtml(header) + "</th>";
})
.join("");
var bodyHtml = rows
.map(function (row) {
return (
"<tr>" +
row
.map(function (cell) {
return "<td>" + cell + "</td>";
})
.join("") +
"</tr>"
);
})
.join("");
return (
"<table><thead><tr>" +
headHtml +
"</tr></thead><tbody>" +
bodyHtml +
"</tbody></table>"
);
}
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 = [
'<span class="status-tag status-' +
escapeHtml(status) +
'">' +
escapeHtml(labelMap[status] || status) +
"</span>",
];
if (song && song.is_non_music_resource) {
parts.push('<span class="status-tag non-music">非音乐资源</span>');
}
return parts.join(" ");
}
function renderSongProgressTable(songRows) {
if (!songRows || !songRows.length) {
return '<p class="muted">No songs in this playlist yet.</p>';
}
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 (
"<tr>" +
"<td>" +
escapeHtml(position) +
"</td>" +
"<td><strong>" +
escapeHtml(song.song_name || "-") +
'</strong><div class="muted">' +
escapeHtml(metaParts || "-") +
"</div></td>" +
"<td>" +
renderSongStatusCell(song) +
"</td>" +
'<td><span class="song-note">' +
escapeHtml(song.status_note || "-") +
"</span></td>" +
"</tr>"
);
})
.join("");
return (
'<table class="song-progress-table"><thead><tr>' +
"<th>#</th><th>Song</th><th>Status</th><th>Note</th>" +
"</tr></thead><tbody>" +
bodyHtml +
"</tbody></table>"
);
}
function renderPlaylistProgressTree(jobId, playlistRows) {
if (!playlistRows || !playlistRows.length) {
return '<p class="muted">No playlist progress.</p>';
}
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 = '<p class="muted">Expand to load songs...</p>';
if (
isExpanded &&
Object.prototype.hasOwnProperty.call(dashboardState.playlistSongCache, key)
) {
detailContent = renderSongProgressTable(dashboardState.playlistSongCache[key] || []);
} else if (isExpanded) {
detailContent = '<p class="muted">Loading songs...</p>';
}
return [
'<tr class="tree-row" data-playlist-row="' + escapeHtml(key) + '">',
"<td>",
'<button type="button" class="tree-toggle" data-playlist-expand="' +
escapeHtml(key) +
'" data-job-id="' +
escapeHtml(jobId) +
'" data-playlist-id="' +
escapeHtml(playlistId) +
'" aria-expanded="' +
(isExpanded ? "true" : "false") +
'" aria-label="Expand playlist ' +
escapeHtml(playlistId) +
'">' +
(isExpanded ? "-" : "+") +
"</button>",
"</td>",
"<td><strong>" +
escapeHtml(row.playlist_name || "-") +
'</strong><div class="muted">' +
escapeHtml(subtitleParts.join(" #") || "-") +
"</div></td>",
'<td class="progress-cell">' +
progressCellHtml(doneText, row.progress_percent || 0) +
"</td>",
"<td>" +
escapeHtml(stateSummary) +
"</td>",
"</tr>",
'<tr class="tree-row-detail" data-playlist-detail-row="' +
escapeHtml(key) +
'"' +
(isExpanded ? "" : " hidden") +
"><td colspan=\"4\"><div data-playlist-detail-body=\"" +
escapeHtml(key) +
"\">" +
detailContent +
"</div></td></tr>",
].join("");
})
.join("");
return (
'<table class="inline-tree"><thead><tr>' +
"<th></th><th>Playlist</th><th>Progress</th><th>State</th>" +
"</tr></thead><tbody>" +
rowHtml +
"</tbody></table>"
);
}
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 [
'<div class="grid">',
'<div><h3>Summary</h3><table><tbody>',
"<tr><th>ID</th><td>" + escapeHtml(job.id || "-") + "</td></tr>",
"<tr><th>Type</th><td>" + escapeHtml(job.job_type || "-") + "</td></tr>",
"<tr><th>Status</th><td>" + escapeHtml(job.status || "-") + "</td></tr>",
"<tr><th>Started</th><td>" + escapeHtml(job.started_at || "-") + "</td></tr>",
"<tr><th>Ended</th><td>" + escapeHtml(job.ended_at || "-") + "</td></tr>",
"</tbody></table></div>",
'<div><h3>Stages</h3>' +
renderDetailTable(
["Stage", "Status", "Done", "Failed"],
stageRows,
"No stages."
) +
"</div>",
'<div><h3>Workers</h3>' +
renderDetailTable(
["Worker", "Status", "Current Item", "Progress"],
workerRows,
"No workers."
) +
"</div>",
'<div><h3>Running Items</h3>' +
renderDetailTable(
["Worker", "Stage", "Item", "Started"],
runningItemRows,
"No running items."
) +
"</div>",
"</div>",
'<div class="task-playlist-tree"><h3>Playlist Progress</h3>' +
renderPlaylistProgressTree(jobId, payload.playlist_progress || []) +
"</div>",
].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" ? "&gt;" : "||";
var actions = ['<div class="button-grid">'];
if (toggleCommand) {
actions.push(
'<button type="button" data-task-command-toggle="' +
escapeHtml(row.id) +
'" data-task-command-type="' +
escapeHtml(toggleCommand) +
'">' +
toggleLabel +
"</button>"
);
} else {
actions.push('<span class="muted">-</span>');
}
if (
row &&
(row.can_cancel ||
["queued", "running", "paused", "pause_requested"].indexOf(
String(row.status || "").toLowerCase()
) >= 0)
) {
actions.push(
'<button type="button" class="secondary" data-task-command-cancel="' +
escapeHtml(row.id) +
'">x</button>'
);
}
actions.push("</div>");
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 = [
'<div class="task-tree-row">',
'<button type="button" class="tree-toggle" data-task-toggle="' +
escapeHtml(row.id) +
'" aria-expanded="false" aria-label="Expand task ' +
escapeHtml(row.id) +
'">+</button>',
'<div class="task-tree-main">',
'<div class="task-tree-title-line">',
"<strong data-task-name></strong>",
'<span class="muted task-tree-meta-inline" data-task-meta-inline></span>',
'<span class="status-tag" data-task-status></span>',
"</div>",
"</div>",
'<div class="task-tree-progress" data-task-progress></div>',
'<div class="task-tree-actions" data-task-actions></div>',
"</div>",
'<div class="task-tree-children" data-task-children="' +
escapeHtml(row.id) +
'" hidden><p class="muted">Expand to load playlists...</p></div>',
].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 =
'<p class="muted" data-task-tree-empty>' + escapeHtml(emptyTaskTreeText(bucket)) + "</p>";
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
? [
'<div class="task-tree-row task-tree-row-child">',
'<button type="button" class="tree-toggle" data-playlist-toggle="' +
escapeHtml(key) +
'" data-job-id="' +
escapeHtml(jobId) +
'" data-playlist-id="' +
escapeHtml(playlistId) +
'" aria-expanded="false" aria-label="Expand playlist ' +
escapeHtml(playlistId) +
'">+</button>',
'<div class="task-tree-main">',
'<div class="task-tree-title-line"><strong data-playlist-name></strong><span class="muted task-tree-meta-inline" data-playlist-meta-inline></span></div>',
"</div>",
'<div class="task-tree-progress" data-playlist-progress></div>',
'<div class="task-tree-state muted" data-playlist-state></div>',
"</div>",
'<div class="task-tree-children task-tree-children-songs" data-playlist-songs="' +
escapeHtml(key) +
'" hidden><p class="muted">Expand to load songs...</p></div>',
].join("")
: [
'<div class="task-tree-row task-tree-row-child task-tree-row-leaf">',
'<span class="tree-spacer" aria-hidden="true"></span>',
'<div class="task-tree-main">',
'<div class="task-tree-title-line"><strong data-playlist-name></strong><span class="muted task-tree-meta-inline" data-playlist-meta-inline></span></div>',
"</div>",
'<div class="task-tree-progress" data-playlist-progress></div>',
'<div class="task-tree-state muted" data-playlist-state></div>',
"</div>",
].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 = '<p class="muted">No playlist progress.</p>';
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 '<p class="muted">No songs in this playlist yet.</p>';
}
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 [
'<div class="task-tree-song">',
'<div class="task-tree-song-index">' + escapeHtml(position) + "</div>",
'<div class="task-tree-song-main">',
'<div class="task-tree-title-line"><strong>' +
escapeHtml(song.song_name || "-") +
'</strong><span class="muted task-tree-meta-inline">' +
escapeHtml(metaParts || "-") +
"</span></div>",
"</div>",
'<div class="task-tree-song-status">' + renderSongStatusCell(song) + "</div>",
'<div class="task-tree-song-note">' + escapeHtml(song.status_note || "-") + "</div>",
"</div>",
].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 = '<p class="muted">Loading songs...</p>';
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 = '<p class="muted">Loading playlists...</p>';
}
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 [
"<td>" + escapeHtml(worker.worker_name || "-") + "</td>",
"<td>" + escapeHtml(worker.status || "-") + "</td>",
"<td>" + escapeHtml(worker.stage_type || "-") + "</td>",
"<td>" + escapeHtml(worker.display_text || "-") + "</td>",
"<td>" + escapeHtml(worker.speed_text || worker.last_progress_text || "-") + "</td>",
];
}),
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 [
'<td><a href="/jobs/' + jobId + '">' + jobId + "</a></td>",
"<td>" + escapeHtml(item.worker_name || "-") + "</td>",
"<td>" + escapeHtml(item.stage_type || "-") + "</td>",
"<td>" + escapeHtml(item.display_name || "-") + "</td>",
"<td>" + escapeHtml(item.started_at || "-") + "</td>",
];
}),
5,
"No running items."
);
renderRows(
document.querySelector("[data-playlist-sources-body]"),
(payload.playlist_sources || []).map(function (row) {
return [
"<td>" + escapeHtml(row.platform || "-") + "</td>",
"<td>" + escapeHtml(row.pool_kind || "-") + "</td>",
"<td>" + escapeHtml(row.pool_name || "-") + "</td>",
"<td>" + escapeHtml(row.playlist_count || 0) + "</td>",
];
}),
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 (
'<div class="playlist-song-locations">' +
'<span class="muted">' +
escapeHtml(labelParts.join(" | ") || "-") +
"</span>" +
'<span class="mono">' +
escapeHtml(urlText) +
"</span>" +
"</div>"
);
}
function renderPlaylistSongs(items) {
if (!modalSongsBodyNode) {
return;
}
if (!items || !items.length) {
modalSongsBodyNode.innerHTML = '<tr><td colspan="7">No songs.</td></tr>';
return;
}
modalSongsBodyNode.innerHTML = items
.map(function (song) {
var uploadedLocations = (song && song.uploaded_locations) || [];
var localPathHtml = song && song.local_file_path
? '<div class="playlist-song-locations mono">' +
escapeHtml(song.local_file_path) +
"</div>"
: "-";
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 (
"<tr>" +
'<td class="mono">' +
escapeHtml(song && song.song_id ? song.song_id : "-") +
"</td>" +
"<td><strong>" +
escapeHtml((song && song.name) || "-") +
'</strong><div class="muted mono">' +
escapeHtml(songMeta || "-") +
"</div></td>" +
"<td>" +
escapeHtml((song && song.singers) || "-") +
"</td>" +
'<td title="' +
escapeHtml((song && song.file_size_bytes) || "") +
'">' +
escapeHtml(formatFileSizeBytes(song && song.file_size_bytes)) +
"</td>" +
"<td>" +
escapeHtml((song && song.ext) || "-") +
"</td>" +
"<td>" +
localPathHtml +
"</td>" +
"<td>" +
uploadedHtml +
"</td>" +
"</tr>"
);
})
.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);
})();