feat: add automatic media normalization, R2 retention cleanup, and seamless playout loops
This commit is contained in:
1 parent
e15d89edfe
commit
06ab5b4655
16 files changed
+1103
-408
No files matched your search
+88
-13
@@ -480,6 +480,37 @@ async function deleteOverride(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderServiceRow(service: any) {
|
||||
const svcDetail = service.detail || "";
|
||||
|
||||
// playout service returns json detail, all others return ISO formatted time string
|
||||
if (service.name === "playout" && svcDetail) {
|
||||
try {
|
||||
// create a list of key-value pairs for each property in the JSON object
|
||||
const detailObj = JSON.parse(svcDetail);
|
||||
service.detail = Object.entries(detailObj)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const row = document.createElement("article");
|
||||
row.className = "service-row";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<h3>${service.name}</h3>
|
||||
<p class="small-muted">${service.checked}</p>
|
||||
</div>
|
||||
<div class="service-row-right">
|
||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
||||
<p>${service.detail}</p>
|
||||
</div>
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
async function refreshSystemSummary() {
|
||||
setStatus("system-status", "Loading system summary...");
|
||||
try {
|
||||
@@ -499,19 +530,9 @@ async function refreshSystemSummary() {
|
||||
servicesEl.innerHTML = "";
|
||||
const services = (payload.services || []) as ServiceHealth[];
|
||||
services.forEach((service) => {
|
||||
const row = document.createElement("article");
|
||||
row.className = "service-row";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<h3>${service.name}</h3>
|
||||
<p class="small-muted">${service.checked}</p>
|
||||
</div>
|
||||
<div class="service-row-right">
|
||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
||||
<p>${service.detail}</p>
|
||||
</div>
|
||||
`;
|
||||
servicesEl.appendChild(row);
|
||||
renderServiceRow(service).then((row) => {
|
||||
servicesEl.appendChild(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -534,6 +555,7 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
|
||||
files.forEach((file) => {
|
||||
const tr = document.createElement("tr");
|
||||
const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed";
|
||||
tr.innerHTML = `
|
||||
<td>${file.name}</td>
|
||||
<td>${file.title || "-"}</td>
|
||||
@@ -543,10 +565,14 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
<td>${file.modifiedAt || "-"}</td>
|
||||
<td class="action-cell">
|
||||
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
||||
${canNormalize ? `<button type="button" class="btn btn-secondary btn-small" data-action="normalize" data-id="${file.id || ""}">Normalize</button>` : ""}
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
||||
if (canNormalize) {
|
||||
tr.querySelector('[data-action="normalize"]')?.addEventListener("click", () => normalizeMedia(file));
|
||||
}
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -699,6 +725,55 @@ async function editMedia(file: MediaEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeMedia(file: MediaEntry) {
|
||||
if (!file.id) {
|
||||
setStatus("media-status", "Cannot normalize media item without id.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("media-status", `Normalizing ${file.name}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${file.id}/normalize`, { method: "POST" });
|
||||
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||||
|
||||
setStatus("media-status", `Normalization started for ${file.name}.`);
|
||||
await refreshMediaLibrary();
|
||||
pollNormalizationStatus(file.id, 0);
|
||||
} catch (error) {
|
||||
console.error("Failed to normalize media", error);
|
||||
const details = error instanceof Error ? error.message : "Unknown error.";
|
||||
setStatus("media-status", `Failed to normalize ${file.name}. (${details})`, true);
|
||||
}
|
||||
}
|
||||
|
||||
function pollNormalizationStatus(id: number, attempt: number) {
|
||||
if (attempt >= 60) {
|
||||
setStatus("media-status", "Normalization is taking longer than expected. Reload the library to check.", true);
|
||||
return;
|
||||
}
|
||||
window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${id}`);
|
||||
if (!response.ok) return;
|
||||
const file = (await response.json()) as MediaEntry;
|
||||
if (file.status === "normalizing" || file.status === "needs_normalization") {
|
||||
pollNormalizationStatus(id, attempt + 1);
|
||||
return;
|
||||
}
|
||||
await refreshMediaLibrary();
|
||||
await refreshOverview();
|
||||
await refreshSchedule();
|
||||
setStatus(
|
||||
"media-status",
|
||||
file.status === "ready" ? `Normalization complete for ${file.name}.` : `Normalization failed for ${file.name}.`,
|
||||
file.status !== "ready",
|
||||
);
|
||||
} catch {
|
||||
// Stop polling on error; the user can reload the library manually.
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function deleteMedia(file: MediaEntry) {
|
||||
if (!file.id) {
|
||||
setStatus("media-status", "Cannot delete media item without id.", true);
|
||||
|
||||
Reference in new issue
Block a user