commit 3a79cb922069221041e9845251396abab112c703
parent ba3ac4b279eae097e4b5bb9c07c79d81eb0fc9d2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 13:50:34 -0400
channels: the video list shows titles and searches them
VideoRow gains `title`, filled once per page from readChannelVideoTitles
(index, then the metadata scan store, then metadata.info.json for the
remainder). A row shows the title with the id beneath it; its accessible name
stays `open <id>`. The search box and the server's `?q=` both match id OR
title, case-insensitive (matchesVideoQuery). The embedded detail pane's title
line reads the same map instead of re-parsing metadata.info.json.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 64 insertions(+), 26 deletions(-)
diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx
@@ -8,7 +8,11 @@ import { VirtualRow } from "yt-dlp-transcript-common/components/VirtualRow";
import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
import type { VideoRow, VideoFilter } from "../lib/videoRows";
import type { TagDef } from "../lib/videoTagRows";
-import { filterRows, serializeFilters } from "../lib/videoRows";
+import {
+ filterRows,
+ matchesVideoQuery,
+ serializeFilters,
+} from "../lib/videoRows";
import { QueueControl } from "../../../components/QueueControl";
import {
bulkClearFailedMarkersAction,
@@ -147,9 +151,10 @@ export function VideoListPane({
(r.curatedTags ?? []).some((t) => tagFilter.has(t)),
);
}
- const q = query.trim().toLowerCase();
- if (!q) return filtered;
- return filtered.filter((r) => r.id.toLowerCase().includes(q));
+ if (!query.trim()) return filtered;
+ // Id OR title, case-insensitive — the same predicate the server applies to
+ // `?q=` for the detail pane's prev/next.
+ return filtered.filter((r) => matchesVideoQuery(r, query));
}, [rows, filters, query, tagFilter]);
// VIRTUALIZED. The largest channel has ~11,000 videos, and one <li> + <Link>
@@ -468,7 +473,7 @@ export function VideoListPane({
<div className="flex flex-col gap-2">
<input
type="search"
- placeholder="Search video id…"
+ placeholder="Search title or id…"
value={query}
onChange={(e) => changeQuery(e.target.value)}
aria-label="search videos"
@@ -651,7 +656,20 @@ export function VideoListPane({
aria-current={isActive ? "page" : undefined}
>
<StatusGlyphs row={row} />
- <span className="font-mono text-xs truncate">{row.id}</span>
+ {row.title ? (
+ // Title first, id underneath: the id is still what the
+ // row's accessible name and the URL carry.
+ <span className="min-w-0 flex flex-col">
+ <span className="text-sm truncate" title={row.title}>
+ {row.title}
+ </span>
+ <span className="font-mono text-[11px] text-muted-foreground truncate">
+ {row.id}
+ </span>
+ </span>
+ ) : (
+ <span className="font-mono text-xs truncate">{row.id}</span>
+ )}
{row.running && (
<span
aria-label="job running"
diff --git a/editor/app/channels/[slug]/lib/videoRows.ts b/editor/app/channels/[slug]/lib/videoRows.ts
@@ -11,6 +11,11 @@ export type VideoRowStatus =
export type VideoRow = {
id: string;
+ // What the video is called, when anything on disk says so — the transcript
+ // index, the channel's metadata scan, or data/<id>/metadata.info.json, in
+ // that order (common/controller/videoTitles.ts). Absent means the list shows
+ // the id alone.
+ title?: string;
downloaded: boolean;
transcribed: boolean;
untranscribable: boolean;
@@ -144,6 +149,16 @@ export function filterRows(
// Parse the comma-separated `?filter=` value into a set of valid filters,
// ignoring unknown/legacy tokens (including "all"). A single legacy
// `?filter=transcribed` parses to a one-element set, so old links still work.
+// The search box: a case-insensitive substring of the id OR the title.
+export function matchesVideoQuery(r: VideoRow, q: string): boolean {
+ const needle = q.trim().toLowerCase();
+ if (!needle) return true;
+ return (
+ r.id.toLowerCase().includes(needle) ||
+ (r.title?.toLowerCase().includes(needle) ?? false)
+ );
+}
+
export function parseFilters(raw: string | undefined): Set<VideoFilter> {
const out = new Set<VideoFilter>();
if (!raw) return out;
diff --git a/editor/app/channels/[slug]/lib/videoRowsServer.ts b/editor/app/channels/[slug]/lib/videoRowsServer.ts
@@ -26,6 +26,9 @@ export type ComputeRowsInput = {
failedTranscriptionIds: string[];
runningJobs: JobRecord[];
excludedIds: Set<string>;
+ // id -> title, from readChannelVideoTitles. Optional so a caller that does
+ // not show titles need not pay for them.
+ titles?: ReadonlyMap<string, { title: string }>;
};
export function computeVideoRows(input: ComputeRowsInput): VideoRow[] {
@@ -92,8 +95,10 @@ export function computeVideoRows(input: ComputeRowsInput): VideoRow[] {
else if (inDownloadedNoTranscript) status = "downloaded_no_transcript";
else status = "transcribed";
+ const title = input.titles?.get(id)?.title;
rows.push({
id,
+ ...(title ? { title } : {}),
downloaded,
transcribed,
untranscribable: isUntranscribable,
diff --git a/editor/app/channels/[slug]/videos/page.tsx b/editor/app/channels/[slug]/videos/page.tsx
@@ -1,5 +1,5 @@
import path from "node:path";
-import { readdir, readFile, stat } from "node:fs/promises";
+import { readdir, stat } from "node:fs/promises";
import type { Dirent } from "node:fs";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -26,10 +26,16 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { VideoPanel, type VideoFile } from "../videos/[id]/components/VideoPanel";
import { VideoWorkspace } from "./components/VideoWorkspace";
import { readChannelConfigCached } from "../lib/channelConfigCache";
-import { parseFilters, filterRows, serializeFilters } from "../lib/videoRows";
+import {
+ parseFilters,
+ filterRows,
+ matchesVideoQuery,
+ serializeFilters,
+} from "../lib/videoRows";
import { computeVideoRows, readDataDirVideoIds } from "../lib/videoRowsServer";
import { normalizeBuckets } from "yt-dlp-transcript-common/views/pipeline/stageStatus";
import { attachCuratedTags } from "../lib/videoTagRows";
+import { readChannelVideoTitles } from "yt-dlp-transcript-common/controller/videoTitles";
export const dynamic = "force-dynamic";
@@ -55,20 +61,6 @@ async function loadVideoDir(
return { files };
}
-async function loadVideoTitle(
- channelDataDir: string,
- videoId: string,
-): Promise<string | null> {
- const file = path.join(channelDataDir, videoId, "metadata.info.json");
- try {
- const raw = await readFile(file, "utf8");
- const j = JSON.parse(raw);
- return typeof j?.title === "string" ? j.title : null;
- } catch {
- return null;
- }
-}
-
export async function generateMetadata({
params,
}: {
@@ -140,12 +132,20 @@ export default async function ChannelVideosPage({
const channelDataDir = path.join(paths.channelsDir, slug, "data");
const channelDataDirIds = await readDataDirVideoIds(channelDataDir);
+ // What each video is called: one key range over the transcript index, one
+ // read of the channel's metadata-scan.json, and a head read of
+ // metadata.info.json only for what those two did not name. Measured at
+ // ~80 ms for a synthetic 5,000-id channel (plans/release-8.md, slice V).
+ const titles = await readChannelVideoTitles(paths, slug, [
+ ...new Set([...channelDataDirIds, ...(snapshot.undownloadedIds ?? [])]),
+ ]);
const rows = computeVideoRows({
channelDataDirIds,
snapshot,
failedTranscriptionIds: failedVideoIds,
runningJobs,
excludedIds: excludedDownloadIds,
+ titles,
});
// Curated-tag pins, folded onto the rows so the list can offer Tag chips and
@@ -166,9 +166,8 @@ export default async function ChannelVideosPage({
tagFilter.length === 0 ||
(r.curatedTags ?? []).some((t) => tagFilter.includes(t)),
);
- const q = queryRaw.trim().toLowerCase();
- const orderedRows = q
- ? filteredRows.filter((r) => r.id.toLowerCase().includes(q))
+ const orderedRows = queryRaw.trim()
+ ? filteredRows.filter((r) => matchesVideoQuery(r, queryRaw))
: filteredRows;
const selectedIndex = selectedVideoId
? orderedRows.findIndex((r) => r.id === selectedVideoId)
@@ -192,7 +191,8 @@ export default async function ChannelVideosPage({
const [dirData, title, outcome, availabilityRecord, cov, excludedTrunc] =
await Promise.all([
loadVideoDir(channelDataDir, selectedVideoId),
- loadVideoTitle(channelDataDir, selectedVideoId),
+ // Already read for the list — the title map covers every row.
+ Promise.resolve(titles.get(selectedVideoId)?.title ?? null),
loadDownloadOutcome(videoDir),
loadAvailability(videoDir),
readTranscriptCoverage(videoDir),