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:
@@ -129,6 +129,7 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
|
||||
if err != nil {
|
||||
log.ErrorFile(inputPath, "Getting stream languages", err.Error())
|
||||
}
|
||||
tracker.SetStreams(toStatusStreams(streamLangs))
|
||||
|
||||
mediaType := metadata.ParseMediaType(filename)
|
||||
if mediaType == "" {
|
||||
@@ -176,6 +177,16 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *
|
||||
}
|
||||
meta.OriginalMedia = mediaType
|
||||
|
||||
tracker.SetMeta(status.JobMeta{
|
||||
IsSeries: meta.IsSeries,
|
||||
Title: meta.Title,
|
||||
Collection: meta.Collection,
|
||||
Season: meta.Season,
|
||||
Episode: meta.Episode,
|
||||
DateReleased: meta.DateReleased,
|
||||
MediaType: string(mediaType),
|
||||
})
|
||||
|
||||
job := &types.Job{
|
||||
InputPath: inputPath,
|
||||
MediaType: mediaType,
|
||||
@@ -241,6 +252,21 @@ func failToFailed(path, failedDir string, log *logger.Logger) {
|
||||
}
|
||||
}
|
||||
|
||||
// toStatusStreams maps probed source streams to the display shape, keeping only
|
||||
// audio and subtitle streams (the video stream isn't shown).
|
||||
func toStatusStreams(streams []encoder.StreamMetadata) []status.Stream {
|
||||
var out []status.Stream
|
||||
for _, s := range streams {
|
||||
switch s.CodecType {
|
||||
case "audio":
|
||||
out = append(out, status.Stream{Kind: "audio", Language: s.Language, Codec: s.CodecName, Channels: s.Channels, Title: s.Title})
|
||||
case "subtitle":
|
||||
out = append(out, status.Stream{Kind: "subtitle", Language: s.Language, Codec: s.CodecName, Title: s.Title})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func generateRandomString(length int) string {
|
||||
bytes := make([]byte, length/2+1)
|
||||
rand.Read(bytes)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user