Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit afa055a1f1cf9beb9a6485bcec4eb7382fdbb3d5
parent 6e391d13390333bdfae1a52c0baa8a132cb0516f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 18 Sep 2026 18:07:58 -0400

report-to-video: the header says WHO, not just what and when

`${channel} · ${title} · ${date} @ ${h:mm:ss}`. A compilation routinely spans a
streamer's own channel, two VOD mirrors and a guest's show; before this every
line looked like the same source and the only way to tell whose room you were in
was to recognise it.

channelName() walks four answers, each because the next is wrong somewhere: a
per-clip `channelTitle` override, the uploader display name the cue record
already carries (free -- that record is loaded for the title and the upload date
anyway, local or from a published shard), `provenance.channel` for the sweep's
own channel, and the raw slug, which is ugly on purpose. chapterTitle() resolves
it through the same function, so the chapter list and the burned-in header
cannot disagree about who is speaking.

THE BYTE-FOR-BYTE PROPERTY IS DELIBERATELY BROKEN FOR CLIPS. Every clip header
in every cut changes, so an old cut and a new one differ on screen; this is a
re-render, not a quiet upgrade. It still holds where no channel can be named,
for card chapter titles, and for `image` entries -- a still has no archived
channel behind it and its line stays `title · date`.

The bench preview is plumbed the resolved name (readClipDetail -> the clip route
and the page -> ClipBench), because a preview that re-derived it is exactly the
drift attribution.mjs exists to prevent. The e2e fixture's cue records now carry
`channel`, so the spec asserts the record path rather than the fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/clip/route.ts | 4++++
Mumtool/components/projects/ClipBench.tsx | 6++++++
Mumtool/components/projects/ClipBenchPage.tsx | 1+
Mumtool/docs/report-video.md | 39++++++++++++++++++++++++++++++++++-----
Mumtool/e2e/clip-bench.spec.ts | 17++++++++++-------
Mumtool/e2e/fixtures/make-fixture.mjs | 4++++
Mumtool/lib/projects/report.mjs | 7++++++-
Mumtool/report-to-video/README.md | 10++++++++--
Mumtool/report-to-video/attribution.mjs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/attribution.test.mjs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/report-to-video/build-video.mjs | 40+++++++++++++++++++++++++++++-----------
11 files changed, 217 insertions(+), 39 deletions(-)

diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts @@ -74,6 +74,10 @@ export async function GET(request: Request) { segmentMtime: seg?.mtimeMs ?? null, sourceTitle: entry?.sourceTitleClean ?? null, uploadDate: entry?.uploadDate ?? null, + // Already resolved (override -> record -> provenance -> slug); the bench + // feeds it straight back into attributionLine's override slot, so the + // preview and the renderer cannot disagree about who is speaking. + sourceChannel: entry?.sourceChannel ?? null, cues: cues?.cues ?? [], punctuationRate: cues?.punctuationRate ?? null, token: await manifestToken(project.dir), diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -115,6 +115,8 @@ export type ClipBenchData = { /** The archived record's own title (already cleaned) and upload date. */ sourceTitle: string | null; uploadDate: string | null; + /** Who the header will name, already resolved server-side. Heads the line. */ + sourceChannel: string | null; /** Neighbours in the cut, computed server-side. Null at either end. */ prev: string | null; next: string | null; @@ -469,6 +471,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { date: draft.date.trim() || null, cite: draft.cite.trim() === "" ? null : Number(draft.cite), start: clip.start, + // The server already walked override -> record -> provenance -> slug, so + // the resolved name goes into the override slot rather than being + // re-derived here from fields this component does not have. + channelTitle: data.sourceChannel, }, { title: data.sourceTitle, uploadDate: data.uploadDate }, ); diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -78,6 +78,7 @@ export default async function ClipBenchPage({ // exists. sourceTitle: entry.sourceTitleClean ?? null, uploadDate: entry.uploadDate ?? null, + sourceChannel: entry.sourceChannel ?? null, prev: i > 0 ? String(clips[i - 1].id) : null, next: i >= 0 && i < clips.length - 1 ? String(clips[i + 1].id) : null, cues: cues?.cues ?? [], diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md @@ -38,6 +38,7 @@ travel. umtool never reorders as a side effect of a window edit. ```jsonc { "type": "clip", "id": "c04", "video": "uyz1_FIqIEk", "channel": "hasanabi-vods3", // optional: which archived channel's cues + "channelTitle": "HasanAbi", // optional: what to CALL that channel on screen "start": 32980.24, "end": 32994.19, // absolute source seconds, 2 dp "cite": 32989, // the second shown in the attribution line "citeUrl": "https://…", // optional: overrides the derived QR target @@ -51,19 +52,47 @@ travel. umtool never reorders as a side effect of a window edit. "lock": true, "lockStart": true, "lockEnd": true } ``` -### The attribution: `title`, `date`, `cite`, `citeUrl`, `quote` +### The attribution: `channelTitle`, `title`, `date`, `cite`, `citeUrl`, `quote` The header line is -`${title ?? cleanTitle(record.title)} · ${date ?? record.uploadDate} @ ${hms(cite ?? start)}`, +`${channel} · ${title ?? cleanTitle(record.title)} · ${date ?? record.uploadDate} @ ${hms(cite ?? start)}`, built by `report-to-video/attribution.mjs` — one module, imported by the renderer, by the clip bench's live preview and by the writer's validation, so a preview cannot promise a line the renderer would not draw. +**The channel heads the line**, and it is not furniture. A compilation routinely +spans a streamer's own channel, two VOD mirrors and a guest's show; before the +channel was there every line looked like the same source, and the only way to +tell whose room you were in was to recognise it. `channelName()` walks four +answers, each because the next one is wrong somewhere: + +1. **`channelTitle`** — the per-clip override. A record's uploader name can be a + handle, a rebrand or an ALL-CAPS mirror name; this is the author saying what + to call it in this cut. Hand-edited, deliberately: it is a fact about a + *channel*, and a per-clip field in a bench invites a cut whose two clips from + one channel disagree about its name. +2. **the cue record's `channel`** — the uploader's display name, carried by both + a local `transcript.cues.json` and a published shard record. Free: that + record is already loaded for the title and the upload date. +3. **`provenance.channel`** — the sweep's own display name, for a clip from the + sweep's own channel. A hand-written manifest has it even when a cue record + predates the field. +4. **the raw slug** — ugly on screen and meant to be. A visibly wrong name is a + thing somebody fixes; a silently empty head just looks like a design. + **`title` and `date` exist because the archive's answer is often the wrong one.** A VOD mirror's `uploadDate` is the date the *copy* was posted, routinely years -after the stream; `destinys-child` overrides both on all 19 clips. Both absent -reproduces the old line byte for byte, and `chapterTitle()` honours the same two -so the mp4's chapter list and its burned-in header cannot disagree. +after the stream; `destinys-child` overrides both on all 19 clips. `chapterTitle()` +honours the same three so the mp4's chapter list and its burned-in header cannot +disagree about who, what or when. + +> **The old "both absent reproduces the previous line byte for byte" property is +> gone for clips, on purpose.** Adding the channel changes every clip header in +> every cut, which is why it is a deliberate re-render rather than a quiet +> upgrade: a cut built before this and a cut built after it differ on screen. It +> still holds where no channel can be named at all, for card chapter titles, and +> for `image` entries — a still's line is `title · date` and never carries a +> channel, because there is no archived channel behind a screenshot. All five are **writable** — from the clip bench, from `PUT /api/report/window`, and from `umtool window --title/--date/--cite/--cite-url/--quote` — under the diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts @@ -231,9 +231,12 @@ test("a bench save shows up on the project page", async ({ page, request }) => { // --------------------------------------------------------------------------- // The attribution. // -// The fixture's cue file says `Fixture source vid1` / `20250101`, so the line -// the renderer would draw for an un-overridden clip is exact rather than -// plausible -- and so is what changes when a clip overrides it. +// The fixture's cue file says `Fixture Channel` / `Fixture source vid1` / +// `20250101`, so the line the renderer would draw for an un-overridden clip is +// exact rather than plausible -- and so is what changes when a clip overrides +// it. The channel heads the line: a cut routinely spans a streamer's own +// channel and two VOD mirrors, and nothing else on screen says which is +// playing. // --------------------------------------------------------------------------- test("the preview is the line the renderer will draw, and the fields rewrite it", async ({ @@ -245,11 +248,11 @@ test("the preview is the line the renderer will draw, and the fields rewrite it" // at `cite ?? start`. That is build-video.mjs's own attributionLine(), // imported rather than re-implemented -- which is the point of asserting it // here rather than in a unit test of the component. - await expect(preview).toHaveText("Fixture source vid1 · 2025-01-01 @ 0:09"); + await expect(preview).toHaveText("Fixture Channel · Fixture source vid1 · 2025-01-01 @ 0:09"); await setField(page, "title", "Monday Mail #11"); await setField(page, "date", "2016-09-12"); - await expect(preview).toHaveText("Monday Mail #11 · 2016-09-12 @ 0:09"); + await expect(preview).toHaveText("Fixture Channel · Monday Mail #11 · 2016-09-12 @ 0:09"); // On disk, through the one writer. Polled rather than read once: a blur // STARTS a PUT, and the preview above is local state until it lands. @@ -259,7 +262,7 @@ test("the preview is the line the renderer will draw, and the fields rewrite it" // And it is the manifest that is being read, not component state. await page.reload(); await expect(page.locator("[data-attrib-preview]")).toHaveText( - "Monday Mail #11 · 2016-09-12 @ 0:09", + "Fixture Channel · Monday Mail #11 · 2016-09-12 @ 0:09", ); await expect(page.locator("[data-attrib-field=title]")).toHaveValue("Monday Mail #11"); @@ -268,7 +271,7 @@ test("the preview is the line the renderer will draw, and the fields rewrite it" await setField(page, "title", ""); await setField(page, "date", ""); await expect(page.locator("[data-attrib-preview]")).toHaveText( - "Fixture source vid1 · 2025-01-01 @ 0:09", + "Fixture Channel · Fixture source vid1 · 2025-01-01 @ 0:09", ); await expect.poll(() => readClip("c02").title).toBeUndefined(); await expect.poll(() => readClip("c02").date).toBeUndefined(); diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -673,6 +673,10 @@ for (const [vid, rows] of Object.entries(CUES)) { { title: `Fixture source ${vid}`, uploadDate: "20250101", + // The uploader's DISPLAY name. A real record (local or from a published + // shard) carries it, and it heads the burned-in attribution line, so a + // fixture without one would exercise only the fallback. + channel: "Fixture Channel", webpageUrl: `https://example.invalid/watch?v=${vid}`, duration: rows[rows.length - 1][1], cues: rows.map(([start, end, text]) => ({ start, end, text })), diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -8,7 +8,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_VARIANT, cachedWindowsFor, findContainingWindow } from "umtool-report-to-video/build-video"; -import { cleanTitle } from "umtool-report-to-video/attribution"; +import { channelName, cleanTitle } from "umtool-report-to-video/attribution"; /** * Where a build's per-entry segments live. @@ -894,6 +894,11 @@ export async function readClipDetail(dir, { manifest = null } = {}) { // them, so "2019" next to a 2016 stream is visible rather than inferred. sourceTitleClean: doc?.title ? cleanTitle(doc.title) : null, uploadDate: doc?.uploadDate ?? null, + // WHO the header will name, resolved here rather than in the component: + // the answer walks the clip's override, the record, the sweep's own + // display name and the slug, and a preview that re-implemented that walk + // is exactly the drift `attribution.mjs` exists to prevent. + sourceChannel: channelName(e, doc ?? {}, m.provenance ?? {}), punctuationRate: doc?.punctuationRate ?? null, endCueText: endCue?.text ?? null, endsSentence, diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -219,11 +219,17 @@ drawn is an interruption which has to earn its place. Nothing is drawn *over* the picture either. The video is **letterboxed between** thin chrome rather than overlaid by it: -- **Header (`headerHeight`, 56 px).** The citation only — cleaned title · upload - date · timestamp. No quote: the clip is already saying it, and burning in a +- **Header (`headerHeight`, 56 px).** The citation only — channel · cleaned + title · upload date · timestamp (`OmniBased · Chillin' · 2022-08-31 @ + 1:58:08`). No quote: the clip is already saying it, and burning in a transcription of speech you can hear is noise. A clip's own `title` and `date` (`YYYY-MM-DD`) replace the record's cleaned title and upload date — for a VOD-mirror channel that is how the header shows the *stream's* date. + **The channel heads the line** because a compilation routinely spans a + streamer's own channel and two VOD mirrors, and nothing else on screen says + which one is playing; it resolves `channelTitle` (per-clip override) → the cue + record's `channel` → `provenance.channel` for the sweep's own channel → the + raw slug, which is ugly on purpose. - **Footer (`footerHeight`, 100 px).** The timeline: one node per milestone, each with a label and a month/year stamp beneath it. Drawn once by `renderFooterAssets()`. diff --git a/umtool/report-to-video/attribution.mjs b/umtool/report-to-video/attribution.mjs @@ -56,20 +56,72 @@ export function uploadDateToIso(d) { } /** + * WHO is speaking, for the head of the header line. + * + * A compilation routinely spans several channels -- a streamer's own channel, + * two VOD mirrors, a guest's show -- and a line that opens with a title alone + * makes the viewer work out which of them they are watching from the furniture. + * So the channel comes first and every clip in a cut reads the same way. + * + * Four answers, in order, and each exists because the one after it is wrong + * somewhere: + * + * 1. `entry.channelTitle` — the per-clip override. A record's uploader name + * can be a handle, a rebrand or an ALL-CAPS mirror name; this is the + * author saying what to call it in this cut. + * 2. `meta.channel` — the uploader name the cue record already carries, local + * or from a published shard. Free: the record is loaded for the title and + * the upload date anyway, so no lookup is added to a build. + * 3. `provenance.channel` — the sweep's own display name, when this clip comes + * from the sweep's own channel. A hand-written manifest has it even when a + * cue record predates the field. + * 4. The raw slug. Ugly on screen and meant to be: it is visibly a thing to + * fix, where a silently empty head would just look like a design. + * + * @param {{ channelTitle?: string|null, channel?: string|null }} entry + * @param {{ channel?: string|null }} [meta] + * @param {{ channel?: string|null, channelSlug?: string|null }} [provenance] + */ +export function channelName(entry, meta = {}, provenance = {}) { + const override = String(entry?.channelTitle ?? "").trim(); + if (override) return override; + const record = String(meta?.channel ?? "").trim(); + if (record) return record; + const slug = String(entry?.channel ?? provenance?.channelSlug ?? "").trim(); + const own = String(provenance?.channel ?? "").trim(); + if (own && (!entry?.channel || slug === String(provenance?.channelSlug ?? "").trim())) { + return own; + } + return slug; +} + +/** * The line burned into the header, from a clip entry and its source's metadata. * - * A clip's own `title` and `date` WIN. The header otherwise prints the upload - * date of the archived copy, which for a VOD mirror is often years after the - * stream — so the override is how a mirrored clip shows the stream's own date. - * Both absent reproduces the line byte for byte. + * `${channel} · ${title} · ${date} @ ${h:mm:ss}`. + * + * A clip's own `channelTitle`, `title` and `date` WIN. The header otherwise + * prints the upload date of the archived copy, which for a VOD mirror is often + * years after the stream — so the override is how a mirrored clip shows the + * stream's own date. + * + * **The channel is not optional furniture.** Before it was there, a cut that + * spanned a streamer's channel and two mirrors made every line look like the + * same source, and the only way to tell a guest's show from the host's was to + * recognise the room. Nothing else on screen says who is talking. * - * @param {{ title?: string|null, date?: string|null, cite?: number|null, start?: number|null }} entry - * @param {{ title?: string|null, uploadDate?: string|null }} meta + * @param {{ title?: string|null, date?: string|null, cite?: number|null, start?: number|null, channel?: string|null, channelTitle?: string|null }} entry + * @param {{ title?: string|null, uploadDate?: string|null, channel?: string|null }} meta + * @param {{ channel?: string|null, channelSlug?: string|null }} [provenance] */ -export function attributionLine(entry, meta) { +export function attributionLine(entry, meta, provenance = {}) { + const who = channelName(entry, meta, provenance); const title = entry.title ?? cleanTitle(meta.title); const date = entry.date ?? uploadDateToIso(meta.uploadDate); - return `${title} · ${date} @ ${hms(entry.cite ?? entry.start ?? 0)}`; + // A cut with no channel to name anywhere reproduces the old line exactly, + // rather than opening on a stray separator. + const head = [who, title].filter((s) => String(s ?? "").length > 0).join(" · "); + return `${head} · ${date} @ ${hms(entry.cite ?? entry.start ?? 0)}`; } /** diff --git a/umtool/report-to-video/attribution.test.mjs b/umtool/report-to-video/attribution.test.mjs @@ -10,6 +10,7 @@ import test from "node:test"; import { attributionLine, + channelName, cleanTitle, hms, imageAttributionLine, @@ -25,14 +26,63 @@ test("hms: hours only when there are any", () => { assert.equal(hms(32989.9), "9:09:49"); }); -test("attributionLine: a clip carries a clock", () => { +test("attributionLine: a clip carries its channel and a clock", () => { assert.equal( - attributionLine({ title: "Monday Mail #11", date: "2016-09-12", cite: 32989 }, {}), - "Monday Mail #11 · 2016-09-12 @ 9:09:49", + attributionLine( + { title: "Chillin'", date: "2022-08-31", cite: 7088 }, + { channel: "OmniBased" }, + ), + "OmniBased · Chillin' · 2022-08-31 @ 1:58:08", + ); + assert.equal( + attributionLine( + { start: 10 }, + { title: "Stream 🙂 !socials", uploadDate: "20250101", channel: "Destiny" }, + ), + "Destiny · Stream · 2025-01-01 @ 0:10", + ); +}); + +test("attributionLine: channelTitle overrides the record", () => { + assert.equal( + attributionLine( + { channelTitle: "Destiny", channel: "omnivods3", title: "Meet the Snarkers", date: "2026-03-13", cite: 43 }, + { channel: "OMNI VODS 3" }, + ), + "Destiny · Meet the Snarkers · 2026-03-13 @ 0:43", + ); +}); + +test("attributionLine: with nothing naming the channel, the slug is on screen", () => { + // Visibly a thing to fix. A silently empty head would just look like a design. + assert.equal( + attributionLine( + { channel: "omnivods3", title: "Chillin'", date: "2022-08-31", cite: 0 }, + {}, + { channelSlug: "omnibased", channel: "OmniBased" }, + ), + "omnivods3 · Chillin' · 2022-08-31 @ 0:00", ); +}); + +test("channelName: the four answers, in order", () => { + const prov = { channelSlug: "omnibased", channel: "OmniBased" }; + // 1. the per-clip override + assert.equal(channelName({ channelTitle: "Destiny", channel: "x" }, { channel: "Y" }, prov), "Destiny"); + // 2. the cue record's own uploader name + assert.equal(channelName({ channel: "destiny" }, { channel: "Destiny" }, prov), "Destiny"); + // 3. the sweep's display name, for the sweep's own channel + assert.equal(channelName({ channel: "omnibased" }, {}, prov), "OmniBased"); + assert.equal(channelName({}, {}, prov), "OmniBased"); + // 4. the raw slug + assert.equal(channelName({ channel: "destiny" }, {}, prov), "destiny"); + assert.equal(channelName({}, {}, {}), ""); +}); + +test("attributionLine: a cut that can name no channel reads as it always did", () => { assert.equal( - attributionLine({ start: 10 }, { title: "Stream 🙂 !socials", uploadDate: "20250101" }), - "Stream · 2025-01-01 @ 0:10", + attributionLine({ title: "Monday Mail #11", date: "2016-09-12", cite: 32989 }, {}), + "Monday Mail #11 · 2016-09-12 @ 9:09:49", ); }); diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -65,7 +65,9 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs"; // The header line, the clock in it and the title cleaner live in one module // the clip bench imports too -- a preview that shows a line this renderer // would never draw is worse than no preview. -import { attributionLine, hms, imageAttributionLine, uploadDateToIso } from "./attribution.mjs"; +import { + attributionLine, channelName, hms, imageAttributionLine, uploadDateToIso, +} from "./attribution.mjs"; const execFileP = promisify(execFile); @@ -228,7 +230,14 @@ function wrap(text, cols) { // reads identically whichever source answered. async function videoMeta(videoId, channelSlug, hints = {}) { const d = await CUES.load(channelSlug, videoId, hints); - return { title: d.title, uploadDate: d.uploadDate, webpageUrl: d.webpageUrl, duration: d.duration }; + // `channel` is the uploader's DISPLAY name, and it heads the attribution + // line. It costs nothing to carry: both sources -- a local + // transcript.cues.json and a published shard record -- already hold it, and + // this record is loaded for the title and the upload date anyway. + return { + title: d.title, uploadDate: d.uploadDate, webpageUrl: d.webpageUrl, + duration: d.duration, channel: d.channel, + }; } // The CONTAINER's duration is max(video, audio), and the audio is longer: the @@ -521,11 +530,15 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, // Written for reference/diffing only — the quote is no longer drawn on screen. await writeFile(quotePath, wrap(`“${entry.quote}”`, 92), "utf8"); - // The header prints the upload date of the ARCHIVED copy, which for a VOD - // mirror is often years after the stream. A clip may carry `date` (the - // stream's own YYYY-MM-DD) and `title` (a display title) to override the - // record's; both absent reproduces the old line byte for byte. - await writeFile(attribPath, attributionLine(entry, meta), "utf8"); + // WHO, what, when, where in it. The channel heads the line because a + // compilation routinely spans a streamer's own channel and two VOD mirrors, + // and nothing else on screen says which one is playing. + // + // The header otherwise prints the upload date of the ARCHIVED copy, which for + // a VOD mirror is often years after the stream. A clip may carry + // `channelTitle`, `date` (the stream's own YYYY-MM-DD) and `title` (a display + // title) to override the record's. + await writeFile(attribPath, attributionLine(entry, meta, provenance), "utf8"); // The picture is the point. Nothing is drawn over it: the video is letterboxed // between a thin citation header and a thin timeline footer, so the source @@ -1578,12 +1591,17 @@ async function chapterTitle(entry, index, provenance) { if (entry.type !== "clip") return entry.title ?? entry.heading ?? `Card ${index + 1}`; try { const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug, { siteChannel: entry.siteChannel, siteVideo: entry.siteVideo }); - // The SAME overrides the header honours. A chapter list that says 2019 for - // a clip whose burned-in line says 2016 is the mp4 disagreeing with itself, - // and only one of the two is on screen while you watch. + // The SAME overrides the header honours, resolved by the SAME functions. A + // chapter list that says 2019 for a clip whose burned-in line says 2016 is + // the mp4 disagreeing with itself, and only one of the two is on screen + // while you watch. The channel is in both for the same reason: a chapter + // list for a cut spanning three mirrors has to say which one each jump + // lands in. + const who = channelName(entry, meta, provenance); const date = entry.date ?? uploadDateToIso(meta.uploadDate); const title = String(entry.title ?? meta.title ?? entry.video); - return `${date} — ${title.length > 60 ? `${title.slice(0, 57)}…` : title}`.trim(); + const tail = `${date} — ${title.length > 60 ? `${title.slice(0, 57)}…` : title}`.trim(); + return who ? `${who} · ${tail}` : tail; } catch { return `${index + 1}. ${entry.video}`; }