add: show metadata, streams, and clock-time ETA in status UI
Surface the fetched job metadata (show/episode or movie title, season, episode, release/airdate, media type) and the source audio/subtitle streams through the tracker and /status, and render them in the dashboard's current-job card. Audio channel counts are now probed and shown as 5.1/2.0/etc. ETA now reads as a wall-clock finish time plus remaining duration, e.g. "17:49 (3h52m)". Recent events drop the per-row date for a WhatsApp-style Today/Yesterday/date divider with time-only rows. All dynamic strings are HTML-escaped, since metadata and filenames are user-controlled.
This commit is contained in:
+112
-12
@@ -75,6 +75,23 @@
|
||||
.ev time { font-family:var(--mono); font-size:.7rem; color:var(--dim); white-space:nowrap; padding-top:.1rem; }
|
||||
.ev .msg { color:var(--text); word-break:break-word; }
|
||||
.ev.error .msg { color:var(--red); }
|
||||
.day { text-align:center; font-family:var(--mono); font-size:.64rem; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin:.8rem 0 .2rem; }
|
||||
.day:first-child { margin-top:0; }
|
||||
|
||||
/* job metadata */
|
||||
.meta { margin-top:1.1rem; padding-top:1.1rem; border-top:1px solid var(--line); }
|
||||
.meta .mtitle { font-size:1.1rem; color:#e7edf4; font-weight:600; word-break:break-word; }
|
||||
.meta .msub { color:var(--amber-soft); font-size:.92rem; margin-top:.15rem; }
|
||||
.meta .mrow { color:var(--muted); font-size:.85rem; margin-top:.3rem; }
|
||||
.meta .mrow b { color:var(--text); font-weight:500; }
|
||||
|
||||
/* stream chips */
|
||||
.streams { display:flex; flex-direction:column; gap:.5rem; margin-top:1rem; }
|
||||
.srow { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; }
|
||||
.srow .lab { font-family:var(--mono); font-size:.64rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); width:4rem; flex:none; padding-top:.25rem; }
|
||||
.chip { font-family:var(--mono); font-size:.74rem; padding:.2rem .5rem; border-radius:5px; background:var(--panel-2); border:1px solid var(--line); color:var(--text); }
|
||||
.chip .ch { color:var(--amber); }
|
||||
.chip .cd { color:var(--dim); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -103,6 +120,13 @@
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
// Metadata, filenames, and event messages all originate from user-supplied
|
||||
// filenames — escape every dynamic string before it touches innerHTML.
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||||
({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c]));
|
||||
}
|
||||
|
||||
function fmtDur(sec) {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
if (sec <= 0) return "--";
|
||||
@@ -112,7 +136,62 @@
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// "17:49 (3h52m)" — wall-clock finish time plus remaining duration.
|
||||
function etaText(sec) {
|
||||
if (!sec || sec <= 0) return "--";
|
||||
const done = new Date(Date.now() + sec * 1000);
|
||||
const p = n => String(n).padStart(2, "0");
|
||||
return `${p(done.getHours())}:${p(done.getMinutes())} (${fmtDur(sec)})`;
|
||||
}
|
||||
|
||||
function chLabel(n) { return ({1:"mono",2:"2.0",6:"5.1",8:"7.1"})[n] || (n ? n+"ch" : ""); }
|
||||
function base(path) { return (path || "").split("/").pop(); }
|
||||
function pad2(s) { return String(s || "").padStart(2, "0"); }
|
||||
|
||||
function jobHeading(c) {
|
||||
const m = c.meta;
|
||||
if (m && m.is_series && m.collection) {
|
||||
const se = (m.season && m.episode) ? `S${pad2(m.season)}E${pad2(m.episode)}` : "";
|
||||
return { title: m.collection, sub: [se, m.title].filter(Boolean).join(" · ") };
|
||||
}
|
||||
if (m && m.title && m.title !== "Unknown") return { title: m.title, sub: "" };
|
||||
return { title: base(c.file), sub: "" };
|
||||
}
|
||||
|
||||
function renderStreams(streams) {
|
||||
if (!streams || !streams.length) return "";
|
||||
const audio = streams.filter(s => s.kind === "audio");
|
||||
const subs = streams.filter(s => s.kind === "subtitle");
|
||||
let html = "";
|
||||
if (audio.length) {
|
||||
html += `<div class="srow"><span class="lab">Audio</span>` + audio.map(s => {
|
||||
const ch = chLabel(s.channels), codec = esc(s.codec || "");
|
||||
return `<span class="chip">${esc(s.language || "und")}` +
|
||||
`${ch ? ` <span class="ch">${ch}</span>` : ""}` +
|
||||
`${codec ? ` <span class="cd">${codec}</span>` : ""}</span>`;
|
||||
}).join("") + `</div>`;
|
||||
}
|
||||
if (subs.length) {
|
||||
html += `<div class="srow"><span class="lab">Subs</span>` +
|
||||
subs.map(s => `<span class="chip">${esc(s.language || "und")}</span>`).join("") + `</div>`;
|
||||
}
|
||||
return html ? `<div class="streams">${html}</div>` : "";
|
||||
}
|
||||
|
||||
function renderMeta(c) {
|
||||
const m = c.meta, h = jobHeading(c);
|
||||
let rows = "";
|
||||
if (m) {
|
||||
if (m.date_released) rows += `<div class="mrow">${m.is_series ? "Aired" : "Released"} <b>${esc(m.date_released)}</b></div>`;
|
||||
if (m.media_type) rows += `<div class="mrow">Source <b>${esc(m.media_type)}</b></div>`;
|
||||
}
|
||||
return `<div class="meta">
|
||||
<div class="mtitle">${esc(h.title)}</div>
|
||||
${h.sub ? `<div class="msub">${esc(h.sub)}</div>` : ""}
|
||||
${rows}
|
||||
${renderStreams(c.streams)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderJob(c) {
|
||||
const body = $("job-body");
|
||||
@@ -126,17 +205,18 @@
|
||||
const barW = encoding ? c.percent : 0;
|
||||
body.innerHTML = `
|
||||
<div class="job-head">
|
||||
<span class="job-file">${base(c.file)}</span>
|
||||
<span class="badge" data-phase="${c.phase}">${c.phase}</span>
|
||||
<span class="job-file">${esc(base(c.file))}</span>
|
||||
<span class="badge" data-phase="${esc(c.phase)}">${esc(c.phase)}</span>
|
||||
</div>
|
||||
<div class="pct${encoding ? "" : " idle"}">${pct}</div>
|
||||
<div class="${barClass}" role="progressbar" aria-valuenow="${encoding ? Math.round(c.percent) : 0}" aria-valuemin="0" aria-valuemax="100"><i style="width:${barW}%"></i></div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="v">${(c.fps||0).toFixed(1)}</div><div class="k">fps</div></div>
|
||||
<div class="stat"><div class="v">${(c.speed||0).toFixed(2)}×</div><div class="k">speed</div></div>
|
||||
<div class="stat"><div class="v">${fmtDur(c.eta_sec)}</div><div class="k">eta</div></div>
|
||||
<div class="stat"><div class="v">${etaText(c.eta_sec)}</div><div class="k">eta</div></div>
|
||||
<div class="stat"><div class="v">${fmtDur(c.elapsed_sec)}</div><div class="k">elapsed</div></div>
|
||||
</div>`;
|
||||
</div>
|
||||
${renderMeta(c)}`;
|
||||
}
|
||||
|
||||
function renderQueue(queue, current) {
|
||||
@@ -144,18 +224,38 @@
|
||||
const pending = (queue || []).filter(f => f !== cur);
|
||||
$("qcount").textContent = pending.length ? `(${pending.length})` : "";
|
||||
$("queue").innerHTML = pending.length
|
||||
? pending.map(f => `<li>${f}</li>`).join("")
|
||||
? pending.map(f => `<li>${esc(f)}</li>`).join("")
|
||||
: `<li class="empty">Nothing waiting</li>`;
|
||||
}
|
||||
|
||||
// WhatsApp-style: a date divider whenever the day changes, time-only per row.
|
||||
function dayLabel(dt) {
|
||||
const t = new Date(), a = new Date(t.getFullYear(), t.getMonth(), t.getDate());
|
||||
const b = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate());
|
||||
const diff = Math.round((a - b) / 86400000);
|
||||
if (diff === 0) return "Today";
|
||||
if (diff === 1) return "Yesterday";
|
||||
return dt.toLocaleDateString(undefined, { year:"numeric", month:"short", day:"numeric" });
|
||||
}
|
||||
function hms(dt) {
|
||||
const p = n => String(n).padStart(2, "0");
|
||||
return `${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`;
|
||||
}
|
||||
|
||||
function renderEvents(recent) {
|
||||
$("events").innerHTML = (recent && recent.length)
|
||||
? recent.map(e => {
|
||||
const t = (e.ts || "").replace("T", " ").replace("Z", "").slice(0, 19);
|
||||
const cls = e.level === "error" ? "ev error" : "ev";
|
||||
return `<div class="${cls}"><time>${t}</time><span class="msg">${e.message}</span></div>`;
|
||||
}).join("")
|
||||
: `<div class="ev"><span class="msg" style="color:var(--dim)">No events yet</span></div>`;
|
||||
if (!recent || !recent.length) {
|
||||
$("events").innerHTML = `<div class="ev"><span class="msg" style="color:var(--dim)">No events yet</span></div>`;
|
||||
return;
|
||||
}
|
||||
let html = "", lastDay = null;
|
||||
for (const e of recent) {
|
||||
const dt = new Date(e.ts);
|
||||
const day = dayLabel(dt);
|
||||
if (day !== lastDay) { html += `<div class="day">${esc(day)}</div>`; lastDay = day; }
|
||||
const cls = e.level === "error" ? "ev error" : "ev";
|
||||
html += `<div class="${cls}"><time>${hms(dt)}</time><span class="msg">${esc(e.message)}</span></div>`;
|
||||
}
|
||||
$("events").innerHTML = html;
|
||||
}
|
||||
|
||||
function setLive(state) {
|
||||
|
||||
Reference in New Issue
Block a user