feat: add automatic media normalization, R2 retention cleanup, and seamless playout loops

This commit is contained in:
zwitschi committed 2026-08-13 11:59:46 +02:00
1 parent e15d89edfe
commit 06ab5b4655
16 files changed
+1103 -408

No files matched your search

+88 -13
View File
@@ -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);