Archilyzer · Source

archilyzer

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

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:
Meditor/app/channels/[slug]/components/VideoListPane.tsx | 30++++++++++++++++++++++++------
Meditor/app/channels/[slug]/lib/videoRows.ts | 15+++++++++++++++
Meditor/app/channels/[slug]/lib/videoRowsServer.ts | 5+++++
Meditor/app/channels/[slug]/videos/page.tsx | 40++++++++++++++++++++--------------------
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),