2309 lines
74 KiB
JavaScript
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, "<")
|
|
.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 (
|
|
'<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" ? ">" : "||";
|
|
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" ? ">" : "||";
|
|
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);
|
|
})();
|