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:
Esa Kataja
2026-06-21 18:23:36 +03:00
parent d38c3ad568
commit 35193c695e
4 changed files with 185 additions and 12 deletions
+3
View File
@@ -45,6 +45,7 @@ type StreamMetadata struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Channels int `json:"channels"`
Language string `json:"tags"`
Title string `json:"title"`
}
@@ -177,6 +178,7 @@ func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]Stream
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Channels int `json:"channels"`
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
@@ -196,6 +198,7 @@ func (e *Encoder) GetStreamLanguages(ctx context.Context, path string) ([]Stream
Index: s.Index,
CodecType: s.CodecType,
CodecName: s.CodecName,
Channels: s.Channels,
Language: lang,
Title: title,
})
+112 -12
View File
@@ -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 =>
({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[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) {
+44
View File
@@ -20,12 +20,34 @@ const (
PhaseEncoding = "encoding"
)
// JobMeta is the fetched metadata for the active job, shaped for display.
type JobMeta struct {
IsSeries bool `json:"is_series"`
Title string `json:"title"` // movie title or episode title
Collection string `json:"collection,omitempty"` // show name (series)
Season string `json:"season,omitempty"`
Episode string `json:"episode,omitempty"`
DateReleased string `json:"date_released,omitempty"` // release date / airdate
MediaType string `json:"media_type,omitempty"`
}
// Stream is one source audio or subtitle stream, for display.
type Stream struct {
Kind string `json:"kind"` // "audio" | "subtitle"
Language string `json:"language,omitempty"`
Codec string `json:"codec,omitempty"`
Channels int `json:"channels,omitempty"` // audio only
Title string `json:"title,omitempty"`
}
// Tracker holds live progress for the active job. Safe for concurrent use:
// the encode goroutine writes, HTTP/log readers call Snapshot.
type Tracker struct {
mu sync.RWMutex
file string
phase string
meta *JobMeta
streams []Stream
totalSec float64 // source duration; 0 until known
outTime float64 // encoded position in seconds
fps float64
@@ -37,6 +59,8 @@ type Tracker struct {
type Snapshot struct {
File string `json:"file"`
Phase string `json:"phase"`
Meta *JobMeta `json:"meta"`
Streams []Stream `json:"streams"`
Percent float64 `json:"percent"`
FPS float64 `json:"fps"`
Speed float64 `json:"speed"`
@@ -55,6 +79,8 @@ func (t *Tracker) Begin(file string) {
defer t.mu.Unlock()
t.file = file
t.phase = PhaseProbing
t.meta = nil
t.streams = nil
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
t.startedAt = time.Now()
}
@@ -65,6 +91,20 @@ func (t *Tracker) SetPhase(p string) {
t.phase = p
}
// SetMeta records the fetched metadata for the active job.
func (t *Tracker) SetMeta(m JobMeta) {
t.mu.Lock()
defer t.mu.Unlock()
t.meta = &m
}
// SetStreams records the source audio/subtitle streams for the active job.
func (t *Tracker) SetStreams(s []Stream) {
t.mu.Lock()
defer t.mu.Unlock()
t.streams = s
}
// SetTotal records the source duration in seconds (from ffprobe).
func (t *Tracker) SetTotal(seconds float64) {
t.mu.Lock()
@@ -85,6 +125,8 @@ func (t *Tracker) Idle() {
defer t.mu.Unlock()
t.file = ""
t.phase = PhaseIdle
t.meta = nil
t.streams = nil
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
t.startedAt = time.Time{}
}
@@ -95,6 +137,8 @@ func (t *Tracker) Snapshot() Snapshot {
s := Snapshot{
File: t.file,
Phase: t.phase,
Meta: t.meta,
Streams: t.streams,
FPS: t.fps,
Speed: t.speed,
StartedAt: t.startedAt,