Archilyzer · Source

archilyzer

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

commit 103b21f4e2e2bd5eaf081d2d05eec6f8f0e1533a
parent ed3957260bcc623fc8bf1051b81a8a31273e8557
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 16 Apr 2026 22:13:52 -0400

optimize for bigger dataset

Diffstat:
Mapp/PlayerProvider.tsx | 49+++++++++++++++++++++++++++----------------------
Mapp/TranscriptSearch.tsx | 163++++++++++++++++++++++++++++---------------------------------------------------
Mapp/page.tsx | 21+++++----------------
Aapp/summaries/route.ts | 7+++++++
Aapp/summariesCache.ts | 17+++++++++++++++++
Mlib/transcripts.ts | 28++++++++++++++++++++++++++++
6 files changed, 141 insertions(+), 144 deletions(-)

diff --git a/app/PlayerProvider.tsx b/app/PlayerProvider.tsx @@ -13,6 +13,7 @@ import { import type ReactPlayerType from "react-player"; import { fetchTranscript } from "./transcriptCache"; import { useUrlParams, writeUrlParams } from "./urlState"; +import { formatDate, formatDuration } from "@/lib/format"; const ReactPlayer = dynamic(() => import("react-player/youtube"), { ssr: false, @@ -20,7 +21,7 @@ const ReactPlayer = dynamic(() => import("react-player/youtube"), { export type Cue = { start: number; end: number; text: string }; -export type TranscriptSummary = { +export type TranscriptData = { slug: string; id: string; title: string; @@ -28,9 +29,6 @@ export type TranscriptSummary = { date: string; duration: string; channel: string; -}; - -export type TranscriptData = TranscriptSummary & { description?: string; cues?: Cue[]; }; @@ -40,6 +38,11 @@ export type DisplayMode = "modal" | "mini" | "hidden"; type Detail = { slug: string; + id: string; + title: string; + uploadDate: string; + duration: number; + channel: string; description?: string; cues?: Cue[]; }; @@ -88,10 +91,8 @@ function toHMS(seconds: number): string { export function PlayerProvider({ children, - transcripts, }: { children: React.ReactNode; - transcripts: TranscriptSummary[]; }) { const { v: urlSlug, t: urlTime } = useUrlParams(); const [detail, setDetail] = useState<Detail | null>(null); @@ -110,25 +111,25 @@ export function PlayerProvider({ const pendingSeekRef = useRef<number | null>(null); const readyForSlugRef = useRef<string | null>(null); - const bySlug = useMemo(() => { - const m = new Map<string, TranscriptSummary>(); - for (const t of transcripts) m.set(t.slug, t); - return m; - }, [transcripts]); - const activeSlug = urlSlug; const displayMode: DisplayMode = displayState?.slug === activeSlug ? displayState.mode : "modal"; const modalOpen = activeSlug !== null && displayMode === "modal"; - const summary = activeSlug ? bySlug.get(activeSlug) ?? null : null; const detailMatches = detail?.slug === activeSlug; const data: TranscriptData | null = useMemo(() => { - if (!summary) return null; - if (detailMatches && detail) { - return { ...summary, description: detail.description, cues: detail.cues }; - } - return { ...summary }; - }, [summary, detail, detailMatches]); + if (!detailMatches || !detail) return null; + return { + slug: detail.slug, + id: detail.id, + title: detail.title, + uploadDate: detail.uploadDate, + date: formatDate(detail.uploadDate), + duration: formatDuration(detail.duration), + channel: detail.channel, + description: detail.description, + cues: detail.cues, + }; + }, [detail, detailMatches]); const status: Status = !activeSlug ? "idle" : detailMatches @@ -213,7 +214,6 @@ export function PlayerProvider({ useEffect(() => { if (!urlSlug) return; - if (!bySlug.has(urlSlug)) return; readyForSlugRef.current = null; pendingSeekRef.current = urlTime ?? 0; let cancelled = false; @@ -222,20 +222,25 @@ export function PlayerProvider({ if (cancelled) return; setDetail({ slug: urlSlug, + id: full.id, + title: full.title, + uploadDate: full.uploadDate, + duration: full.duration, + channel: full.channel, description: full.description, cues: full.cues, }); }) .catch(() => { if (cancelled) return; - setDetail({ slug: urlSlug }); + setDetail(null); }); return () => { cancelled = true; }; // urlTime intentionally omitted — t changes shouldn't refetch // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlSlug, bySlug]); + }, [urlSlug]); useEffect(() => { if (urlTime === null) return; diff --git a/app/TranscriptSearch.tsx b/app/TranscriptSearch.tsx @@ -3,18 +3,11 @@ import { useEffect, useMemo, useState } from "react"; import { usePlayer } from "./PlayerProvider"; import { fetchTranscript } from "./transcriptCache"; +import { fetchSummaries } from "./summariesCache"; import { useUrlParams, writeUrlParams } from "./urlState"; -import type { TranscriptDetail } from "@/lib/transcripts"; +import type { DisplaySummary, TranscriptDetail } from "@/lib/transcripts"; -type Summary = { - slug: string; - id: string; - title: string; - uploadDate: string; - date: string; - duration: string; - channel: string; -}; +type Summary = DisplaySummary; type Hit = { start: number; @@ -31,11 +24,7 @@ type HitGroup = { const MAX_HITS = 1000; -export default function TranscriptSearch({ - transcripts, -}: { - transcripts: Summary[]; -}) { +export default function TranscriptSearch() { const { q: query, re: useRegex, @@ -52,13 +41,35 @@ export default function TranscriptSearch({ return !(params.has("v") && (params.get("q") ?? "") !== ""); }); + const [transcripts, setTranscripts] = useState<Summary[] | null>(null); const [fetched, setFetched] = useState<Record<string, TranscriptDetail>>({}); + const [hitLimit, setHitLimit] = useState(MAX_HITS); const { openTranscript } = usePlayer(); const trimmed = searchExecuted ? query.trim() : ""; useEffect(() => { + setHitLimit(MAX_HITS); + }, [trimmed, useRegex]); + + useEffect(() => { if (!trimmed) return; + if (transcripts) return; + let cancelled = false; + fetchSummaries() + .then((list) => { + if (cancelled) return; + setTranscripts(list); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [trimmed, transcripts]); + + useEffect(() => { + if (!trimmed) return; + if (!transcripts) return; let cancelled = false; for (const t of transcripts) { if (fetched[t.slug]) continue; @@ -77,8 +88,10 @@ export default function TranscriptSearch({ }, [trimmed, transcripts, fetched]); const loadedCount = Object.keys(fetched).length; - const totalCount = transcripts.length; - const fetching = Boolean(trimmed) && loadedCount < totalCount; + const totalCount = transcripts?.length ?? 0; + const indexLoading = Boolean(trimmed) && transcripts === null; + const fetching = + Boolean(trimmed) && transcripts !== null && loadedCount < totalCount; const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => { if (!trimmed || !useRegex) return { re: null, error: null }; @@ -89,31 +102,13 @@ export default function TranscriptSearch({ } }, [trimmed, useRegex]); - const matches = (haystack: string): boolean => { - if (!trimmed) return true; - if (useRegex) return regex.re ? regex.re.test(haystack) : false; - return haystack.toLowerCase().includes(trimmed.toLowerCase()); - }; - - const summaryBySlug = useMemo(() => { - const m = new Map<string, Summary>(); - for (const t of transcripts) m.set(t.slug, t); - return m; - }, [transcripts]); - - const titleMatches = useMemo(() => { - if (!trimmed) return transcripts; - if (useRegex && !regex.re) return []; - return transcripts.filter((t) => matches(t.title)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [trimmed, transcripts, useRegex, regex.re]); - const { groups, totalHits, capped } = useMemo<{ groups: HitGroup[]; totalHits: number; capped: boolean; }>(() => { if (!trimmed) return { groups: [], totalHits: 0, capped: false }; + if (!transcripts) return { groups: [], totalHits: 0, capped: false }; if (useRegex && !regex.re) return { groups: [], totalHits: 0, capped: false }; const groups: HitGroup[] = []; @@ -147,23 +142,23 @@ export default function TranscriptSearch({ text: crosses ? windowText : cur.text, }); total++; - if (total >= MAX_HITS) break; + if (total >= hitLimit) break; } if (hits.length > 0) { groups.push({ slug: t.slug, title: t.title, channel: t.channel, - date: summaryBySlug.get(t.slug)?.date ?? "", + date: t.date, hits, }); } - if (total >= MAX_HITS) { + if (total >= hitLimit) { return { groups, totalHits: total, capped: true }; } } return { groups, totalHits: total, capped: false }; - }, [trimmed, transcripts, fetched, summaryBySlug, useRegex, regex.re]); + }, [trimmed, transcripts, fetched, useRegex, regex.re, hitLimit]); return ( <div className="flex flex-col gap-6"> @@ -182,7 +177,7 @@ export default function TranscriptSearch({ placeholder={ useRegex ? "Regex pattern (case-insensitive)…" - : "Search titles and transcripts..." + : "Search transcripts..." } className={`w-full rounded-lg border ${ regex.error @@ -214,49 +209,37 @@ export default function TranscriptSearch({ </form> {!trimmed && ( - <TranscriptGrid - items={transcripts} - onOpen={(slug) => openTranscript(slug)} - activeSlug={activeVideo} - /> + <p className="text-sm text-zinc-500"> + Enter a query to search transcripts. + </p> )} {trimmed && ( <> <section> - <h2 className="text-sm font-medium text-zinc-500 mb-2"> - Titles ({titleMatches.length}) - </h2> - {titleMatches.length > 0 ? ( - <TranscriptGrid - items={titleMatches} - onOpen={(slug) => openTranscript(slug)} - activeSlug={activeVideo} - /> - ) : ( - <p className="text-sm text-zinc-500">No title matches.</p> - )} - </section> - - <section> <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2"> <span> Transcript mentions{" "} {totalHits > 0 - ? `(${capped ? `${MAX_HITS}+` : totalHits} mention${ + ? `(${totalHits}${capped ? "+" : ""} mention${ totalHits === 1 ? "" : "s" } across ${groups.length} video${ groups.length === 1 ? "" : "s" })` : "(0)"} </span> + {indexLoading && ( + <span className="text-xs text-zinc-400 font-normal"> + loading index… + </span> + )} {fetching && ( <span className="text-xs text-zinc-400 font-normal"> loaded {loadedCount}/{totalCount}… </span> )} </h2> - {groups.length === 0 && !fetching && ( + {!indexLoading && groups.length === 0 && !fetching && ( <p className="text-sm text-zinc-500">No transcript mentions.</p> )} {groups.length > 0 && ( @@ -310,6 +293,17 @@ export default function TranscriptSearch({ ))} </div> )} + {capped && ( + <div className="mt-3 flex justify-center"> + <button + type="button" + onClick={() => setHitLimit((l) => l + MAX_HITS)} + className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" + > + Load more + </button> + </div> + )} </section> </> )} @@ -317,49 +311,6 @@ export default function TranscriptSearch({ ); } -function TranscriptGrid({ - items, - onOpen, - activeSlug, -}: { - items: Summary[]; - onOpen: (slug: string) => void; - activeSlug?: string | null; -}) { - return ( - <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800 border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"> - {items.map((t) => { - const isActive = activeSlug === t.slug; - return ( - <li key={t.slug}> - <button - type="button" - onClick={() => onOpen(t.slug)} - className={`w-full text-left flex items-baseline gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ - isActive - ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" - : "" - }`} - > - <span className="text-xs font-mono text-zinc-500 shrink-0 w-24"> - {t.date} - </span> - <span className="flex-1 min-w-0 font-medium truncate"> - {t.title} - </span> - {t.duration && ( - <span className="text-xs font-mono text-zinc-500 shrink-0"> - {t.duration} - </span> - )} - </button> - </li> - ); - })} - </ul> - ); -} - function findFirstMatchInRange( haystack: string, rangeStart: number, diff --git a/app/page.tsx b/app/page.tsx @@ -1,4 +1,4 @@ -import { listTranscripts, formatDate, formatDuration } from "@/lib/transcripts"; +import { countTranscripts } from "@/lib/transcripts"; import { getSettings } from "@/lib/settings"; import { PlayerProvider } from "./PlayerProvider"; import TranscriptModal from "./TranscriptModal"; @@ -6,29 +6,18 @@ import TranscriptSearch from "./TranscriptSearch"; export default async function Home() { const settings = getSettings(); - const transcripts = await listTranscripts(); - const summaries = transcripts.map((t) => ({ - slug: t.slug, - id: t.id, - title: t.title, - uploadDate: t.uploadDate, - date: formatDate(t.uploadDate), - duration: formatDuration(t.duration), - channel: t.channel, - })); + const count = await countTranscripts(); return ( - <PlayerProvider transcripts={summaries}> + <PlayerProvider> <div className="flex flex-col gap-6"> <div> - <h1 className="text-2xl font-semibold"> - {summaries.length} transcripts - </h1> + <h1 className="text-2xl font-semibold">{count} transcripts</h1> {settings.homeTagline && ( <p className="text-sm text-zinc-500 mt-1">{settings.homeTagline}</p> )} </div> - <TranscriptSearch transcripts={summaries} /> + <TranscriptSearch /> <section className="border-t border-zinc-200 dark:border-zinc-800 pt-4 text-sm text-zinc-500 flex gap-4"> <span>Downloads:</span> <a diff --git a/app/summaries/route.ts b/app/summaries/route.ts @@ -0,0 +1,7 @@ +import { listDisplaySummaries } from "@/lib/transcripts"; + +export const dynamic = "force-static"; + +export async function GET() { + return Response.json(await listDisplaySummaries()); +} diff --git a/app/summariesCache.ts b/app/summariesCache.ts @@ -0,0 +1,17 @@ +"use client"; + +import type { DisplaySummary } from "@/lib/transcripts"; + +let promise: Promise<DisplaySummary[]> | null = null; + +export function fetchSummaries(): Promise<DisplaySummary[]> { + if (promise) return promise; + promise = fetch("/summaries").then((r) => { + if (!r.ok) throw new Error(`Failed to fetch summaries: ${r.status}`); + return r.json() as Promise<DisplaySummary[]>; + }); + promise.catch(() => { + promise = null; + }); + return promise; +} diff --git a/lib/transcripts.ts b/lib/transcripts.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { parseVtt, type Cue } from "./vtt"; import { readFile, readdir } from "fs-extra"; +import { formatDate, formatDuration } from "./format"; const ROOT = path.join(process.cwd(), "transcripts", "data"); @@ -14,6 +15,16 @@ export type TranscriptSummary = { description: string; }; +export type DisplaySummary = { + slug: string; + id: string; + title: string; + uploadDate: string; + date: string; + duration: string; + channel: string; +}; + export type TranscriptDetail = TranscriptSummary & { cues: Cue[] | undefined; }; @@ -73,6 +84,23 @@ export async function listTranscripts(): Promise<TranscriptSummary[]> { ); } +export async function countTranscripts(): Promise<number> { + return (await listSlugs()).length; +} + +export async function listDisplaySummaries(): Promise<DisplaySummary[]> { + const summaries = await listTranscripts(); + return summaries.map((t) => ({ + slug: t.slug, + id: t.id, + title: t.title, + uploadDate: t.uploadDate, + date: formatDate(t.uploadDate), + duration: formatDuration(t.duration), + channel: t.channel, + })); +} + export async function loadTranscript(slug: string): Promise<TranscriptDetail> { const meta = await readMetadata(slug); const cues = await readTranscript(slug);