commit 66cfeb0a8097798cb8466116ea11caaa8ba2478b
parent fb6dc5d7726a72a65ef466e132ec756c7b7de8cc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 26 Apr 2026 19:07:21 -0400
refactor: split into pnpm workspace with common/ and export/ packages
Carves the existing single Next.js app into two workspace packages with no
behavior change: common/ holds reusable lib code and React components,
export/ holds the static-export Next.js app that imports them via
yt-dlp-transcript-common (workspace:*).
- Adds tsconfig.base.json, common/{package,tsconfig}.json, export/{package,tsconfig}.json
- Moves lib/* into common/lib/ and reusable app/* into common/components/
- Moves Next.js entry (page/layout/globals/favicon), config files, public/,
and scripts/build-index.ts into export/
- Adds transpilePackages: ["yt-dlp-transcript-common"] to export's next.config
- common/lib/settings.ts walks up to find the monorepo root so settings.json
resolves regardless of cwd
- Root package.json becomes the workspace root with orchestration scripts
- create-archives.sh and .gitignore updated for the new layout
Verified pnpm --filter export run build produces identical output (13,344
transcripts across 14 pages, 7 channels) and dev mode renders correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat:
56 files changed, 3302 insertions(+), 3184 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
-/node_modules
+**/node_modules
/.pnp
.pnp.*
.yarn/*
@@ -14,8 +14,8 @@
/coverage
# next.js
-/.next/
-/out/
+**/.next/
+**/out/
# production
/build
@@ -37,19 +37,19 @@ yarn-error.log*
.vercel
# typescript
-*.tsbuildinfo
-next-env.d.ts
+**/*.tsbuildinfo
+**/next-env.d.ts
# nested transcripts repo
/transcripts
-# public downloadable repo archives (generate with create-archives.sh)
-/public/code.tar.gz
-/public/code.tar.xz
-/public/transcripts.tar.gz
-/public/transcripts.tar.xz
+# downloadable repo archives (generate with create-archives.sh)
+/export/public/code.tar.gz
+/export/public/code.tar.xz
+/export/public/transcripts.tar.gz
+/export/public/transcripts.tar.xz
# preprocessed transcript data (regenerate with `pnpm build:index`)
-/public/summaries/
-/public/transcripts/
+/export/public/summaries/
+/export/public/transcripts/
/transcripts/index.mdb/
diff --git a/app/PlayerProvider.tsx b/app/PlayerProvider.tsx
@@ -1,512 +0,0 @@
-"use client";
-
-import dynamic from "next/dynamic";
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import type ReactPlayerType from "react-player";
-import { fetchTranscript } from "./transcriptCache";
-import { useUrlParams, writeUrlParams } from "./urlState";
-import { formatDate, formatDuration } from "@/lib/format";
-import type { Platform } from "@/lib/transcripts";
-import type { RumblePlayerHandle } from "./RumblePlayer";
-import type { OdyseePlayerHandle } from "./OdyseePlayer";
-
-const ReactPlayer = dynamic(() => import("react-player/youtube"), {
- ssr: false,
-});
-
-const RumblePlayer = dynamic(() => import("./RumblePlayer"), {
- ssr: false,
-});
-
-const OdyseePlayer = dynamic(() => import("./OdyseePlayer"), {
- ssr: false,
-});
-
-type PlayerHandle =
- | Pick<ReactPlayerType, "seekTo">
- | RumblePlayerHandle
- | OdyseePlayerHandle;
-
-export type Cue = { start: number; end: number; text: string };
-
-export type TranscriptData = {
- slug: string;
- id: string;
- title: string;
- uploadDate: string;
- date: string;
- duration: string;
- channel: string;
- description?: string;
- isLivestream: boolean;
- ageRestricted: boolean;
- platform: Platform;
- webpageUrl: string;
- cues?: Cue[];
-};
-
-type Status = "idle" | "loading" | "ready";
-export type DisplayMode = "modal" | "mini" | "hidden";
-
-type Detail = {
- slug: string;
- id: string;
- title: string;
- uploadDate: string;
- duration: number;
- channel: string;
- description?: string;
- isLivestream: boolean;
- ageRestricted: boolean;
- platform: Platform;
- webpageUrl: string;
- cues?: Cue[];
-};
-
-type ClipState = {
- slug: string | null;
- start: number | null;
- end: number | null;
-};
-
-type PlayerState = {
- activeSlug: string | null;
- data: TranscriptData | null;
- status: Status;
- displayMode: DisplayMode;
- modalOpen: boolean;
- playing: boolean;
- currentTime: number;
- clipStart: number | null;
- clipEnd: number | null;
- openTranscript: (slug: string, start?: number) => void;
- setDisplayMode: (mode: DisplayMode) => void;
- closePlayer: () => void;
- markClipStart: () => void;
- markClipEnd: () => void;
- clearClip: () => void;
- copyDownloadCommand: () => Promise<boolean>;
- copyShareUrl: () => Promise<boolean>;
- openInPreservetube: () => void;
- seekTo: (seconds: number) => void;
-};
-
-const Ctx = createContext<PlayerState | null>(null);
-
-export function usePlayer(): PlayerState {
- const v = useContext(Ctx);
- if (!v) throw new Error("usePlayer must be inside PlayerProvider");
- return v;
-}
-
-function toHMS(seconds: number): string {
- const s = Math.max(0, Math.floor(seconds));
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const sec = s % 60;
- return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
-}
-
-export function PlayerProvider({
- children,
-}: {
- children: React.ReactNode;
-}) {
- const { v: urlSlug, t: urlTime } = useUrlParams();
- const [detail, setDetail] = useState<Detail | null>(null);
- const [displayState, setDisplayState] = useState<{
- slug: string;
- mode: DisplayMode;
- } | null>(null);
- const [clip, setClip] = useState<ClipState>({
- slug: null,
- start: null,
- end: null,
- });
- const [playing, setPlaying] = useState(false);
- const [currentTime, setCurrentTime] = useState(0);
- const playerRef = useRef<PlayerHandle | null>(null);
- const pendingSeekRef = useRef<number | null>(null);
- const readyForSlugRef = useRef<string | null>(null);
-
- const activeSlug = urlSlug;
- const displayMode: DisplayMode =
- displayState?.slug === activeSlug ? displayState.mode : "modal";
- const modalOpen = activeSlug !== null && displayMode === "modal";
- const detailMatches = detail?.slug === activeSlug;
- const data: TranscriptData | null = useMemo(() => {
- 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,
- isLivestream: detail.isLivestream,
- ageRestricted: detail.ageRestricted,
- platform: detail.platform,
- webpageUrl: detail.webpageUrl,
- cues: detail.cues,
- };
- }, [detail, detailMatches]);
- const status: Status = !activeSlug
- ? "idle"
- : detailMatches
- ? "ready"
- : "loading";
-
- const clipStart = clip.slug && clip.slug === activeSlug ? clip.start : null;
- const clipEnd = clip.slug && clip.slug === activeSlug ? clip.end : null;
-
- const openTranscript = useCallback(
- (slug: string, start?: number) => {
- setDisplayState({ slug, mode: "modal" });
- const t = typeof start === "number" ? Math.round(start) : null;
- if (slug === activeSlug && t !== null && playerRef.current) {
- playerRef.current.seekTo(t, "seconds");
- setCurrentTime(t);
- setPlaying(true);
- }
- writeUrlParams({ v: slug, t });
- },
- [activeSlug],
- );
-
- const setDisplayMode = useCallback(
- (mode: DisplayMode) => {
- if (!activeSlug) return;
- setDisplayState({ slug: activeSlug, mode });
- },
- [activeSlug],
- );
-
- const closePlayer = useCallback(() => {
- setDisplayState(null);
- writeUrlParams({ v: null, t: null });
- }, []);
-
- const markClipStart = useCallback(() => {
- if (!activeSlug) return;
- setClip((prev) => ({
- slug: activeSlug,
- start: currentTime,
- end: prev.slug === activeSlug ? prev.end : null,
- }));
- }, [activeSlug, currentTime]);
-
- const markClipEnd = useCallback(() => {
- if (!activeSlug) return;
- setClip((prev) => ({
- slug: activeSlug,
- start: prev.slug === activeSlug ? prev.start : null,
- end: currentTime,
- }));
- }, [activeSlug, currentTime]);
-
- const clearClip = useCallback(() => {
- setClip({ slug: null, start: null, end: null });
- }, []);
-
- const copyDownloadCommand = useCallback(async (): Promise<boolean> => {
- if (!data) return false;
- const url = data.webpageUrl;
- const sections =
- clipStart !== null && clipEnd !== null
- ? `--download-sections "*${toHMS(clipStart)}-${toHMS(clipEnd)}" `
- : "";
- const cmd = `yt-dlp ${sections}"${url}"`;
- try {
- await navigator.clipboard.writeText(cmd);
- return true;
- } catch {
- return false;
- }
- }, [data, clipStart, clipEnd]);
-
- const copyShareUrl = useCallback(async (): Promise<boolean> => {
- if (!data) return false;
- const t = data.platform === "youtube" ? currentTime : (urlTime ?? 0);
- const secs = Math.max(0, Math.floor(t));
- const params = new URLSearchParams();
- params.set("v", data.slug);
- if (secs > 0) params.set("t", String(secs));
- const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
- try {
- await navigator.clipboard.writeText(url);
- return true;
- } catch {
- return false;
- }
- }, [data, currentTime, urlTime]);
-
- const openInPreservetube = useCallback(() => {
- if (!data || data.platform !== "youtube") return;
- window.open(
- `https://preservetube.com/watch?v=${encodeURIComponent(data.id)}`,
- "_blank",
- "noopener,noreferrer",
- );
- }, [data]);
-
- const seekTo = useCallback((seconds: number) => {
- if (playerRef.current) {
- playerRef.current.seekTo(seconds, "seconds");
- setPlaying(true);
- } else {
- pendingSeekRef.current = seconds;
- }
- setCurrentTime(seconds);
- }, []);
-
- useEffect(() => {
- if (!urlSlug) return;
- readyForSlugRef.current = null;
- pendingSeekRef.current = urlTime ?? 0;
- let cancelled = false;
- fetchTranscript(urlSlug)
- .then((full) => {
- if (cancelled) return;
- setDetail({
- slug: urlSlug,
- id: full.id,
- title: full.title,
- uploadDate: full.uploadDate,
- duration: full.duration,
- channel: full.channel,
- description: full.description,
- isLivestream: full.isLivestream,
- ageRestricted: full.ageRestricted,
- platform: full.platform,
- webpageUrl: full.webpageUrl,
- cues: full.cues,
- });
- })
- .catch(() => {
- if (cancelled) return;
- setDetail(null);
- });
- return () => {
- cancelled = true;
- };
- // urlTime intentionally omitted — t changes shouldn't refetch
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [urlSlug]);
-
- useEffect(() => {
- if (urlTime === null) return;
- if (readyForSlugRef.current !== urlSlug) return;
- playerRef.current?.seekTo(urlTime, "seconds");
- setCurrentTime(urlTime);
- }, [urlSlug, urlTime]);
-
- // Rumble / Odysee have no onReady or progress events; seed currentTime from
- // the URL so clip-mark buttons and active-cue highlighting have a baseline,
- // and mark the slug ready so later ?t= changes can seek via the imperative
- // handle.
- useEffect(() => {
- if (detail?.platform === "rumble" || detail?.platform === "odysee") {
- readyForSlugRef.current = detail.slug;
- pendingSeekRef.current = null;
- setCurrentTime(urlTime ?? 0);
- }
- // urlTime intentionally omitted — the urlTime effect above handles later changes.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [detail]);
-
- useEffect(() => {
- if (!modalOpen) return;
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") setDisplayMode("mini");
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [modalOpen, setDisplayMode]);
-
- const handleReady = () => {
- if (
- readyForSlugRef.current !== activeSlug &&
- pendingSeekRef.current !== null &&
- playerRef.current
- ) {
- playerRef.current.seekTo(pendingSeekRef.current, "seconds");
- setPlaying(true);
- pendingSeekRef.current = null;
- readyForSlugRef.current = activeSlug;
- }
- };
-
- const value: PlayerState = {
- activeSlug,
- data,
- status,
- displayMode,
- modalOpen,
- playing,
- currentTime,
- clipStart,
- clipEnd,
- openTranscript,
- setDisplayMode,
- closePlayer,
- markClipStart,
- markClipEnd,
- clearClip,
- copyDownloadCommand,
- copyShareUrl,
- openInPreservetube,
- seekTo,
- };
-
- const showPlayer = activeSlug !== null && data !== null;
-
- let containerClass = "";
- if (displayMode === "modal") {
- containerClass =
- "fixed left-1/2 -translate-x-1/2 top-16 w-[min(92vw,960px)] aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl";
- } else if (displayMode === "mini") {
- containerClass =
- "fixed bottom-4 right-4 w-72 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40";
- } else {
- // hidden — keep iframe rendered with size so YT keeps playing,
- // but pull it offscreen and remove from interaction.
- containerClass =
- "fixed -left-[10000px] -top-[10000px] w-80 aspect-video pointer-events-none";
- }
-
- return (
- <Ctx.Provider value={value}>
- {children}
-
- {showPlayer && (
- <div className={containerClass} aria-hidden={displayMode === "hidden"}>
- {data.platform === "rumble" ? (
- <RumblePlayer
- key={data.id}
- ref={(p: RumblePlayerHandle | null) => {
- playerRef.current = p;
- }}
- videoId={data.id}
- startSeconds={urlTime ?? 0}
- />
- ) : data.platform === "odysee" ? (
- <OdyseePlayer
- key={data.id}
- ref={(p: OdyseePlayerHandle | null) => {
- playerRef.current = p;
- }}
- videoId={data.id}
- startSeconds={urlTime ?? 0}
- />
- ) : (
- <ReactPlayer
- ref={(p: ReactPlayerType | null) => {
- playerRef.current = p;
- }}
- url={`https://www.youtube.com/watch?v=${data.id}`}
- width="100%"
- height="100%"
- playing={playing}
- controls
- onReady={handleReady}
- onPlay={() => setPlaying(true)}
- onPause={() => setPlaying(false)}
- onProgress={(s: { playedSeconds: number }) =>
- setCurrentTime(s.playedSeconds)
- }
- />
- )}
- {displayMode === "mini" && (
- <button
- type="button"
- aria-label="Reopen transcript"
- onClick={() => setDisplayMode("modal")}
- className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent text-white text-xs px-3 py-1.5 text-left hover:from-black/95"
- >
- <span className="font-medium line-clamp-1">{data.title}</span>
- <span className="opacity-70">click to reopen</span>
- </button>
- )}
- </div>
- )}
-
- {showPlayer && displayMode === "hidden" && (
- <HiddenControls
- title={data.title}
- onShow={() => setDisplayMode("modal")}
- onMini={() => setDisplayMode("mini")}
- onClose={closePlayer}
- />
- )}
- </Ctx.Provider>
- );
-}
-
-function HiddenControls({
- title,
- onShow,
- onMini,
- onClose,
-}: {
- title: string;
- onShow: () => void;
- onMini: () => void;
- onClose: () => void;
-}) {
- return (
- <div className="fixed bottom-4 right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)]">
- <span className="text-xs opacity-70 shrink-0">♪</span>
- <span className="text-xs font-medium truncate flex-1 min-w-0">
- {title}
- </span>
- <ControlButton title="Show player" onClick={onShow} char="▢" />
- <ControlButton title="Collapse to mini-player" onClick={onMini} char="↘" />
- <ControlButton title="Close player" onClick={onClose} char="✕" />
- </div>
- );
-}
-
-function ControlButton({
- title,
- onClick,
- char,
- disabled,
- highlight,
-}: {
- title: string;
- onClick: () => void;
- char: string;
- disabled?: boolean;
- highlight?: boolean;
-}) {
- return (
- <button
- type="button"
- title={title}
- aria-label={title}
- onClick={onClick}
- disabled={disabled}
- className={`shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-md text-sm font-mono transition ${
- disabled
- ? "bg-white/5 text-white/30 cursor-not-allowed"
- : highlight
- ? "bg-amber-500/30 hover:bg-amber-500/50 text-white ring-1 ring-amber-400/60"
- : "bg-white/10 hover:bg-white/20 text-white"
- }`}
- >
- {char}
- </button>
- );
-}
-
-export { ControlButton, toHMS };
diff --git a/app/TranscriptModal.tsx b/app/TranscriptModal.tsx
@@ -1,273 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { ControlButton, toHMS, usePlayer } from "./PlayerProvider";
-import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
-import { formatTimestamp } from "@/lib/vtt";
-import { formatDate } from "@/lib/format";
-
-export default function TranscriptModal() {
- const {
- modalOpen,
- activeSlug,
- data,
- status,
- currentTime,
- clipStart,
- clipEnd,
- setDisplayMode,
- closePlayer,
- markClipStart,
- markClipEnd,
- clearClip,
- copyDownloadCommand,
- copyShareUrl,
- openInPreservetube,
- seekTo,
- } = usePlayer();
- const activeRef = useRef<HTMLLIElement | null>(null);
- const scrollRef = useRef<HTMLDivElement | null>(null);
- const [copied, setCopied] = useState(false);
- const copyResetRef = useRef<number | null>(null);
- const [shareCopied, setShareCopied] = useState(false);
- const shareResetRef = useRef<number | null>(null);
-
- const cues = data?.cues ?? [];
- const activeIndex = findActiveIndex(cues, currentTime);
-
- useEffect(() => {
- if (!modalOpen) return;
- activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
- }, [activeIndex, modalOpen]);
-
- useEffect(() => {
- if (modalOpen) {
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- return () => {
- document.body.style.overflow = prev;
- };
- }
- }, [modalOpen]);
-
- useEffect(() => {
- return () => {
- if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current);
- if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current);
- };
- }, []);
-
- if (!modalOpen || !activeSlug) return null;
-
- const canDownload = clipStart !== null && clipEnd !== null && clipEnd > clipStart;
- const onCopy = async () => {
- const ok = await copyDownloadCommand();
- if (!ok) return;
- setCopied(true);
- if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current);
- copyResetRef.current = window.setTimeout(() => setCopied(false), 1800);
- };
- const onShare = async () => {
- const ok = await copyShareUrl();
- if (!ok) return;
- setShareCopied(true);
- if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current);
- shareResetRef.current = window.setTimeout(() => setShareCopied(false), 1800);
- };
-
- return (
- <div className="fixed inset-0 z-50 flex flex-col">
- <button
- type="button"
- aria-label="Collapse to mini-player"
- onClick={() => setDisplayMode("mini")}
- className="absolute inset-0 bg-black/70 backdrop-blur-sm"
- />
- <div className="relative flex flex-col items-center w-full h-full overflow-hidden pt-4 pointer-events-none">
- <div
- className="w-[min(92vw,960px)] pointer-events-auto flex flex-col gap-3"
- style={{
- marginTop: "calc(min(92vw, 960px) * 9 / 16 + 3rem)",
- maxHeight:
- "calc(100vh - (min(92vw, 960px) * 9 / 16) - 5rem)",
- }}
- >
- <div className="flex items-center gap-2 flex-wrap rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-2">
- <ControlButton
- title="Mark clip start at current time"
- onClick={markClipStart}
- char="["
- highlight={clipStart !== null}
- />
- <ControlButton
- title="Mark clip end at current time"
- onClick={markClipEnd}
- char="]"
- highlight={clipEnd !== null}
- />
- <ClipReadout
- clipStart={clipStart}
- clipEnd={clipEnd}
- onClear={clearClip}
- />
- <ControlButton
- title={
- canDownload
- ? copied
- ? "Copied!"
- : "Copy yt-dlp download command for this clip"
- : "Mark both clip start and end first"
- }
- onClick={onCopy}
- char={copied ? "✓" : "↓"}
- disabled={!canDownload}
- />
- <div className="flex-1" />
- <ControlButton
- title={
- !data
- ? "Loading…"
- : shareCopied
- ? "Copied!"
- : "Copy share link at current time"
- }
- onClick={onShare}
- char={shareCopied ? "✓" : "⤴"}
- disabled={!data}
- />
- {data?.platform === "youtube" && (
- <ControlButton
- title="Open in Preservetube"
- onClick={openInPreservetube}
- char="⧉"
- />
- )}
- <ControlButton
- title="Collapse to mini-player"
- onClick={() => setDisplayMode("mini")}
- char="↘"
- />
- <ControlButton
- title="Hide player (keep audio playing)"
- onClick={() => setDisplayMode("hidden")}
- char="—"
- />
- <ControlButton
- title="Close player"
- onClick={closePlayer}
- char="✕"
- />
- </div>
-
- <div className="text-white min-w-0">
- <h2 className="text-lg font-semibold leading-tight truncate">
- {data?.title ?? "Loading…"}
- </h2>
- {data && (
- <p className="text-xs text-zinc-300 mt-0.5 flex items-center gap-1.5 flex-wrap">
- <span>
- {formatDate(data.uploadDate)}
- {data.channel && ` · ${data.channel}`}
- </span>
- {data.isLivestream && <LivestreamBadge />}
- {data.ageRestricted && <AgeRestrictedBadge />}
- </p>
- )}
- </div>
-
- <div
- ref={scrollRef}
- className="flex-1 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900"
- >
- {status === "loading" && (
- <p className="text-sm text-zinc-400 p-4">Loading transcript…</p>
- )}
- {status === "ready" && cues.length === 0 && (
- <p className="text-sm text-zinc-400 p-4">No cues available.</p>
- )}
- {status === "ready" && cues.length > 0 && (
- <ol className="divide-y divide-zinc-800 text-zinc-100">
- {cues.map((cue, i) => {
- const isActive = i === activeIndex;
- return (
- <li
- key={i}
- ref={isActive ? activeRef : undefined}
- className={
- isActive ? "bg-blue-950/50" : ""
- }
- >
- <button
- type="button"
- onClick={() => seekTo(cue.start)}
- className="w-full text-left flex gap-3 px-3 py-2 hover:bg-zinc-800"
- >
- <span className="text-xs font-mono text-zinc-400 shrink-0 w-16 pt-0.5">
- {formatTimestamp(cue.start)}
- </span>
- <span className="text-sm">{cue.text}</span>
- </button>
- </li>
- );
- })}
- </ol>
- )}
- </div>
- </div>
- </div>
- </div>
- );
-}
-
-function ClipReadout({
- clipStart,
- clipEnd,
- onClear,
-}: {
- clipStart: number | null;
- clipEnd: number | null;
- onClear: () => void;
-}) {
- if (clipStart === null && clipEnd === null) {
- return (
- <span className="text-xs text-zinc-500 font-mono px-1">
- no clip set
- </span>
- );
- }
- return (
- <span className="text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1">
- <span>{clipStart !== null ? toHMS(clipStart) : "--:--:--"}</span>
- <span className="opacity-60">→</span>
- <span>{clipEnd !== null ? toHMS(clipEnd) : "--:--:--"}</span>
- <button
- type="button"
- title="Clear clip"
- onClick={onClear}
- className="ml-1 text-zinc-400 hover:text-white"
- >
- ×
- </button>
- </span>
- );
-}
-
-function findActiveIndex(
- cues: { start: number }[],
- t: number,
-): number {
- if (!cues.length) return -1;
- let lo = 0;
- let hi = cues.length - 1;
- let found = -1;
- while (lo <= hi) {
- const mid = (lo + hi) >> 1;
- if (cues[mid].start <= t) {
- found = mid;
- lo = mid + 1;
- } else {
- hi = mid - 1;
- }
- }
- return found;
-}
diff --git a/app/TranscriptSearch.tsx b/app/TranscriptSearch.tsx
@@ -1,737 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState } from "react";
-import { usePlayer } from "./PlayerProvider";
-import { useSummaries } from "./summariesCache";
-import {
- createSearchPipeline,
- filterSlugs,
- type Hit,
- type PipelineController,
-} from "./searchPipeline";
-import { useUrlParams, writeUrlParams } from "./urlState";
-import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
-import type { DisplaySummary } from "@/lib/transcripts";
-
-type Summary = DisplaySummary;
-
-type HitGroup = {
- slug: string;
- title: string;
- channel: string;
- date: string;
- isLivestream: boolean;
- ageRestricted: boolean;
- hits: Hit[];
-};
-
-const DEFAULT_MAX_HITS = 500;
-const DEFAULT_FETCH_CONCURRENCY = 6;
-const DEFAULT_FLUSH_INTERVAL_MS = 120;
-const DEFAULT_GROUPS_PAGE_SIZE = 50;
-
-export default function TranscriptSearch() {
- const {
- q: query,
- re: useRegex,
- v: activeVideo,
- t: activeTime,
- ch: committedChannelList,
- nov,
- nol,
- naa,
- nar,
- } = useUrlParams();
- // Content-based key so Set identity doesn't churn when other URL params
- // (v/t) change — otherwise opening the player modal would restart the
- // search pipeline from scratch.
- const committedChannelsKey = committedChannelList
- .slice()
- .sort()
- .join("\u0000");
- const committedChannels = useMemo(
- () => new Set(committedChannelList),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [committedChannelsKey],
- );
- const [input, setInput] = useState(query);
- useEffect(() => {
- setInput(query);
- }, [query]);
-
- const [draftExcludedChannels, setDraftExcludedChannels] = useState<
- Set<string>
- >(() => new Set(committedChannelList));
- const [draftNov, setDraftNov] = useState(nov);
- const [draftNol, setDraftNol] = useState(nol);
- const [draftNaa, setDraftNaa] = useState(naa);
- const [draftNar, setDraftNar] = useState(nar);
- useEffect(() => {
- setDraftExcludedChannels(new Set(committedChannelList));
- }, [committedChannelList]);
- useEffect(() => setDraftNov(nov), [nov]);
- useEffect(() => setDraftNol(nol), [nol]);
- useEffect(() => setDraftNaa(naa), [naa]);
- useEffect(() => setDraftNar(nar), [nar]);
-
- const [searchExecuted, setSearchExecuted] = useState(() => {
- if (typeof window === "undefined") return true;
- const params = new URLSearchParams(window.location.search);
- return !(params.has("v") && (params.get("q") ?? "") !== "");
- });
-
- const { manifest, summaries, loadedPages, pageCount, summariesReady } =
- useSummaries();
- const transcripts: Summary[] | null = summariesReady ? summaries : null;
-
- const [hitsBySlug, setHitsBySlug] = useState<Record<string, Hit[]>>({});
- const [totalHits, setTotalHits] = useState(0);
- const [processed, setProcessed] = useState(0);
- const [totalToProcess, setTotalToProcess] = useState(0);
- const [pipelineDone, setPipelineDone] = useState(false);
- const [capped, setCapped] = useState(false);
-
- // Advanced options — configurable from the UI, pre-filled with defaults.
- const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS);
- const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false);
- const [groupsBatchValue, setGroupsBatchValue] = useState(
- DEFAULT_GROUPS_PAGE_SIZE,
- );
- const [groupsBatchUnlimited, setGroupsBatchUnlimited] = useState(false);
- const [fetchConcurrency, setFetchConcurrency] = useState(
- DEFAULT_FETCH_CONCURRENCY,
- );
- const [flushIntervalMs, setFlushIntervalMs] = useState(
- DEFAULT_FLUSH_INTERVAL_MS,
- );
- const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue;
- const groupsBatchSize = groupsBatchUnlimited
- ? Infinity
- : groupsBatchValue;
-
- const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS);
- const [groupsShown, setGroupsShown] = useState<number>(
- DEFAULT_GROUPS_PAGE_SIZE,
- );
- const pipelineRef = useRef<PipelineController | null>(null);
- const { openTranscript } = usePlayer();
-
- const trimmed = searchExecuted ? query.trim() : "";
-
- // Reset the live caps on each new search. Uses the currently configured
- // batch sizes so an advanced-options change only takes effect on the next
- // search (no jarring mid-search restarts when tuning).
- useEffect(() => {
- setHitLimit(hitBatchSize);
- setGroupsShown(groupsBatchSize);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [trimmed, useRegex]);
-
- const channelOptions = useMemo<string[]>(() => {
- if (manifest?.channels.length) {
- return manifest.channels
- .map((c) => c.name)
- .filter((n) => n)
- .sort((a, b) => a.localeCompare(b));
- }
- if (!transcripts) return [];
- const names = new Set<string>();
- for (const t of transcripts) if (t.channel) names.add(t.channel);
- return Array.from(names).sort((a, b) => a.localeCompare(b));
- }, [manifest, transcripts]);
-
- const passesFilter = useMemo(() => {
- return (t: Summary) => {
- if (committedChannels.has(t.channel)) return false;
- if (t.isLivestream ? nol : nov) return false;
- if (t.ageRestricted ? nar : naa) return false;
- return true;
- };
- }, [committedChannels, nov, nol, naa, nar]);
-
- // Stable signature of the filter inputs; used as a single dep in the search
- // pipeline so reference-unstable upstream values (new `ch: []` array on every
- // URL change) don't cancel an in-flight search when the modal opens.
- const filterKey = `${committedChannelsKey}|${nov ? 1 : 0}|${nol ? 1 : 0}|${naa ? 1 : 0}|${nar ? 1 : 0}`;
-
- const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => {
- if (!trimmed || !useRegex) return { re: null, error: null };
- try {
- return { re: new RegExp(trimmed, "i"), error: null };
- } catch (e) {
- return { re: null, error: (e as Error).message };
- }
- }, [trimmed, useRegex]);
-
- // Pipeline lifecycle: create a new pipeline when inputs change, and cancel
- // the prior one. hitLimit is NOT a dep here — it's pushed into the live
- // pipeline via setHitLimit so bumping the cap resumes rather than restarts.
- useEffect(() => {
- pipelineRef.current?.cancel();
- pipelineRef.current = null;
-
- if (!trimmed) {
- setHitsBySlug({});
- setTotalHits(0);
- setProcessed(0);
- setTotalToProcess(0);
- setPipelineDone(false);
- setCapped(false);
- return;
- }
- if (!transcripts) return;
- if (useRegex && !regex.re) return;
-
- const slugs = filterSlugs(transcripts, passesFilter);
-
- const controller = createSearchPipeline({
- slugs,
- query: trimmed,
- useRegex,
- regex: regex.re,
- initialHitLimit: hitLimit,
- concurrency: fetchConcurrency,
- flushIntervalMs,
- emit: (u) => {
- setHitsBySlug(u.hitsBySlug);
- setTotalHits(u.totalHits);
- setProcessed(u.processed);
- setTotalToProcess(u.totalToProcess);
- setCapped(u.capped);
- setPipelineDone(u.done);
- },
- });
- pipelineRef.current = controller;
-
- return () => {
- controller.cancel();
- if (pipelineRef.current === controller) pipelineRef.current = null;
- };
- // hitLimit intentionally omitted — bumped via setHitLimit below.
- // passesFilter captured via filterKey (content signature).
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [trimmed, transcripts, filterKey, useRegex, regex.re]);
-
- // Push hitLimit bumps into the live pipeline so "Show more videos" resumes
- // the existing traversal instead of restarting from slug 0.
- useEffect(() => {
- pipelineRef.current?.setHitLimit(hitLimit);
- }, [hitLimit]);
-
- const groups = useMemo<HitGroup[]>(() => {
- if (!trimmed || !transcripts) return [];
- const out: HitGroup[] = [];
- for (const t of transcripts) {
- const hits = hitsBySlug[t.slug];
- if (!hits?.length) continue;
- out.push({
- slug: t.slug,
- title: t.title,
- channel: t.channel,
- date: t.date,
- isLivestream: t.isLivestream,
- ageRestricted: t.ageRestricted,
- hits,
- });
- }
- return out;
- }, [trimmed, transcripts, hitsBySlug]);
-
- const indexLoading = Boolean(trimmed) && !summariesReady;
- const searching = Boolean(trimmed) && totalToProcess > 0;
- const pipelineActive = searching && !pipelineDone;
- const indexProgress =
- pageCount > 0 ? `${loadedPages}/${pageCount} pages` : "";
-
- const filtersDirty =
- !sameSet(draftExcludedChannels, committedChannels) ||
- draftNov !== nov ||
- draftNol !== nol ||
- draftNaa !== naa ||
- draftNar !== nar;
- const queryDirty = input.trim() !== (searchExecuted ? query.trim() : "");
- const commitSearch = () => {
- setSearchExecuted(true);
- writeUrlParams({
- q: input.trim(),
- ch: Array.from(draftExcludedChannels),
- nov: draftNov,
- nol: draftNol,
- naa: draftNaa,
- nar: draftNar,
- });
- };
-
- return (
- <div className="flex flex-col gap-6">
- <form
- onSubmit={(e) => {
- e.preventDefault();
- commitSearch();
- }}
- className="flex flex-col gap-1"
- >
- <input
- type="search"
- value={input}
- onChange={(e) => setInput(e.target.value)}
- placeholder={
- useRegex
- ? "Regex pattern (case-insensitive)…"
- : "Search transcripts..."
- }
- className={`w-full rounded-lg border ${
- regex.error
- ? "border-red-500"
- : "border-zinc-300 dark:border-zinc-700"
- } bg-white dark:bg-zinc-900 px-4 py-3 text-base outline-none focus:ring-2 focus:ring-blue-500 ${
- useRegex ? "font-mono" : ""
- }`}
- />
- <label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 select-none">
- <input
- type="checkbox"
- checked={useRegex}
- onChange={(e) => writeUrlParams({ re: e.target.checked })}
- className="accent-blue-600"
- />
- Regex
- {regex.error && (
- <span className="text-red-600 font-mono text-xs">
- {regex.error}
- </span>
- )}
- {(queryDirty || filtersDirty) && (
- <span className="text-xs text-zinc-400 ml-auto">
- Press Enter to search
- </span>
- )}
- </label>
- </form>
-
- {channelOptions.length > 0 && (
- <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400 -mt-3">
- {channelOptions.length > 1 && (
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-zinc-500">
- Channels
- </span>
- {channelOptions.map((name) => {
- const checked = !draftExcludedChannels.has(name);
- return (
- <label
- key={name}
- className="flex items-center gap-1.5 select-none"
- >
- <input
- type="checkbox"
- checked={checked}
- onChange={(e) => {
- setDraftExcludedChannels((prev) => {
- const next = new Set(prev);
- if (e.target.checked) next.delete(name);
- else next.add(name);
- return next;
- });
- }}
- className="accent-blue-600"
- />
- {name}
- </label>
- );
- })}
- </div>
- )}
- <label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
- checked={!draftNov}
- onChange={(e) => setDraftNov(!e.target.checked)}
- className="accent-blue-600"
- />
- Videos
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
- checked={!draftNol}
- onChange={(e) => setDraftNol(!e.target.checked)}
- className="accent-blue-600"
- />
- Livestreams
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
- checked={!draftNaa}
- onChange={(e) => setDraftNaa(!e.target.checked)}
- className="accent-blue-600"
- />
- All ages
- </label>
- <label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
- checked={!draftNar}
- onChange={(e) => setDraftNar(!e.target.checked)}
- className="accent-blue-600"
- />
- Age-restricted
- </label>
- </div>
- )}
-
- <details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400">
- <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
- Advanced options
- </summary>
- <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
- <LimitField
- label="Max hits per batch"
- hint="Pause after this many matches; “Show more” adds another batch."
- value={hitBatchValue}
- unlimited={hitBatchUnlimited}
- defaultValue={DEFAULT_MAX_HITS}
- onValueChange={setHitBatchValue}
- onUnlimitedChange={setHitBatchUnlimited}
- />
- <LimitField
- label="Videos per page"
- hint="How many matching videos to show at a time."
- value={groupsBatchValue}
- unlimited={groupsBatchUnlimited}
- defaultValue={DEFAULT_GROUPS_PAGE_SIZE}
- onValueChange={setGroupsBatchValue}
- onUnlimitedChange={setGroupsBatchUnlimited}
- />
- <NumberField
- label="Fetch concurrency"
- hint="Parallel transcript downloads while searching. Applies on next search."
- value={fetchConcurrency}
- defaultValue={DEFAULT_FETCH_CONCURRENCY}
- min={1}
- max={32}
- onChange={setFetchConcurrency}
- />
- <NumberField
- label="Flush interval (ms)"
- hint="UI update cadence during search. Applies on next search."
- value={flushIntervalMs}
- defaultValue={DEFAULT_FLUSH_INTERVAL_MS}
- min={16}
- max={2000}
- onChange={setFlushIntervalMs}
- />
- <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
- <button
- type="button"
- onClick={() => {
- setHitBatchUnlimited(true);
- setGroupsBatchUnlimited(true);
- setHitLimit(Infinity);
- setGroupsShown(Infinity);
- }}
- className="px-3 py-1.5 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-xs"
- >
- Search through all transcripts
- </button>
- <button
- type="button"
- onClick={() => {
- setHitBatchValue(DEFAULT_MAX_HITS);
- setHitBatchUnlimited(false);
- setGroupsBatchValue(DEFAULT_GROUPS_PAGE_SIZE);
- setGroupsBatchUnlimited(false);
- setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
- setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
- }}
- className="px-3 py-1.5 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-xs"
- >
- Reset to defaults
- </button>
- </div>
- </div>
- </details>
-
- {!trimmed && (
- <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 flex items-center gap-2">
- <span>
- Transcript mentions{" "}
- {totalHits > 0
- ? `(${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{indexProgress ? ` (${indexProgress})` : ""}…
- </span>
- )}
- {searching && (
- <span className="text-xs text-zinc-400 font-normal">
- searched {processed}/{totalToProcess}
- {pipelineActive ? "…" : ""}
- </span>
- )}
- </h2>
- {!indexLoading && groups.length === 0 && !pipelineActive && (
- <p className="text-sm text-zinc-500">No transcript mentions.</p>
- )}
- {groups.length > 0 && (
- <div className="flex flex-col gap-3">
- {groups.slice(0, groupsShown).map((g) => (
- <div
- key={g.slug}
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
- >
- <button
- type="button"
- onClick={() => openTranscript(g.slug)}
- className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
- >
- <span className="font-medium truncate flex-1 min-w-0">
- {g.title}
- </span>
- {g.isLivestream && <LivestreamBadge />}
- {g.ageRestricted && <AgeRestrictedBadge />}
- <span className="text-xs text-zinc-500 shrink-0">
- {g.channel && `${g.channel} · `}
- {g.date}
- {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`}
- </span>
- </button>
- <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
- {g.hits.map((h, i) => {
- const isActive =
- activeVideo === g.slug && activeTime === h.start;
- return (
- <li key={i}>
- <button
- type="button"
- onClick={() => openTranscript(g.slug, h.start)}
- className={`w-full text-left flex 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-16">
- {formatSeconds(h.start)}
- </span>
- <span className="flex-1 min-w-0 text-sm">
- {highlight(h.text, trimmed, useRegex)}
- </span>
- </button>
- </li>
- );
- })}
- </ul>
- </div>
- ))}
- </div>
- )}
- {(groups.length > groupsShown || capped) && (
- <div className="mt-3 flex justify-center">
- <button
- type="button"
- onClick={() => {
- setGroupsShown((n) => n + groupsBatchSize);
- if (capped) setHitLimit((l) => l + hitBatchSize);
- }}
- 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"
- >
- {groups.length > groupsShown
- ? `Show more videos (${groups.length - groupsShown} loaded${capped ? ", more available" : ""})`
- : "Show more videos"}
- </button>
- </div>
- )}
- </section>
- </>
- )}
- </div>
- );
-}
-
-function sameSet<T>(a: Set<T>, b: Set<T>): boolean {
- if (a.size !== b.size) return false;
- for (const v of a) if (!b.has(v)) return false;
- return true;
-}
-
-function NumberField({
- label,
- hint,
- value,
- defaultValue,
- min,
- max,
- onChange,
- disabled,
-}: {
- label: string;
- hint?: string;
- value: number;
- defaultValue: number;
- min: number;
- max: number;
- onChange: (n: number) => void;
- disabled?: boolean;
-}) {
- return (
- <label className="flex flex-col gap-1">
- <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
- {label}
- </span>
- <input
- type="number"
- min={min}
- max={max}
- value={Number.isFinite(value) ? value : ""}
- placeholder={String(defaultValue)}
- disabled={disabled}
- onChange={(e) => {
- const raw = e.target.value;
- if (raw === "") {
- onChange(defaultValue);
- return;
- }
- const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
- if (Number.isFinite(n)) onChange(n);
- }}
- className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
- />
- {hint && <span className="text-xs text-zinc-500">{hint}</span>}
- </label>
- );
-}
-
-function LimitField({
- label,
- hint,
- value,
- unlimited,
- defaultValue,
- onValueChange,
- onUnlimitedChange,
-}: {
- label: string;
- hint?: string;
- value: number;
- unlimited: boolean;
- defaultValue: number;
- onValueChange: (n: number) => void;
- onUnlimitedChange: (b: boolean) => void;
-}) {
- return (
- <div className="flex flex-col gap-1">
- <div className="flex items-baseline justify-between gap-2">
- <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
- {label}
- </span>
- <label className="flex items-center gap-1 text-xs select-none">
- <input
- type="checkbox"
- checked={unlimited}
- onChange={(e) => onUnlimitedChange(e.target.checked)}
- className="accent-blue-600"
- />
- No limit
- </label>
- </div>
- <input
- type="number"
- min={1}
- max={1_000_000}
- value={unlimited ? "" : value}
- placeholder={unlimited ? "∞" : String(defaultValue)}
- disabled={unlimited}
- onChange={(e) => {
- const raw = e.target.value;
- if (raw === "") {
- onValueChange(defaultValue);
- return;
- }
- const n = Math.max(1, Math.floor(Number(raw)));
- if (Number.isFinite(n)) onValueChange(n);
- }}
- className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
- />
- {hint && <span className="text-xs text-zinc-500">{hint}</span>}
- </div>
- );
-}
-
-function formatSeconds(s: number): string {
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const sec = s % 60;
- const mm = String(m).padStart(2, "0");
- const ss = String(sec).padStart(2, "0");
- return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
-}
-
-function highlight(text: string, q: string, useRegex = false) {
- if (!q) return text;
- const parts: React.ReactNode[] = [];
-
- if (useRegex) {
- let re: RegExp;
- try {
- re = new RegExp(q, "gi");
- } catch {
- return text;
- }
- let last = 0;
- let m: RegExpExecArray | null;
- let key = 0;
- while ((m = re.exec(text)) !== null) {
- if (m.index > last) parts.push(text.slice(last, m.index));
- parts.push(
- <mark
- key={key++}
- className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit"
- >
- {m[0]}
- </mark>,
- );
- last = m.index + m[0].length;
- if (m[0].length === 0) re.lastIndex++;
- }
- if (last < text.length) parts.push(text.slice(last));
- return <>{parts}</>;
- }
-
- const lower = text.toLowerCase();
- const ql = q.toLowerCase();
- let i = 0;
- while (i < text.length) {
- const idx = lower.indexOf(ql, i);
- if (idx === -1) {
- parts.push(text.slice(i));
- break;
- }
- if (idx > i) parts.push(text.slice(i, idx));
- parts.push(
- <mark
- key={idx}
- className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit"
- >
- {text.slice(idx, idx + q.length)}
- </mark>,
- );
- i = idx + q.length;
- }
- return <>{parts}</>;
-}
diff --git a/app/layout.tsx b/app/layout.tsx
@@ -1,53 +0,0 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import Link from "next/link";
-import { getSettings } from "@/lib/settings";
-import { QueryProvider } from "./QueryProvider";
-import "./globals.css";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-export function generateMetadata(): Metadata {
- const s = getSettings();
- return {
- title: s.siteTitle,
- description: s.siteDescription,
- };
-}
-
-export default async function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
- const settings = getSettings();
- return (
- <html
- lang="en"
- className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
- >
- <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans">
- <QueryProvider>
- <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20">
- <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
- <Link href="/" className="font-semibold tracking-tight">
- {settings.headerTitle}
- </Link>
- </div>
- </header>
- <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6">
- {children}
- </main>
- </QueryProvider>
- </body>
- </html>
- );
-}
diff --git a/app/page.tsx b/app/page.tsx
@@ -1,34 +0,0 @@
-import { countTranscripts } from "@/lib/transcripts";
-import { getSettings } from "@/lib/settings";
-import { PlayerProvider } from "./PlayerProvider";
-import TranscriptModal from "./TranscriptModal";
-import TranscriptSearch from "./TranscriptSearch";
-
-export default async function Home() {
- const settings = getSettings();
- const count = await countTranscripts();
-
- return (
- <PlayerProvider>
- <div className="flex flex-col gap-6">
- <div>
- <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 />
- <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
- href="/code.tar.gz"
- className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- code.tar.gz
- </a>
- </section>
- </div>
- <TranscriptModal />
- </PlayerProvider>
- );
-}
diff --git a/app/searchPipeline.ts b/app/searchPipeline.ts
@@ -1,251 +0,0 @@
-import { fetchTranscript } from "./transcriptCache";
-import type { DisplaySummary } from "@/lib/transcripts";
-
-export type Hit = { start: number; text: string };
-
-export type PipelineUpdate = {
- hitsBySlug: Record<string, Hit[]>;
- totalHits: number;
- processed: number;
- totalToProcess: number;
- capped: boolean;
- done: boolean;
-};
-
-type PipelineConfig = {
- slugs: string[];
- query: string;
- useRegex: boolean;
- regex: RegExp | null;
- initialHitLimit: number;
- concurrency: number;
- flushIntervalMs: number;
- emit: (update: PipelineUpdate) => void;
-};
-
-export type PipelineController = {
- cancel(): void;
- setHitLimit(limit: number): void;
-};
-
-// Runs a streaming search over the given slugs. State is closure-captured so
-// `setHitLimit` can raise the cap and re-spawn workers without restarting
-// traversal — workers resume from the shared `idx` cursor.
-export function createSearchPipeline(
- config: PipelineConfig,
-): PipelineController {
- const {
- slugs,
- query,
- useRegex,
- regex,
- initialHitLimit,
- concurrency,
- flushIntervalMs,
- emit,
- } = config;
-
- let cancelled = false;
- // `idx` is the next-to-claim cursor (leading edge); `completed` counts
- // slugs that workers have finished scanning (trailing edge). We report
- // `completed` to the UI so progress reflects actual work done, not
- // in-flight claims — and it never exceeds slugs.length.
- let idx = 0;
- let completed = 0;
- let totalSoFar = 0;
- let hitLimit = initialHitLimit;
- let activeWorkers = 0;
- let done = false;
- const localHits: Record<string, Hit[]> = {};
- let flushTimer: number | null = null;
-
- const pushUpdate = (overrides: Partial<PipelineUpdate> = {}) => {
- emit({
- hitsBySlug: { ...localHits },
- totalHits: totalSoFar,
- processed: completed,
- totalToProcess: slugs.length,
- // `capped` keys off `idx` (claimed), not `completed` — raising the cap
- // only helps if there are still unclaimed slugs a worker can pick up.
- capped: totalSoFar >= hitLimit && idx < slugs.length,
- done,
- ...overrides,
- });
- };
-
- const scheduleFlush = () => {
- if (flushTimer !== null || cancelled) return;
- flushTimer = window.setTimeout(() => {
- flushTimer = null;
- if (cancelled) return;
- pushUpdate();
- }, flushIntervalMs);
- };
-
- const finalize = () => {
- if (done || cancelled) return;
- done = true;
- if (flushTimer !== null) {
- window.clearTimeout(flushTimer);
- flushTimer = null;
- }
- pushUpdate();
- };
-
- const worker = async () => {
- activeWorkers++;
- try {
- while (!cancelled) {
- if (totalSoFar >= hitLimit) return;
- if (idx >= slugs.length) return;
- const my = idx++;
- const slug = slugs[my];
- try {
- const full = await fetchTranscript(slug);
- if (cancelled) return;
- if (full.cues && totalSoFar < hitLimit) {
- const remaining = hitLimit - totalSoFar;
- const hits = findHitsInCues(
- full.cues,
- query,
- useRegex,
- regex,
- remaining,
- );
- if (hits.length > 0) {
- localHits[slug] = hits;
- totalSoFar += hits.length;
- }
- }
- } catch {
- // ignore per-transcript failures
- }
- completed++;
- scheduleFlush();
- }
- } finally {
- activeWorkers--;
- if (activeWorkers === 0 && !cancelled) {
- // Either we hit the cap or ran out of work — either way, settle.
- finalize();
- }
- }
- };
-
- const ensureWorkers = () => {
- if (cancelled || done) return;
- if (totalSoFar >= hitLimit) return;
- if (idx >= slugs.length) return;
- const needed = Math.min(
- concurrency - activeWorkers,
- slugs.length - idx,
- );
- for (let i = 0; i < needed; i++) worker();
- };
-
- // Emit initial snapshot synchronously so the UI clears previous results.
- pushUpdate();
- ensureWorkers();
-
- return {
- cancel() {
- cancelled = true;
- if (flushTimer !== null) {
- window.clearTimeout(flushTimer);
- flushTimer = null;
- }
- },
- setHitLimit(limit: number) {
- if (cancelled) return;
- if (limit <= hitLimit) return;
- hitLimit = limit;
- // The prior run may have finalized because we were capped. Resume.
- if (done) {
- done = false;
- pushUpdate();
- }
- ensureWorkers();
- },
- };
-}
-
-function findHitsInCues(
- cues: { start: number; text: string }[],
- query: string,
- useRegex: boolean,
- regex: RegExp | null,
- limit: number,
-): Hit[] {
- const hits: Hit[] = [];
- for (let i = 0; i < cues.length && hits.length < limit; i++) {
- const cur = cues[i];
- const prevText = i > 0 ? cues[i - 1].text : "";
- const nextText = i < cues.length - 1 ? cues[i + 1].text : "";
- const sep1 = prevText ? " " : "";
- const sep2 = nextText ? " " : "";
- const windowText = prevText + sep1 + cur.text + sep2 + nextText;
- const curStart = prevText.length + sep1.length;
- const curEnd = curStart + cur.text.length;
- const m = findFirstMatchInRange(
- windowText,
- curStart,
- curEnd,
- query,
- useRegex,
- regex,
- );
- if (!m) continue;
- const crosses = m.idx < curStart || m.idx + m.length > curEnd;
- hits.push({
- start: Math.round(cur.start),
- text: crosses ? windowText : cur.text,
- });
- }
- return hits;
-}
-
-function findFirstMatchInRange(
- haystack: string,
- rangeStart: number,
- rangeEnd: number,
- query: string,
- useRegex: boolean,
- regex: RegExp | null,
-): { idx: number; length: number } | null {
- if (useRegex) {
- if (!regex) return null;
- const flags = regex.flags.includes("g") ? regex.flags : regex.flags + "g";
- const re = new RegExp(regex.source, flags);
- let m: RegExpExecArray | null;
- while ((m = re.exec(haystack)) !== null) {
- if (m.index >= rangeStart && m.index < rangeEnd) {
- return { idx: m.index, length: m[0].length };
- }
- if (m.index >= rangeEnd) return null;
- if (m[0].length === 0) re.lastIndex++;
- }
- return null;
- }
- if (!query) return null;
- const lower = haystack.toLowerCase();
- const ql = query.toLowerCase();
- let from = 0;
- while (from <= haystack.length) {
- const idx = lower.indexOf(ql, from);
- if (idx === -1) return null;
- if (idx >= rangeStart && idx < rangeEnd) return { idx, length: ql.length };
- if (idx >= rangeEnd) return null;
- from = idx + 1;
- }
- return null;
-}
-
-// Helper to build slug list from summaries + filter predicate.
-export function filterSlugs(
- summaries: DisplaySummary[],
- passes: (t: DisplaySummary) => boolean,
-): string[] {
- const slugs: string[] = [];
- for (const t of summaries) if (passes(t)) slugs.push(t.slug);
- return slugs;
-}
diff --git a/app/summariesCache.ts b/app/summariesCache.ts
@@ -1,82 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import { useQueries, useQuery } from "@tanstack/react-query";
-import type { DisplaySummary } from "@/lib/transcripts";
-import type { Manifest } from "@/lib/manifest";
-import { pageFileName } from "@/lib/manifest";
-
-async function fetchJson<T>(url: string): Promise<T> {
- const r = await fetch(url);
- if (!r.ok) throw new Error(`Failed to fetch ${url}: ${r.status}`);
- return (await r.json()) as T;
-}
-
-export type SummariesState = {
- manifest: Manifest | null;
- summaries: DisplaySummary[];
- loadedPages: number;
- pageCount: number;
- summariesReady: boolean;
- error: Error | null;
-};
-
-export function useManifest() {
- return useQuery<Manifest>({
- queryKey: ["manifest"],
- queryFn: () => fetchJson<Manifest>("/summaries/manifest.json"),
- });
-}
-
-export function useSummaries(): SummariesState {
- const manifestQuery = useManifest();
- const manifest = manifestQuery.data ?? null;
- const pageCount = manifest?.pageCount ?? 0;
-
- const pageQueries = useQueries({
- queries: Array.from({ length: pageCount }, (_, i) => ({
- queryKey: ["summaries-page", i],
- queryFn: () =>
- fetchJson<DisplaySummary[]>(`/summaries/${pageFileName(i)}`),
- enabled: pageCount > 0,
- })),
- });
-
- const loadedPages = pageQueries.filter((q) => q.data).length;
- const summariesReady = pageCount > 0 && loadedPages === pageCount;
-
- // Concatenate available pages; sort once all pages are in. Pages may arrive
- // out of order, so a late sort keeps the newest-first ordering stable
- // regardless of arrival order. Matches the LMDB composite-key order
- // [uploadDate, channelSlug, id] reversed.
- const summaries = useMemo<DisplaySummary[]>(() => {
- if (loadedPages === 0) return [];
- const out: DisplaySummary[] = [];
- for (const q of pageQueries) if (q.data) out.push(...q.data);
- if (summariesReady) {
- out.sort(
- (a, b) =>
- b.uploadDate.localeCompare(a.uploadDate) ||
- a.channelSlug.localeCompare(b.channelSlug) ||
- a.id.localeCompare(b.id),
- );
- }
- return out;
- // pageQueries identity changes every render; key off loadedPages + ready.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [loadedPages, summariesReady]);
-
- const error =
- manifestQuery.error instanceof Error
- ? manifestQuery.error
- : (pageQueries.find((q) => q.error)?.error as Error | undefined) ?? null;
-
- return {
- manifest,
- summaries,
- loadedPages,
- pageCount,
- summariesReady,
- error,
- };
-}
diff --git a/app/transcriptCache.ts b/app/transcriptCache.ts
@@ -1,99 +0,0 @@
-"use client";
-
-import type { TranscriptDetail } from "@/lib/transcripts";
-import type { ChannelTranscriptsManifest } from "@/lib/manifest";
-import { transcriptPageFileName } from "@/lib/manifest";
-import { idbGet, idbPutBatch } from "./transcriptStore";
-
-const resolved = new Map<string, TranscriptDetail>();
-const inFlight = new Map<string, Promise<TranscriptDetail>>();
-const channelManifests = new Map<
- string,
- Promise<ChannelTranscriptsManifest>
->();
-const pagePromises = new Map<string, Promise<TranscriptDetail[]>>();
-
-export function fetchTranscript(slug: string): Promise<TranscriptDetail> {
- const hit = resolved.get(slug);
- if (hit) return Promise.resolve(hit);
- const flying = inFlight.get(slug);
- if (flying) return flying;
-
- const p = load(slug).then((detail) => {
- resolved.set(slug, detail);
- inFlight.delete(slug);
- return detail;
- });
- p.catch(() => {
- inFlight.delete(slug);
- });
- inFlight.set(slug, p);
- return p;
-}
-
-function fetchChannelManifest(
- channelSlug: string,
-): Promise<ChannelTranscriptsManifest> {
- let p = channelManifests.get(channelSlug);
- if (!p) {
- p = fetch(`/transcripts/${channelSlug}/manifest.json`).then((r) => {
- if (!r.ok)
- throw new Error(
- `Failed to fetch transcripts manifest for ${channelSlug}`,
- );
- return r.json() as Promise<ChannelTranscriptsManifest>;
- });
- p.catch(() => channelManifests.delete(channelSlug));
- channelManifests.set(channelSlug, p);
- }
- return p;
-}
-
-function fetchPage(
- channelSlug: string,
- pageIndex: number,
-): Promise<TranscriptDetail[]> {
- const key = `${channelSlug}:${pageIndex}`;
- let p = pagePromises.get(key);
- if (!p) {
- p = fetch(
- `/transcripts/${channelSlug}/${transcriptPageFileName(pageIndex)}`,
- ).then((r) => {
- if (!r.ok)
- throw new Error(
- `Failed to fetch transcript page ${channelSlug}/${pageIndex}`,
- );
- return r.json() as Promise<TranscriptDetail[]>;
- });
- p.catch(() => pagePromises.delete(key));
- pagePromises.set(key, p);
- }
- return p;
-}
-
-async function load(slug: string): Promise<TranscriptDetail> {
- const stored = await idbGet(slug);
- if (stored) return stored;
- const slashIdx = slug.indexOf("/");
- if (slashIdx < 0) throw new Error(`Malformed transcript slug: ${slug}`);
- const channelSlug = slug.slice(0, slashIdx);
- const videoId = slug.slice(slashIdx + 1);
- const manifest = await fetchChannelManifest(channelSlug);
- const pageIndex = manifest.slugToPage[videoId];
- if (pageIndex === undefined)
- throw new Error(`Unknown transcript slug: ${slug}`);
- const page = await fetchPage(channelSlug, pageIndex);
- let found: TranscriptDetail | undefined;
- for (const entry of page) {
- if (entry.slug === slug) found = entry;
- // Opportunistically warm the per-slug memory + IDB caches so subsequent
- // fetches for other videos in this page hit without a network round-trip.
- // Huge win for searchPipeline which iterates many slugs from the same
- // channel/page.
- resolved.set(entry.slug, entry);
- idbPutBatch(entry);
- }
- if (!found)
- throw new Error(`Transcript ${slug} missing from page ${pageIndex}`);
- return found;
-}
diff --git a/app/transcriptStore.ts b/app/transcriptStore.ts
@@ -1,147 +0,0 @@
-"use client";
-
-import type { TranscriptDetail } from "@/lib/transcripts";
-
-const DB_NAME = "yt-dlp-transcript-browser";
-const DB_VERSION = 1;
-const STORE = "transcripts";
-
-type Mode = "pending" | "ok" | "unavailable";
-
-let mode: Mode = "pending";
-let dbPromise: Promise<IDBDatabase | null> | null = null;
-
-function openDb(): Promise<IDBDatabase | null> {
- if (typeof indexedDB === "undefined") {
- mode = "unavailable";
- return Promise.resolve(null);
- }
- if (dbPromise) return dbPromise;
- dbPromise = new Promise<IDBDatabase | null>((resolve) => {
- let req: IDBOpenDBRequest;
- try {
- req = indexedDB.open(DB_NAME, DB_VERSION);
- } catch {
- downgrade("open threw");
- resolve(null);
- return;
- }
- req.onupgradeneeded = () => {
- const db = req.result;
- if (!db.objectStoreNames.contains(STORE)) {
- db.createObjectStore(STORE, { keyPath: "slug" });
- }
- };
- req.onsuccess = () => {
- mode = "ok";
- resolve(req.result);
- };
- req.onerror = () => {
- downgrade("open failed");
- resolve(null);
- };
- req.onblocked = () => {
- downgrade("open blocked");
- resolve(null);
- };
- });
- return dbPromise;
-}
-
-function downgrade(reason: string): void {
- if (mode === "unavailable") return;
- mode = "unavailable";
- console.warn(
- `[transcriptStore] IndexedDB unavailable (${reason}); falling back to network-only.`,
- );
-}
-
-export async function idbGet(
- slug: string,
-): Promise<TranscriptDetail | null> {
- const db = await openDb();
- if (!db) return null;
- return new Promise<TranscriptDetail | null>((resolve) => {
- let req: IDBRequest<TranscriptDetail | undefined>;
- try {
- const tx = db.transaction(STORE, "readonly");
- req = tx.objectStore(STORE).get(slug) as IDBRequest<
- TranscriptDetail | undefined
- >;
- } catch {
- downgrade("read tx threw");
- resolve(null);
- return;
- }
- req.onsuccess = () => resolve(req.result ?? null);
- req.onerror = () => resolve(null);
- });
-}
-
-let pending: TranscriptDetail[] = [];
-let flushScheduled = false;
-let flushInFlight: Promise<void> | null = null;
-
-export function idbPutBatch(detail: TranscriptDetail): void {
- if (mode === "unavailable") return;
- pending.push(detail);
- if (flushScheduled) return;
- flushScheduled = true;
- queueMicrotask(() => {
- flushScheduled = false;
- void flush();
- });
-}
-
-async function flush(): Promise<void> {
- if (flushInFlight) {
- await flushInFlight;
- }
- if (pending.length === 0) return;
- const batch = pending;
- pending = [];
- flushInFlight = writeBatch(batch).finally(() => {
- flushInFlight = null;
- if (pending.length > 0 && !flushScheduled) {
- flushScheduled = true;
- queueMicrotask(() => {
- flushScheduled = false;
- void flush();
- });
- }
- });
- await flushInFlight;
-}
-
-async function writeBatch(batch: TranscriptDetail[]): Promise<void> {
- const db = await openDb();
- if (!db) return;
- return new Promise<void>((resolve) => {
- let tx: IDBTransaction;
- try {
- tx = db.transaction(STORE, "readwrite");
- } catch {
- downgrade("write tx threw");
- resolve();
- return;
- }
- const store = tx.objectStore(STORE);
- for (const entry of batch) {
- try {
- store.put(entry);
- } catch {
- // Per-entry errors (e.g. unclonable values) shouldn't fail the batch.
- }
- }
- tx.oncomplete = () => resolve();
- tx.onerror = () => {
- // Quota errors land here; degrade silently after one warn.
- downgrade("write tx error");
- resolve();
- };
- tx.onabort = () => {
- downgrade("write tx abort");
- resolve();
- };
- });
-}
diff --git a/app/OdyseePlayer.tsx b/common/components/OdyseePlayer.tsx
diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx
@@ -0,0 +1,512 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type ReactPlayerType from "react-player";
+import { fetchTranscript } from "./transcriptCache";
+import { useUrlParams, writeUrlParams } from "./urlState";
+import { formatDate, formatDuration } from "../lib/format";
+import type { Platform } from "../lib/transcripts";
+import type { RumblePlayerHandle } from "./RumblePlayer";
+import type { OdyseePlayerHandle } from "./OdyseePlayer";
+
+const ReactPlayer = dynamic(() => import("react-player/youtube"), {
+ ssr: false,
+});
+
+const RumblePlayer = dynamic(() => import("./RumblePlayer"), {
+ ssr: false,
+});
+
+const OdyseePlayer = dynamic(() => import("./OdyseePlayer"), {
+ ssr: false,
+});
+
+type PlayerHandle =
+ | Pick<ReactPlayerType, "seekTo">
+ | RumblePlayerHandle
+ | OdyseePlayerHandle;
+
+export type Cue = { start: number; end: number; text: string };
+
+export type TranscriptData = {
+ slug: string;
+ id: string;
+ title: string;
+ uploadDate: string;
+ date: string;
+ duration: string;
+ channel: string;
+ description?: string;
+ isLivestream: boolean;
+ ageRestricted: boolean;
+ platform: Platform;
+ webpageUrl: string;
+ cues?: Cue[];
+};
+
+type Status = "idle" | "loading" | "ready";
+export type DisplayMode = "modal" | "mini" | "hidden";
+
+type Detail = {
+ slug: string;
+ id: string;
+ title: string;
+ uploadDate: string;
+ duration: number;
+ channel: string;
+ description?: string;
+ isLivestream: boolean;
+ ageRestricted: boolean;
+ platform: Platform;
+ webpageUrl: string;
+ cues?: Cue[];
+};
+
+type ClipState = {
+ slug: string | null;
+ start: number | null;
+ end: number | null;
+};
+
+type PlayerState = {
+ activeSlug: string | null;
+ data: TranscriptData | null;
+ status: Status;
+ displayMode: DisplayMode;
+ modalOpen: boolean;
+ playing: boolean;
+ currentTime: number;
+ clipStart: number | null;
+ clipEnd: number | null;
+ openTranscript: (slug: string, start?: number) => void;
+ setDisplayMode: (mode: DisplayMode) => void;
+ closePlayer: () => void;
+ markClipStart: () => void;
+ markClipEnd: () => void;
+ clearClip: () => void;
+ copyDownloadCommand: () => Promise<boolean>;
+ copyShareUrl: () => Promise<boolean>;
+ openInPreservetube: () => void;
+ seekTo: (seconds: number) => void;
+};
+
+const Ctx = createContext<PlayerState | null>(null);
+
+export function usePlayer(): PlayerState {
+ const v = useContext(Ctx);
+ if (!v) throw new Error("usePlayer must be inside PlayerProvider");
+ return v;
+}
+
+function toHMS(seconds: number): string {
+ const s = Math.max(0, Math.floor(seconds));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
+}
+
+export function PlayerProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const { v: urlSlug, t: urlTime } = useUrlParams();
+ const [detail, setDetail] = useState<Detail | null>(null);
+ const [displayState, setDisplayState] = useState<{
+ slug: string;
+ mode: DisplayMode;
+ } | null>(null);
+ const [clip, setClip] = useState<ClipState>({
+ slug: null,
+ start: null,
+ end: null,
+ });
+ const [playing, setPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const playerRef = useRef<PlayerHandle | null>(null);
+ const pendingSeekRef = useRef<number | null>(null);
+ const readyForSlugRef = useRef<string | null>(null);
+
+ const activeSlug = urlSlug;
+ const displayMode: DisplayMode =
+ displayState?.slug === activeSlug ? displayState.mode : "modal";
+ const modalOpen = activeSlug !== null && displayMode === "modal";
+ const detailMatches = detail?.slug === activeSlug;
+ const data: TranscriptData | null = useMemo(() => {
+ 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,
+ isLivestream: detail.isLivestream,
+ ageRestricted: detail.ageRestricted,
+ platform: detail.platform,
+ webpageUrl: detail.webpageUrl,
+ cues: detail.cues,
+ };
+ }, [detail, detailMatches]);
+ const status: Status = !activeSlug
+ ? "idle"
+ : detailMatches
+ ? "ready"
+ : "loading";
+
+ const clipStart = clip.slug && clip.slug === activeSlug ? clip.start : null;
+ const clipEnd = clip.slug && clip.slug === activeSlug ? clip.end : null;
+
+ const openTranscript = useCallback(
+ (slug: string, start?: number) => {
+ setDisplayState({ slug, mode: "modal" });
+ const t = typeof start === "number" ? Math.round(start) : null;
+ if (slug === activeSlug && t !== null && playerRef.current) {
+ playerRef.current.seekTo(t, "seconds");
+ setCurrentTime(t);
+ setPlaying(true);
+ }
+ writeUrlParams({ v: slug, t });
+ },
+ [activeSlug],
+ );
+
+ const setDisplayMode = useCallback(
+ (mode: DisplayMode) => {
+ if (!activeSlug) return;
+ setDisplayState({ slug: activeSlug, mode });
+ },
+ [activeSlug],
+ );
+
+ const closePlayer = useCallback(() => {
+ setDisplayState(null);
+ writeUrlParams({ v: null, t: null });
+ }, []);
+
+ const markClipStart = useCallback(() => {
+ if (!activeSlug) return;
+ setClip((prev) => ({
+ slug: activeSlug,
+ start: currentTime,
+ end: prev.slug === activeSlug ? prev.end : null,
+ }));
+ }, [activeSlug, currentTime]);
+
+ const markClipEnd = useCallback(() => {
+ if (!activeSlug) return;
+ setClip((prev) => ({
+ slug: activeSlug,
+ start: prev.slug === activeSlug ? prev.start : null,
+ end: currentTime,
+ }));
+ }, [activeSlug, currentTime]);
+
+ const clearClip = useCallback(() => {
+ setClip({ slug: null, start: null, end: null });
+ }, []);
+
+ const copyDownloadCommand = useCallback(async (): Promise<boolean> => {
+ if (!data) return false;
+ const url = data.webpageUrl;
+ const sections =
+ clipStart !== null && clipEnd !== null
+ ? `--download-sections "*${toHMS(clipStart)}-${toHMS(clipEnd)}" `
+ : "";
+ const cmd = `yt-dlp ${sections}"${url}"`;
+ try {
+ await navigator.clipboard.writeText(cmd);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [data, clipStart, clipEnd]);
+
+ const copyShareUrl = useCallback(async (): Promise<boolean> => {
+ if (!data) return false;
+ const t = data.platform === "youtube" ? currentTime : (urlTime ?? 0);
+ const secs = Math.max(0, Math.floor(t));
+ const params = new URLSearchParams();
+ params.set("v", data.slug);
+ if (secs > 0) params.set("t", String(secs));
+ const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [data, currentTime, urlTime]);
+
+ const openInPreservetube = useCallback(() => {
+ if (!data || data.platform !== "youtube") return;
+ window.open(
+ `https://preservetube.com/watch?v=${encodeURIComponent(data.id)}`,
+ "_blank",
+ "noopener,noreferrer",
+ );
+ }, [data]);
+
+ const seekTo = useCallback((seconds: number) => {
+ if (playerRef.current) {
+ playerRef.current.seekTo(seconds, "seconds");
+ setPlaying(true);
+ } else {
+ pendingSeekRef.current = seconds;
+ }
+ setCurrentTime(seconds);
+ }, []);
+
+ useEffect(() => {
+ if (!urlSlug) return;
+ readyForSlugRef.current = null;
+ pendingSeekRef.current = urlTime ?? 0;
+ let cancelled = false;
+ fetchTranscript(urlSlug)
+ .then((full) => {
+ if (cancelled) return;
+ setDetail({
+ slug: urlSlug,
+ id: full.id,
+ title: full.title,
+ uploadDate: full.uploadDate,
+ duration: full.duration,
+ channel: full.channel,
+ description: full.description,
+ isLivestream: full.isLivestream,
+ ageRestricted: full.ageRestricted,
+ platform: full.platform,
+ webpageUrl: full.webpageUrl,
+ cues: full.cues,
+ });
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setDetail(null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ // urlTime intentionally omitted — t changes shouldn't refetch
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [urlSlug]);
+
+ useEffect(() => {
+ if (urlTime === null) return;
+ if (readyForSlugRef.current !== urlSlug) return;
+ playerRef.current?.seekTo(urlTime, "seconds");
+ setCurrentTime(urlTime);
+ }, [urlSlug, urlTime]);
+
+ // Rumble / Odysee have no onReady or progress events; seed currentTime from
+ // the URL so clip-mark buttons and active-cue highlighting have a baseline,
+ // and mark the slug ready so later ?t= changes can seek via the imperative
+ // handle.
+ useEffect(() => {
+ if (detail?.platform === "rumble" || detail?.platform === "odysee") {
+ readyForSlugRef.current = detail.slug;
+ pendingSeekRef.current = null;
+ setCurrentTime(urlTime ?? 0);
+ }
+ // urlTime intentionally omitted — the urlTime effect above handles later changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [detail]);
+
+ useEffect(() => {
+ if (!modalOpen) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setDisplayMode("mini");
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [modalOpen, setDisplayMode]);
+
+ const handleReady = () => {
+ if (
+ readyForSlugRef.current !== activeSlug &&
+ pendingSeekRef.current !== null &&
+ playerRef.current
+ ) {
+ playerRef.current.seekTo(pendingSeekRef.current, "seconds");
+ setPlaying(true);
+ pendingSeekRef.current = null;
+ readyForSlugRef.current = activeSlug;
+ }
+ };
+
+ const value: PlayerState = {
+ activeSlug,
+ data,
+ status,
+ displayMode,
+ modalOpen,
+ playing,
+ currentTime,
+ clipStart,
+ clipEnd,
+ openTranscript,
+ setDisplayMode,
+ closePlayer,
+ markClipStart,
+ markClipEnd,
+ clearClip,
+ copyDownloadCommand,
+ copyShareUrl,
+ openInPreservetube,
+ seekTo,
+ };
+
+ const showPlayer = activeSlug !== null && data !== null;
+
+ let containerClass = "";
+ if (displayMode === "modal") {
+ containerClass =
+ "fixed left-1/2 -translate-x-1/2 top-16 w-[min(92vw,960px)] aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl";
+ } else if (displayMode === "mini") {
+ containerClass =
+ "fixed bottom-4 right-4 w-72 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40";
+ } else {
+ // hidden — keep iframe rendered with size so YT keeps playing,
+ // but pull it offscreen and remove from interaction.
+ containerClass =
+ "fixed -left-[10000px] -top-[10000px] w-80 aspect-video pointer-events-none";
+ }
+
+ return (
+ <Ctx.Provider value={value}>
+ {children}
+
+ {showPlayer && (
+ <div className={containerClass} aria-hidden={displayMode === "hidden"}>
+ {data.platform === "rumble" ? (
+ <RumblePlayer
+ key={data.id}
+ ref={(p: RumblePlayerHandle | null) => {
+ playerRef.current = p;
+ }}
+ videoId={data.id}
+ startSeconds={urlTime ?? 0}
+ />
+ ) : data.platform === "odysee" ? (
+ <OdyseePlayer
+ key={data.id}
+ ref={(p: OdyseePlayerHandle | null) => {
+ playerRef.current = p;
+ }}
+ videoId={data.id}
+ startSeconds={urlTime ?? 0}
+ />
+ ) : (
+ <ReactPlayer
+ ref={(p: ReactPlayerType | null) => {
+ playerRef.current = p;
+ }}
+ url={`https://www.youtube.com/watch?v=${data.id}`}
+ width="100%"
+ height="100%"
+ playing={playing}
+ controls
+ onReady={handleReady}
+ onPlay={() => setPlaying(true)}
+ onPause={() => setPlaying(false)}
+ onProgress={(s: { playedSeconds: number }) =>
+ setCurrentTime(s.playedSeconds)
+ }
+ />
+ )}
+ {displayMode === "mini" && (
+ <button
+ type="button"
+ aria-label="Reopen transcript"
+ onClick={() => setDisplayMode("modal")}
+ className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent text-white text-xs px-3 py-1.5 text-left hover:from-black/95"
+ >
+ <span className="font-medium line-clamp-1">{data.title}</span>
+ <span className="opacity-70">click to reopen</span>
+ </button>
+ )}
+ </div>
+ )}
+
+ {showPlayer && displayMode === "hidden" && (
+ <HiddenControls
+ title={data.title}
+ onShow={() => setDisplayMode("modal")}
+ onMini={() => setDisplayMode("mini")}
+ onClose={closePlayer}
+ />
+ )}
+ </Ctx.Provider>
+ );
+}
+
+function HiddenControls({
+ title,
+ onShow,
+ onMini,
+ onClose,
+}: {
+ title: string;
+ onShow: () => void;
+ onMini: () => void;
+ onClose: () => void;
+}) {
+ return (
+ <div className="fixed bottom-4 right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)]">
+ <span className="text-xs opacity-70 shrink-0">♪</span>
+ <span className="text-xs font-medium truncate flex-1 min-w-0">
+ {title}
+ </span>
+ <ControlButton title="Show player" onClick={onShow} char="▢" />
+ <ControlButton title="Collapse to mini-player" onClick={onMini} char="↘" />
+ <ControlButton title="Close player" onClick={onClose} char="✕" />
+ </div>
+ );
+}
+
+function ControlButton({
+ title,
+ onClick,
+ char,
+ disabled,
+ highlight,
+}: {
+ title: string;
+ onClick: () => void;
+ char: string;
+ disabled?: boolean;
+ highlight?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ title={title}
+ aria-label={title}
+ onClick={onClick}
+ disabled={disabled}
+ className={`shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-md text-sm font-mono transition ${
+ disabled
+ ? "bg-white/5 text-white/30 cursor-not-allowed"
+ : highlight
+ ? "bg-amber-500/30 hover:bg-amber-500/50 text-white ring-1 ring-amber-400/60"
+ : "bg-white/10 hover:bg-white/20 text-white"
+ }`}
+ >
+ {char}
+ </button>
+ );
+}
+
+export { ControlButton, toHMS };
diff --git a/app/QueryProvider.tsx b/common/components/QueryProvider.tsx
diff --git a/app/RumblePlayer.tsx b/common/components/RumblePlayer.tsx
diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { ControlButton, toHMS, usePlayer } from "./PlayerProvider";
+import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
+import { formatTimestamp } from "../lib/vtt";
+import { formatDate } from "../lib/format";
+
+export default function TranscriptModal() {
+ const {
+ modalOpen,
+ activeSlug,
+ data,
+ status,
+ currentTime,
+ clipStart,
+ clipEnd,
+ setDisplayMode,
+ closePlayer,
+ markClipStart,
+ markClipEnd,
+ clearClip,
+ copyDownloadCommand,
+ copyShareUrl,
+ openInPreservetube,
+ seekTo,
+ } = usePlayer();
+ const activeRef = useRef<HTMLLIElement | null>(null);
+ const scrollRef = useRef<HTMLDivElement | null>(null);
+ const [copied, setCopied] = useState(false);
+ const copyResetRef = useRef<number | null>(null);
+ const [shareCopied, setShareCopied] = useState(false);
+ const shareResetRef = useRef<number | null>(null);
+
+ const cues = data?.cues ?? [];
+ const activeIndex = findActiveIndex(cues, currentTime);
+
+ useEffect(() => {
+ if (!modalOpen) return;
+ activeRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }, [activeIndex, modalOpen]);
+
+ useEffect(() => {
+ if (modalOpen) {
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = prev;
+ };
+ }
+ }, [modalOpen]);
+
+ useEffect(() => {
+ return () => {
+ if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current);
+ if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current);
+ };
+ }, []);
+
+ if (!modalOpen || !activeSlug) return null;
+
+ const canDownload = clipStart !== null && clipEnd !== null && clipEnd > clipStart;
+ const onCopy = async () => {
+ const ok = await copyDownloadCommand();
+ if (!ok) return;
+ setCopied(true);
+ if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current);
+ copyResetRef.current = window.setTimeout(() => setCopied(false), 1800);
+ };
+ const onShare = async () => {
+ const ok = await copyShareUrl();
+ if (!ok) return;
+ setShareCopied(true);
+ if (shareResetRef.current !== null) window.clearTimeout(shareResetRef.current);
+ shareResetRef.current = window.setTimeout(() => setShareCopied(false), 1800);
+ };
+
+ return (
+ <div className="fixed inset-0 z-50 flex flex-col">
+ <button
+ type="button"
+ aria-label="Collapse to mini-player"
+ onClick={() => setDisplayMode("mini")}
+ className="absolute inset-0 bg-black/70 backdrop-blur-sm"
+ />
+ <div className="relative flex flex-col items-center w-full h-full overflow-hidden pt-4 pointer-events-none">
+ <div
+ className="w-[min(92vw,960px)] pointer-events-auto flex flex-col gap-3"
+ style={{
+ marginTop: "calc(min(92vw, 960px) * 9 / 16 + 3rem)",
+ maxHeight:
+ "calc(100vh - (min(92vw, 960px) * 9 / 16) - 5rem)",
+ }}
+ >
+ <div className="flex items-center gap-2 flex-wrap rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-2">
+ <ControlButton
+ title="Mark clip start at current time"
+ onClick={markClipStart}
+ char="["
+ highlight={clipStart !== null}
+ />
+ <ControlButton
+ title="Mark clip end at current time"
+ onClick={markClipEnd}
+ char="]"
+ highlight={clipEnd !== null}
+ />
+ <ClipReadout
+ clipStart={clipStart}
+ clipEnd={clipEnd}
+ onClear={clearClip}
+ />
+ <ControlButton
+ title={
+ canDownload
+ ? copied
+ ? "Copied!"
+ : "Copy yt-dlp download command for this clip"
+ : "Mark both clip start and end first"
+ }
+ onClick={onCopy}
+ char={copied ? "✓" : "↓"}
+ disabled={!canDownload}
+ />
+ <div className="flex-1" />
+ <ControlButton
+ title={
+ !data
+ ? "Loading…"
+ : shareCopied
+ ? "Copied!"
+ : "Copy share link at current time"
+ }
+ onClick={onShare}
+ char={shareCopied ? "✓" : "⤴"}
+ disabled={!data}
+ />
+ {data?.platform === "youtube" && (
+ <ControlButton
+ title="Open in Preservetube"
+ onClick={openInPreservetube}
+ char="⧉"
+ />
+ )}
+ <ControlButton
+ title="Collapse to mini-player"
+ onClick={() => setDisplayMode("mini")}
+ char="↘"
+ />
+ <ControlButton
+ title="Hide player (keep audio playing)"
+ onClick={() => setDisplayMode("hidden")}
+ char="—"
+ />
+ <ControlButton
+ title="Close player"
+ onClick={closePlayer}
+ char="✕"
+ />
+ </div>
+
+ <div className="text-white min-w-0">
+ <h2 className="text-lg font-semibold leading-tight truncate">
+ {data?.title ?? "Loading…"}
+ </h2>
+ {data && (
+ <p className="text-xs text-zinc-300 mt-0.5 flex items-center gap-1.5 flex-wrap">
+ <span>
+ {formatDate(data.uploadDate)}
+ {data.channel && ` · ${data.channel}`}
+ </span>
+ {data.isLivestream && <LivestreamBadge />}
+ {data.ageRestricted && <AgeRestrictedBadge />}
+ </p>
+ )}
+ </div>
+
+ <div
+ ref={scrollRef}
+ className="flex-1 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900"
+ >
+ {status === "loading" && (
+ <p className="text-sm text-zinc-400 p-4">Loading transcript…</p>
+ )}
+ {status === "ready" && cues.length === 0 && (
+ <p className="text-sm text-zinc-400 p-4">No cues available.</p>
+ )}
+ {status === "ready" && cues.length > 0 && (
+ <ol className="divide-y divide-zinc-800 text-zinc-100">
+ {cues.map((cue, i) => {
+ const isActive = i === activeIndex;
+ return (
+ <li
+ key={i}
+ ref={isActive ? activeRef : undefined}
+ className={
+ isActive ? "bg-blue-950/50" : ""
+ }
+ >
+ <button
+ type="button"
+ onClick={() => seekTo(cue.start)}
+ className="w-full text-left flex gap-3 px-3 py-2 hover:bg-zinc-800"
+ >
+ <span className="text-xs font-mono text-zinc-400 shrink-0 w-16 pt-0.5">
+ {formatTimestamp(cue.start)}
+ </span>
+ <span className="text-sm">{cue.text}</span>
+ </button>
+ </li>
+ );
+ })}
+ </ol>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function ClipReadout({
+ clipStart,
+ clipEnd,
+ onClear,
+}: {
+ clipStart: number | null;
+ clipEnd: number | null;
+ onClear: () => void;
+}) {
+ if (clipStart === null && clipEnd === null) {
+ return (
+ <span className="text-xs text-zinc-500 font-mono px-1">
+ no clip set
+ </span>
+ );
+ }
+ return (
+ <span className="text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1">
+ <span>{clipStart !== null ? toHMS(clipStart) : "--:--:--"}</span>
+ <span className="opacity-60">→</span>
+ <span>{clipEnd !== null ? toHMS(clipEnd) : "--:--:--"}</span>
+ <button
+ type="button"
+ title="Clear clip"
+ onClick={onClear}
+ className="ml-1 text-zinc-400 hover:text-white"
+ >
+ ×
+ </button>
+ </span>
+ );
+}
+
+function findActiveIndex(
+ cues: { start: number }[],
+ t: number,
+): number {
+ if (!cues.length) return -1;
+ let lo = 0;
+ let hi = cues.length - 1;
+ let found = -1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1;
+ if (cues[mid].start <= t) {
+ found = mid;
+ lo = mid + 1;
+ } else {
+ hi = mid - 1;
+ }
+ }
+ return found;
+}
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -0,0 +1,737 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { usePlayer } from "./PlayerProvider";
+import { useSummaries } from "./summariesCache";
+import {
+ createSearchPipeline,
+ filterSlugs,
+ type Hit,
+ type PipelineController,
+} from "./searchPipeline";
+import { useUrlParams, writeUrlParams } from "./urlState";
+import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
+import type { DisplaySummary } from "../lib/transcripts";
+
+type Summary = DisplaySummary;
+
+type HitGroup = {
+ slug: string;
+ title: string;
+ channel: string;
+ date: string;
+ isLivestream: boolean;
+ ageRestricted: boolean;
+ hits: Hit[];
+};
+
+const DEFAULT_MAX_HITS = 500;
+const DEFAULT_FETCH_CONCURRENCY = 6;
+const DEFAULT_FLUSH_INTERVAL_MS = 120;
+const DEFAULT_GROUPS_PAGE_SIZE = 50;
+
+export default function TranscriptSearch() {
+ const {
+ q: query,
+ re: useRegex,
+ v: activeVideo,
+ t: activeTime,
+ ch: committedChannelList,
+ nov,
+ nol,
+ naa,
+ nar,
+ } = useUrlParams();
+ // Content-based key so Set identity doesn't churn when other URL params
+ // (v/t) change — otherwise opening the player modal would restart the
+ // search pipeline from scratch.
+ const committedChannelsKey = committedChannelList
+ .slice()
+ .sort()
+ .join("\u0000");
+ const committedChannels = useMemo(
+ () => new Set(committedChannelList),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [committedChannelsKey],
+ );
+ const [input, setInput] = useState(query);
+ useEffect(() => {
+ setInput(query);
+ }, [query]);
+
+ const [draftExcludedChannels, setDraftExcludedChannels] = useState<
+ Set<string>
+ >(() => new Set(committedChannelList));
+ const [draftNov, setDraftNov] = useState(nov);
+ const [draftNol, setDraftNol] = useState(nol);
+ const [draftNaa, setDraftNaa] = useState(naa);
+ const [draftNar, setDraftNar] = useState(nar);
+ useEffect(() => {
+ setDraftExcludedChannels(new Set(committedChannelList));
+ }, [committedChannelList]);
+ useEffect(() => setDraftNov(nov), [nov]);
+ useEffect(() => setDraftNol(nol), [nol]);
+ useEffect(() => setDraftNaa(naa), [naa]);
+ useEffect(() => setDraftNar(nar), [nar]);
+
+ const [searchExecuted, setSearchExecuted] = useState(() => {
+ if (typeof window === "undefined") return true;
+ const params = new URLSearchParams(window.location.search);
+ return !(params.has("v") && (params.get("q") ?? "") !== "");
+ });
+
+ const { manifest, summaries, loadedPages, pageCount, summariesReady } =
+ useSummaries();
+ const transcripts: Summary[] | null = summariesReady ? summaries : null;
+
+ const [hitsBySlug, setHitsBySlug] = useState<Record<string, Hit[]>>({});
+ const [totalHits, setTotalHits] = useState(0);
+ const [processed, setProcessed] = useState(0);
+ const [totalToProcess, setTotalToProcess] = useState(0);
+ const [pipelineDone, setPipelineDone] = useState(false);
+ const [capped, setCapped] = useState(false);
+
+ // Advanced options — configurable from the UI, pre-filled with defaults.
+ const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS);
+ const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false);
+ const [groupsBatchValue, setGroupsBatchValue] = useState(
+ DEFAULT_GROUPS_PAGE_SIZE,
+ );
+ const [groupsBatchUnlimited, setGroupsBatchUnlimited] = useState(false);
+ const [fetchConcurrency, setFetchConcurrency] = useState(
+ DEFAULT_FETCH_CONCURRENCY,
+ );
+ const [flushIntervalMs, setFlushIntervalMs] = useState(
+ DEFAULT_FLUSH_INTERVAL_MS,
+ );
+ const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue;
+ const groupsBatchSize = groupsBatchUnlimited
+ ? Infinity
+ : groupsBatchValue;
+
+ const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS);
+ const [groupsShown, setGroupsShown] = useState<number>(
+ DEFAULT_GROUPS_PAGE_SIZE,
+ );
+ const pipelineRef = useRef<PipelineController | null>(null);
+ const { openTranscript } = usePlayer();
+
+ const trimmed = searchExecuted ? query.trim() : "";
+
+ // Reset the live caps on each new search. Uses the currently configured
+ // batch sizes so an advanced-options change only takes effect on the next
+ // search (no jarring mid-search restarts when tuning).
+ useEffect(() => {
+ setHitLimit(hitBatchSize);
+ setGroupsShown(groupsBatchSize);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [trimmed, useRegex]);
+
+ const channelOptions = useMemo<string[]>(() => {
+ if (manifest?.channels.length) {
+ return manifest.channels
+ .map((c) => c.name)
+ .filter((n) => n)
+ .sort((a, b) => a.localeCompare(b));
+ }
+ if (!transcripts) return [];
+ const names = new Set<string>();
+ for (const t of transcripts) if (t.channel) names.add(t.channel);
+ return Array.from(names).sort((a, b) => a.localeCompare(b));
+ }, [manifest, transcripts]);
+
+ const passesFilter = useMemo(() => {
+ return (t: Summary) => {
+ if (committedChannels.has(t.channel)) return false;
+ if (t.isLivestream ? nol : nov) return false;
+ if (t.ageRestricted ? nar : naa) return false;
+ return true;
+ };
+ }, [committedChannels, nov, nol, naa, nar]);
+
+ // Stable signature of the filter inputs; used as a single dep in the search
+ // pipeline so reference-unstable upstream values (new `ch: []` array on every
+ // URL change) don't cancel an in-flight search when the modal opens.
+ const filterKey = `${committedChannelsKey}|${nov ? 1 : 0}|${nol ? 1 : 0}|${naa ? 1 : 0}|${nar ? 1 : 0}`;
+
+ const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => {
+ if (!trimmed || !useRegex) return { re: null, error: null };
+ try {
+ return { re: new RegExp(trimmed, "i"), error: null };
+ } catch (e) {
+ return { re: null, error: (e as Error).message };
+ }
+ }, [trimmed, useRegex]);
+
+ // Pipeline lifecycle: create a new pipeline when inputs change, and cancel
+ // the prior one. hitLimit is NOT a dep here — it's pushed into the live
+ // pipeline via setHitLimit so bumping the cap resumes rather than restarts.
+ useEffect(() => {
+ pipelineRef.current?.cancel();
+ pipelineRef.current = null;
+
+ if (!trimmed) {
+ setHitsBySlug({});
+ setTotalHits(0);
+ setProcessed(0);
+ setTotalToProcess(0);
+ setPipelineDone(false);
+ setCapped(false);
+ return;
+ }
+ if (!transcripts) return;
+ if (useRegex && !regex.re) return;
+
+ const slugs = filterSlugs(transcripts, passesFilter);
+
+ const controller = createSearchPipeline({
+ slugs,
+ query: trimmed,
+ useRegex,
+ regex: regex.re,
+ initialHitLimit: hitLimit,
+ concurrency: fetchConcurrency,
+ flushIntervalMs,
+ emit: (u) => {
+ setHitsBySlug(u.hitsBySlug);
+ setTotalHits(u.totalHits);
+ setProcessed(u.processed);
+ setTotalToProcess(u.totalToProcess);
+ setCapped(u.capped);
+ setPipelineDone(u.done);
+ },
+ });
+ pipelineRef.current = controller;
+
+ return () => {
+ controller.cancel();
+ if (pipelineRef.current === controller) pipelineRef.current = null;
+ };
+ // hitLimit intentionally omitted — bumped via setHitLimit below.
+ // passesFilter captured via filterKey (content signature).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [trimmed, transcripts, filterKey, useRegex, regex.re]);
+
+ // Push hitLimit bumps into the live pipeline so "Show more videos" resumes
+ // the existing traversal instead of restarting from slug 0.
+ useEffect(() => {
+ pipelineRef.current?.setHitLimit(hitLimit);
+ }, [hitLimit]);
+
+ const groups = useMemo<HitGroup[]>(() => {
+ if (!trimmed || !transcripts) return [];
+ const out: HitGroup[] = [];
+ for (const t of transcripts) {
+ const hits = hitsBySlug[t.slug];
+ if (!hits?.length) continue;
+ out.push({
+ slug: t.slug,
+ title: t.title,
+ channel: t.channel,
+ date: t.date,
+ isLivestream: t.isLivestream,
+ ageRestricted: t.ageRestricted,
+ hits,
+ });
+ }
+ return out;
+ }, [trimmed, transcripts, hitsBySlug]);
+
+ const indexLoading = Boolean(trimmed) && !summariesReady;
+ const searching = Boolean(trimmed) && totalToProcess > 0;
+ const pipelineActive = searching && !pipelineDone;
+ const indexProgress =
+ pageCount > 0 ? `${loadedPages}/${pageCount} pages` : "";
+
+ const filtersDirty =
+ !sameSet(draftExcludedChannels, committedChannels) ||
+ draftNov !== nov ||
+ draftNol !== nol ||
+ draftNaa !== naa ||
+ draftNar !== nar;
+ const queryDirty = input.trim() !== (searchExecuted ? query.trim() : "");
+ const commitSearch = () => {
+ setSearchExecuted(true);
+ writeUrlParams({
+ q: input.trim(),
+ ch: Array.from(draftExcludedChannels),
+ nov: draftNov,
+ nol: draftNol,
+ naa: draftNaa,
+ nar: draftNar,
+ });
+ };
+
+ return (
+ <div className="flex flex-col gap-6">
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ commitSearch();
+ }}
+ className="flex flex-col gap-1"
+ >
+ <input
+ type="search"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ placeholder={
+ useRegex
+ ? "Regex pattern (case-insensitive)…"
+ : "Search transcripts..."
+ }
+ className={`w-full rounded-lg border ${
+ regex.error
+ ? "border-red-500"
+ : "border-zinc-300 dark:border-zinc-700"
+ } bg-white dark:bg-zinc-900 px-4 py-3 text-base outline-none focus:ring-2 focus:ring-blue-500 ${
+ useRegex ? "font-mono" : ""
+ }`}
+ />
+ <label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 select-none">
+ <input
+ type="checkbox"
+ checked={useRegex}
+ onChange={(e) => writeUrlParams({ re: e.target.checked })}
+ className="accent-blue-600"
+ />
+ Regex
+ {regex.error && (
+ <span className="text-red-600 font-mono text-xs">
+ {regex.error}
+ </span>
+ )}
+ {(queryDirty || filtersDirty) && (
+ <span className="text-xs text-zinc-400 ml-auto">
+ Press Enter to search
+ </span>
+ )}
+ </label>
+ </form>
+
+ {channelOptions.length > 0 && (
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400 -mt-3">
+ {channelOptions.length > 1 && (
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-zinc-500">
+ Channels
+ </span>
+ {channelOptions.map((name) => {
+ const checked = !draftExcludedChannels.has(name);
+ return (
+ <label
+ key={name}
+ className="flex items-center gap-1.5 select-none"
+ >
+ <input
+ type="checkbox"
+ checked={checked}
+ onChange={(e) => {
+ setDraftExcludedChannels((prev) => {
+ const next = new Set(prev);
+ if (e.target.checked) next.delete(name);
+ else next.add(name);
+ return next;
+ });
+ }}
+ className="accent-blue-600"
+ />
+ {name}
+ </label>
+ );
+ })}
+ </div>
+ )}
+ <label className="flex items-center gap-1.5 select-none">
+ <input
+ type="checkbox"
+ checked={!draftNov}
+ onChange={(e) => setDraftNov(!e.target.checked)}
+ className="accent-blue-600"
+ />
+ Videos
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <input
+ type="checkbox"
+ checked={!draftNol}
+ onChange={(e) => setDraftNol(!e.target.checked)}
+ className="accent-blue-600"
+ />
+ Livestreams
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <input
+ type="checkbox"
+ checked={!draftNaa}
+ onChange={(e) => setDraftNaa(!e.target.checked)}
+ className="accent-blue-600"
+ />
+ All ages
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ <input
+ type="checkbox"
+ checked={!draftNar}
+ onChange={(e) => setDraftNar(!e.target.checked)}
+ className="accent-blue-600"
+ />
+ Age-restricted
+ </label>
+ </div>
+ )}
+
+ <details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400">
+ <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
+ Advanced options
+ </summary>
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
+ <LimitField
+ label="Max hits per batch"
+ hint="Pause after this many matches; “Show more” adds another batch."
+ value={hitBatchValue}
+ unlimited={hitBatchUnlimited}
+ defaultValue={DEFAULT_MAX_HITS}
+ onValueChange={setHitBatchValue}
+ onUnlimitedChange={setHitBatchUnlimited}
+ />
+ <LimitField
+ label="Videos per page"
+ hint="How many matching videos to show at a time."
+ value={groupsBatchValue}
+ unlimited={groupsBatchUnlimited}
+ defaultValue={DEFAULT_GROUPS_PAGE_SIZE}
+ onValueChange={setGroupsBatchValue}
+ onUnlimitedChange={setGroupsBatchUnlimited}
+ />
+ <NumberField
+ label="Fetch concurrency"
+ hint="Parallel transcript downloads while searching. Applies on next search."
+ value={fetchConcurrency}
+ defaultValue={DEFAULT_FETCH_CONCURRENCY}
+ min={1}
+ max={32}
+ onChange={setFetchConcurrency}
+ />
+ <NumberField
+ label="Flush interval (ms)"
+ hint="UI update cadence during search. Applies on next search."
+ value={flushIntervalMs}
+ defaultValue={DEFAULT_FLUSH_INTERVAL_MS}
+ min={16}
+ max={2000}
+ onChange={setFlushIntervalMs}
+ />
+ <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
+ <button
+ type="button"
+ onClick={() => {
+ setHitBatchUnlimited(true);
+ setGroupsBatchUnlimited(true);
+ setHitLimit(Infinity);
+ setGroupsShown(Infinity);
+ }}
+ className="px-3 py-1.5 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-xs"
+ >
+ Search through all transcripts
+ </button>
+ <button
+ type="button"
+ onClick={() => {
+ setHitBatchValue(DEFAULT_MAX_HITS);
+ setHitBatchUnlimited(false);
+ setGroupsBatchValue(DEFAULT_GROUPS_PAGE_SIZE);
+ setGroupsBatchUnlimited(false);
+ setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY);
+ setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
+ }}
+ className="px-3 py-1.5 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-xs"
+ >
+ Reset to defaults
+ </button>
+ </div>
+ </div>
+ </details>
+
+ {!trimmed && (
+ <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 flex items-center gap-2">
+ <span>
+ Transcript mentions{" "}
+ {totalHits > 0
+ ? `(${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{indexProgress ? ` (${indexProgress})` : ""}…
+ </span>
+ )}
+ {searching && (
+ <span className="text-xs text-zinc-400 font-normal">
+ searched {processed}/{totalToProcess}
+ {pipelineActive ? "…" : ""}
+ </span>
+ )}
+ </h2>
+ {!indexLoading && groups.length === 0 && !pipelineActive && (
+ <p className="text-sm text-zinc-500">No transcript mentions.</p>
+ )}
+ {groups.length > 0 && (
+ <div className="flex flex-col gap-3">
+ {groups.slice(0, groupsShown).map((g) => (
+ <div
+ key={g.slug}
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
+ >
+ <button
+ type="button"
+ onClick={() => openTranscript(g.slug)}
+ className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
+ >
+ <span className="font-medium truncate flex-1 min-w-0">
+ {g.title}
+ </span>
+ {g.isLivestream && <LivestreamBadge />}
+ {g.ageRestricted && <AgeRestrictedBadge />}
+ <span className="text-xs text-zinc-500 shrink-0">
+ {g.channel && `${g.channel} · `}
+ {g.date}
+ {` · ${g.hits.length} mention${g.hits.length === 1 ? "" : "s"}`}
+ </span>
+ </button>
+ <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
+ {g.hits.map((h, i) => {
+ const isActive =
+ activeVideo === g.slug && activeTime === h.start;
+ return (
+ <li key={i}>
+ <button
+ type="button"
+ onClick={() => openTranscript(g.slug, h.start)}
+ className={`w-full text-left flex 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-16">
+ {formatSeconds(h.start)}
+ </span>
+ <span className="flex-1 min-w-0 text-sm">
+ {highlight(h.text, trimmed, useRegex)}
+ </span>
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ ))}
+ </div>
+ )}
+ {(groups.length > groupsShown || capped) && (
+ <div className="mt-3 flex justify-center">
+ <button
+ type="button"
+ onClick={() => {
+ setGroupsShown((n) => n + groupsBatchSize);
+ if (capped) setHitLimit((l) => l + hitBatchSize);
+ }}
+ 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"
+ >
+ {groups.length > groupsShown
+ ? `Show more videos (${groups.length - groupsShown} loaded${capped ? ", more available" : ""})`
+ : "Show more videos"}
+ </button>
+ </div>
+ )}
+ </section>
+ </>
+ )}
+ </div>
+ );
+}
+
+function sameSet<T>(a: Set<T>, b: Set<T>): boolean {
+ if (a.size !== b.size) return false;
+ for (const v of a) if (!b.has(v)) return false;
+ return true;
+}
+
+function NumberField({
+ label,
+ hint,
+ value,
+ defaultValue,
+ min,
+ max,
+ onChange,
+ disabled,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ defaultValue: number;
+ min: number;
+ max: number;
+ onChange: (n: number) => void;
+ disabled?: boolean;
+}) {
+ return (
+ <label className="flex flex-col gap-1">
+ <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
+ {label}
+ </span>
+ <input
+ type="number"
+ min={min}
+ max={max}
+ value={Number.isFinite(value) ? value : ""}
+ placeholder={String(defaultValue)}
+ disabled={disabled}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onChange(defaultValue);
+ return;
+ }
+ const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
+ if (Number.isFinite(n)) onChange(n);
+ }}
+ className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ </label>
+ );
+}
+
+function LimitField({
+ label,
+ hint,
+ value,
+ unlimited,
+ defaultValue,
+ onValueChange,
+ onUnlimitedChange,
+}: {
+ label: string;
+ hint?: string;
+ value: number;
+ unlimited: boolean;
+ defaultValue: number;
+ onValueChange: (n: number) => void;
+ onUnlimitedChange: (b: boolean) => void;
+}) {
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex items-baseline justify-between gap-2">
+ <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
+ {label}
+ </span>
+ <label className="flex items-center gap-1 text-xs select-none">
+ <input
+ type="checkbox"
+ checked={unlimited}
+ onChange={(e) => onUnlimitedChange(e.target.checked)}
+ className="accent-blue-600"
+ />
+ No limit
+ </label>
+ </div>
+ <input
+ type="number"
+ min={1}
+ max={1_000_000}
+ value={unlimited ? "" : value}
+ placeholder={unlimited ? "∞" : String(defaultValue)}
+ disabled={unlimited}
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onValueChange(defaultValue);
+ return;
+ }
+ const n = Math.max(1, Math.floor(Number(raw)));
+ if (Number.isFinite(n)) onValueChange(n);
+ }}
+ className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
+ />
+ {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ </div>
+ );
+}
+
+function formatSeconds(s: number): string {
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ const mm = String(m).padStart(2, "0");
+ const ss = String(sec).padStart(2, "0");
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
+}
+
+function highlight(text: string, q: string, useRegex = false) {
+ if (!q) return text;
+ const parts: React.ReactNode[] = [];
+
+ if (useRegex) {
+ let re: RegExp;
+ try {
+ re = new RegExp(q, "gi");
+ } catch {
+ return text;
+ }
+ let last = 0;
+ let m: RegExpExecArray | null;
+ let key = 0;
+ while ((m = re.exec(text)) !== null) {
+ if (m.index > last) parts.push(text.slice(last, m.index));
+ parts.push(
+ <mark
+ key={key++}
+ className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit"
+ >
+ {m[0]}
+ </mark>,
+ );
+ last = m.index + m[0].length;
+ if (m[0].length === 0) re.lastIndex++;
+ }
+ if (last < text.length) parts.push(text.slice(last));
+ return <>{parts}</>;
+ }
+
+ const lower = text.toLowerCase();
+ const ql = q.toLowerCase();
+ let i = 0;
+ while (i < text.length) {
+ const idx = lower.indexOf(ql, i);
+ if (idx === -1) {
+ parts.push(text.slice(i));
+ break;
+ }
+ if (idx > i) parts.push(text.slice(i, idx));
+ parts.push(
+ <mark
+ key={idx}
+ className="bg-yellow-200 dark:bg-yellow-500/30 text-inherit"
+ >
+ {text.slice(idx, idx + q.length)}
+ </mark>,
+ );
+ i = idx + q.length;
+ }
+ return <>{parts}</>;
+}
diff --git a/app/badges.tsx b/common/components/badges.tsx
diff --git a/common/components/searchPipeline.ts b/common/components/searchPipeline.ts
@@ -0,0 +1,251 @@
+import { fetchTranscript } from "./transcriptCache";
+import type { DisplaySummary } from "../lib/transcripts";
+
+export type Hit = { start: number; text: string };
+
+export type PipelineUpdate = {
+ hitsBySlug: Record<string, Hit[]>;
+ totalHits: number;
+ processed: number;
+ totalToProcess: number;
+ capped: boolean;
+ done: boolean;
+};
+
+type PipelineConfig = {
+ slugs: string[];
+ query: string;
+ useRegex: boolean;
+ regex: RegExp | null;
+ initialHitLimit: number;
+ concurrency: number;
+ flushIntervalMs: number;
+ emit: (update: PipelineUpdate) => void;
+};
+
+export type PipelineController = {
+ cancel(): void;
+ setHitLimit(limit: number): void;
+};
+
+// Runs a streaming search over the given slugs. State is closure-captured so
+// `setHitLimit` can raise the cap and re-spawn workers without restarting
+// traversal — workers resume from the shared `idx` cursor.
+export function createSearchPipeline(
+ config: PipelineConfig,
+): PipelineController {
+ const {
+ slugs,
+ query,
+ useRegex,
+ regex,
+ initialHitLimit,
+ concurrency,
+ flushIntervalMs,
+ emit,
+ } = config;
+
+ let cancelled = false;
+ // `idx` is the next-to-claim cursor (leading edge); `completed` counts
+ // slugs that workers have finished scanning (trailing edge). We report
+ // `completed` to the UI so progress reflects actual work done, not
+ // in-flight claims — and it never exceeds slugs.length.
+ let idx = 0;
+ let completed = 0;
+ let totalSoFar = 0;
+ let hitLimit = initialHitLimit;
+ let activeWorkers = 0;
+ let done = false;
+ const localHits: Record<string, Hit[]> = {};
+ let flushTimer: number | null = null;
+
+ const pushUpdate = (overrides: Partial<PipelineUpdate> = {}) => {
+ emit({
+ hitsBySlug: { ...localHits },
+ totalHits: totalSoFar,
+ processed: completed,
+ totalToProcess: slugs.length,
+ // `capped` keys off `idx` (claimed), not `completed` — raising the cap
+ // only helps if there are still unclaimed slugs a worker can pick up.
+ capped: totalSoFar >= hitLimit && idx < slugs.length,
+ done,
+ ...overrides,
+ });
+ };
+
+ const scheduleFlush = () => {
+ if (flushTimer !== null || cancelled) return;
+ flushTimer = window.setTimeout(() => {
+ flushTimer = null;
+ if (cancelled) return;
+ pushUpdate();
+ }, flushIntervalMs);
+ };
+
+ const finalize = () => {
+ if (done || cancelled) return;
+ done = true;
+ if (flushTimer !== null) {
+ window.clearTimeout(flushTimer);
+ flushTimer = null;
+ }
+ pushUpdate();
+ };
+
+ const worker = async () => {
+ activeWorkers++;
+ try {
+ while (!cancelled) {
+ if (totalSoFar >= hitLimit) return;
+ if (idx >= slugs.length) return;
+ const my = idx++;
+ const slug = slugs[my];
+ try {
+ const full = await fetchTranscript(slug);
+ if (cancelled) return;
+ if (full.cues && totalSoFar < hitLimit) {
+ const remaining = hitLimit - totalSoFar;
+ const hits = findHitsInCues(
+ full.cues,
+ query,
+ useRegex,
+ regex,
+ remaining,
+ );
+ if (hits.length > 0) {
+ localHits[slug] = hits;
+ totalSoFar += hits.length;
+ }
+ }
+ } catch {
+ // ignore per-transcript failures
+ }
+ completed++;
+ scheduleFlush();
+ }
+ } finally {
+ activeWorkers--;
+ if (activeWorkers === 0 && !cancelled) {
+ // Either we hit the cap or ran out of work — either way, settle.
+ finalize();
+ }
+ }
+ };
+
+ const ensureWorkers = () => {
+ if (cancelled || done) return;
+ if (totalSoFar >= hitLimit) return;
+ if (idx >= slugs.length) return;
+ const needed = Math.min(
+ concurrency - activeWorkers,
+ slugs.length - idx,
+ );
+ for (let i = 0; i < needed; i++) worker();
+ };
+
+ // Emit initial snapshot synchronously so the UI clears previous results.
+ pushUpdate();
+ ensureWorkers();
+
+ return {
+ cancel() {
+ cancelled = true;
+ if (flushTimer !== null) {
+ window.clearTimeout(flushTimer);
+ flushTimer = null;
+ }
+ },
+ setHitLimit(limit: number) {
+ if (cancelled) return;
+ if (limit <= hitLimit) return;
+ hitLimit = limit;
+ // The prior run may have finalized because we were capped. Resume.
+ if (done) {
+ done = false;
+ pushUpdate();
+ }
+ ensureWorkers();
+ },
+ };
+}
+
+function findHitsInCues(
+ cues: { start: number; text: string }[],
+ query: string,
+ useRegex: boolean,
+ regex: RegExp | null,
+ limit: number,
+): Hit[] {
+ const hits: Hit[] = [];
+ for (let i = 0; i < cues.length && hits.length < limit; i++) {
+ const cur = cues[i];
+ const prevText = i > 0 ? cues[i - 1].text : "";
+ const nextText = i < cues.length - 1 ? cues[i + 1].text : "";
+ const sep1 = prevText ? " " : "";
+ const sep2 = nextText ? " " : "";
+ const windowText = prevText + sep1 + cur.text + sep2 + nextText;
+ const curStart = prevText.length + sep1.length;
+ const curEnd = curStart + cur.text.length;
+ const m = findFirstMatchInRange(
+ windowText,
+ curStart,
+ curEnd,
+ query,
+ useRegex,
+ regex,
+ );
+ if (!m) continue;
+ const crosses = m.idx < curStart || m.idx + m.length > curEnd;
+ hits.push({
+ start: Math.round(cur.start),
+ text: crosses ? windowText : cur.text,
+ });
+ }
+ return hits;
+}
+
+function findFirstMatchInRange(
+ haystack: string,
+ rangeStart: number,
+ rangeEnd: number,
+ query: string,
+ useRegex: boolean,
+ regex: RegExp | null,
+): { idx: number; length: number } | null {
+ if (useRegex) {
+ if (!regex) return null;
+ const flags = regex.flags.includes("g") ? regex.flags : regex.flags + "g";
+ const re = new RegExp(regex.source, flags);
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(haystack)) !== null) {
+ if (m.index >= rangeStart && m.index < rangeEnd) {
+ return { idx: m.index, length: m[0].length };
+ }
+ if (m.index >= rangeEnd) return null;
+ if (m[0].length === 0) re.lastIndex++;
+ }
+ return null;
+ }
+ if (!query) return null;
+ const lower = haystack.toLowerCase();
+ const ql = query.toLowerCase();
+ let from = 0;
+ while (from <= haystack.length) {
+ const idx = lower.indexOf(ql, from);
+ if (idx === -1) return null;
+ if (idx >= rangeStart && idx < rangeEnd) return { idx, length: ql.length };
+ if (idx >= rangeEnd) return null;
+ from = idx + 1;
+ }
+ return null;
+}
+
+// Helper to build slug list from summaries + filter predicate.
+export function filterSlugs(
+ summaries: DisplaySummary[],
+ passes: (t: DisplaySummary) => boolean,
+): string[] {
+ const slugs: string[] = [];
+ for (const t of summaries) if (passes(t)) slugs.push(t.slug);
+ return slugs;
+}
diff --git a/common/components/summariesCache.ts b/common/components/summariesCache.ts
@@ -0,0 +1,82 @@
+"use client";
+
+import { useMemo } from "react";
+import { useQueries, useQuery } from "@tanstack/react-query";
+import type { DisplaySummary } from "../lib/transcripts";
+import type { Manifest } from "../lib/manifest";
+import { pageFileName } from "../lib/manifest";
+
+async function fetchJson<T>(url: string): Promise<T> {
+ const r = await fetch(url);
+ if (!r.ok) throw new Error(`Failed to fetch ${url}: ${r.status}`);
+ return (await r.json()) as T;
+}
+
+export type SummariesState = {
+ manifest: Manifest | null;
+ summaries: DisplaySummary[];
+ loadedPages: number;
+ pageCount: number;
+ summariesReady: boolean;
+ error: Error | null;
+};
+
+export function useManifest() {
+ return useQuery<Manifest>({
+ queryKey: ["manifest"],
+ queryFn: () => fetchJson<Manifest>("/summaries/manifest.json"),
+ });
+}
+
+export function useSummaries(): SummariesState {
+ const manifestQuery = useManifest();
+ const manifest = manifestQuery.data ?? null;
+ const pageCount = manifest?.pageCount ?? 0;
+
+ const pageQueries = useQueries({
+ queries: Array.from({ length: pageCount }, (_, i) => ({
+ queryKey: ["summaries-page", i],
+ queryFn: () =>
+ fetchJson<DisplaySummary[]>(`/summaries/${pageFileName(i)}`),
+ enabled: pageCount > 0,
+ })),
+ });
+
+ const loadedPages = pageQueries.filter((q) => q.data).length;
+ const summariesReady = pageCount > 0 && loadedPages === pageCount;
+
+ // Concatenate available pages; sort once all pages are in. Pages may arrive
+ // out of order, so a late sort keeps the newest-first ordering stable
+ // regardless of arrival order. Matches the LMDB composite-key order
+ // [uploadDate, channelSlug, id] reversed.
+ const summaries = useMemo<DisplaySummary[]>(() => {
+ if (loadedPages === 0) return [];
+ const out: DisplaySummary[] = [];
+ for (const q of pageQueries) if (q.data) out.push(...q.data);
+ if (summariesReady) {
+ out.sort(
+ (a, b) =>
+ b.uploadDate.localeCompare(a.uploadDate) ||
+ a.channelSlug.localeCompare(b.channelSlug) ||
+ a.id.localeCompare(b.id),
+ );
+ }
+ return out;
+ // pageQueries identity changes every render; key off loadedPages + ready.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loadedPages, summariesReady]);
+
+ const error =
+ manifestQuery.error instanceof Error
+ ? manifestQuery.error
+ : (pageQueries.find((q) => q.error)?.error as Error | undefined) ?? null;
+
+ return {
+ manifest,
+ summaries,
+ loadedPages,
+ pageCount,
+ summariesReady,
+ error,
+ };
+}
diff --git a/common/components/transcriptCache.ts b/common/components/transcriptCache.ts
@@ -0,0 +1,99 @@
+"use client";
+
+import type { TranscriptDetail } from "../lib/transcripts";
+import type { ChannelTranscriptsManifest } from "../lib/manifest";
+import { transcriptPageFileName } from "../lib/manifest";
+import { idbGet, idbPutBatch } from "./transcriptStore";
+
+const resolved = new Map<string, TranscriptDetail>();
+const inFlight = new Map<string, Promise<TranscriptDetail>>();
+const channelManifests = new Map<
+ string,
+ Promise<ChannelTranscriptsManifest>
+>();
+const pagePromises = new Map<string, Promise<TranscriptDetail[]>>();
+
+export function fetchTranscript(slug: string): Promise<TranscriptDetail> {
+ const hit = resolved.get(slug);
+ if (hit) return Promise.resolve(hit);
+ const flying = inFlight.get(slug);
+ if (flying) return flying;
+
+ const p = load(slug).then((detail) => {
+ resolved.set(slug, detail);
+ inFlight.delete(slug);
+ return detail;
+ });
+ p.catch(() => {
+ inFlight.delete(slug);
+ });
+ inFlight.set(slug, p);
+ return p;
+}
+
+function fetchChannelManifest(
+ channelSlug: string,
+): Promise<ChannelTranscriptsManifest> {
+ let p = channelManifests.get(channelSlug);
+ if (!p) {
+ p = fetch(`/transcripts/${channelSlug}/manifest.json`).then((r) => {
+ if (!r.ok)
+ throw new Error(
+ `Failed to fetch transcripts manifest for ${channelSlug}`,
+ );
+ return r.json() as Promise<ChannelTranscriptsManifest>;
+ });
+ p.catch(() => channelManifests.delete(channelSlug));
+ channelManifests.set(channelSlug, p);
+ }
+ return p;
+}
+
+function fetchPage(
+ channelSlug: string,
+ pageIndex: number,
+): Promise<TranscriptDetail[]> {
+ const key = `${channelSlug}:${pageIndex}`;
+ let p = pagePromises.get(key);
+ if (!p) {
+ p = fetch(
+ `/transcripts/${channelSlug}/${transcriptPageFileName(pageIndex)}`,
+ ).then((r) => {
+ if (!r.ok)
+ throw new Error(
+ `Failed to fetch transcript page ${channelSlug}/${pageIndex}`,
+ );
+ return r.json() as Promise<TranscriptDetail[]>;
+ });
+ p.catch(() => pagePromises.delete(key));
+ pagePromises.set(key, p);
+ }
+ return p;
+}
+
+async function load(slug: string): Promise<TranscriptDetail> {
+ const stored = await idbGet(slug);
+ if (stored) return stored;
+ const slashIdx = slug.indexOf("/");
+ if (slashIdx < 0) throw new Error(`Malformed transcript slug: ${slug}`);
+ const channelSlug = slug.slice(0, slashIdx);
+ const videoId = slug.slice(slashIdx + 1);
+ const manifest = await fetchChannelManifest(channelSlug);
+ const pageIndex = manifest.slugToPage[videoId];
+ if (pageIndex === undefined)
+ throw new Error(`Unknown transcript slug: ${slug}`);
+ const page = await fetchPage(channelSlug, pageIndex);
+ let found: TranscriptDetail | undefined;
+ for (const entry of page) {
+ if (entry.slug === slug) found = entry;
+ // Opportunistically warm the per-slug memory + IDB caches so subsequent
+ // fetches for other videos in this page hit without a network round-trip.
+ // Huge win for searchPipeline which iterates many slugs from the same
+ // channel/page.
+ resolved.set(entry.slug, entry);
+ idbPutBatch(entry);
+ }
+ if (!found)
+ throw new Error(`Transcript ${slug} missing from page ${pageIndex}`);
+ return found;
+}
diff --git a/common/components/transcriptStore.ts b/common/components/transcriptStore.ts
@@ -0,0 +1,147 @@
+"use client";
+
+import type { TranscriptDetail } from "../lib/transcripts";
+
+const DB_NAME = "yt-dlp-transcript-browser";
+const DB_VERSION = 1;
+const STORE = "transcripts";
+
+type Mode = "pending" | "ok" | "unavailable";
+
+let mode: Mode = "pending";
+let dbPromise: Promise<IDBDatabase | null> | null = null;
+
+function openDb(): Promise<IDBDatabase | null> {
+ if (typeof indexedDB === "undefined") {
+ mode = "unavailable";
+ return Promise.resolve(null);
+ }
+ if (dbPromise) return dbPromise;
+ dbPromise = new Promise<IDBDatabase | null>((resolve) => {
+ let req: IDBOpenDBRequest;
+ try {
+ req = indexedDB.open(DB_NAME, DB_VERSION);
+ } catch {
+ downgrade("open threw");
+ resolve(null);
+ return;
+ }
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE)) {
+ db.createObjectStore(STORE, { keyPath: "slug" });
+ }
+ };
+ req.onsuccess = () => {
+ mode = "ok";
+ resolve(req.result);
+ };
+ req.onerror = () => {
+ downgrade("open failed");
+ resolve(null);
+ };
+ req.onblocked = () => {
+ downgrade("open blocked");
+ resolve(null);
+ };
+ });
+ return dbPromise;
+}
+
+function downgrade(reason: string): void {
+ if (mode === "unavailable") return;
+ mode = "unavailable";
+ console.warn(
+ `[transcriptStore] IndexedDB unavailable (${reason}); falling back to network-only.`,
+ );
+}
+
+export async function idbGet(
+ slug: string,
+): Promise<TranscriptDetail | null> {
+ const db = await openDb();
+ if (!db) return null;
+ return new Promise<TranscriptDetail | null>((resolve) => {
+ let req: IDBRequest<TranscriptDetail | undefined>;
+ try {
+ const tx = db.transaction(STORE, "readonly");
+ req = tx.objectStore(STORE).get(slug) as IDBRequest<
+ TranscriptDetail | undefined
+ >;
+ } catch {
+ downgrade("read tx threw");
+ resolve(null);
+ return;
+ }
+ req.onsuccess = () => resolve(req.result ?? null);
+ req.onerror = () => resolve(null);
+ });
+}
+
+let pending: TranscriptDetail[] = [];
+let flushScheduled = false;
+let flushInFlight: Promise<void> | null = null;
+
+export function idbPutBatch(detail: TranscriptDetail): void {
+ if (mode === "unavailable") return;
+ pending.push(detail);
+ if (flushScheduled) return;
+ flushScheduled = true;
+ queueMicrotask(() => {
+ flushScheduled = false;
+ void flush();
+ });
+}
+
+async function flush(): Promise<void> {
+ if (flushInFlight) {
+ await flushInFlight;
+ }
+ if (pending.length === 0) return;
+ const batch = pending;
+ pending = [];
+ flushInFlight = writeBatch(batch).finally(() => {
+ flushInFlight = null;
+ if (pending.length > 0 && !flushScheduled) {
+ flushScheduled = true;
+ queueMicrotask(() => {
+ flushScheduled = false;
+ void flush();
+ });
+ }
+ });
+ await flushInFlight;
+}
+
+async function writeBatch(batch: TranscriptDetail[]): Promise<void> {
+ const db = await openDb();
+ if (!db) return;
+ return new Promise<void>((resolve) => {
+ let tx: IDBTransaction;
+ try {
+ tx = db.transaction(STORE, "readwrite");
+ } catch {
+ downgrade("write tx threw");
+ resolve();
+ return;
+ }
+ const store = tx.objectStore(STORE);
+ for (const entry of batch) {
+ try {
+ store.put(entry);
+ } catch {
+ // Per-entry errors (e.g. unclonable values) shouldn't fail the batch.
+ }
+ }
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => {
+ // Quota errors land here; degrade silently after one warn.
+ downgrade("write tx error");
+ resolve();
+ };
+ tx.onabort = () => {
+ downgrade("write tx abort");
+ resolve();
+ };
+ });
+}
diff --git a/app/urlState.ts b/common/components/urlState.ts
diff --git a/lib/format.ts b/common/lib/format.ts
diff --git a/lib/manifest.ts b/common/lib/manifest.ts
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -0,0 +1,60 @@
+import fs from "node:fs";
+import path from "node:path";
+
+function findMonorepoRoot(): string {
+ let dir = process.cwd();
+ for (let i = 0; i < 8; i++) {
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return process.cwd();
+}
+
+export type SiteSettings = {
+ siteTitle: string;
+ siteDescription: string;
+ headerTitle: string;
+ homeTagline: string;
+ maxTranscriptPageBytes: number;
+};
+
+export const TRANSCRIPT_PAGE_HARD_CAP_BYTES = 20 * 1024 * 1024;
+export const TRANSCRIPT_PAGE_MIN_BYTES = 256 * 1024;
+export const TRANSCRIPT_PAGE_DEFAULT_BYTES = 8 * 1024 * 1024;
+
+const DEFAULTS: SiteSettings = {
+ siteTitle: "Transcript Browser",
+ siteDescription: "Browse and search video transcripts",
+ headerTitle: "Transcript Browser",
+ homeTagline: "",
+ maxTranscriptPageBytes: TRANSCRIPT_PAGE_DEFAULT_BYTES,
+};
+
+let cached: SiteSettings | null = null;
+
+export function getSettings(): SiteSettings {
+ if (cached) return cached;
+ const file = path.join(findMonorepoRoot(), "settings.json");
+ let parsed: Partial<SiteSettings> = {};
+ try {
+ parsed = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<SiteSettings>;
+ } catch {
+ parsed = {};
+ }
+ const merged = { ...DEFAULTS, ...parsed };
+ merged.maxTranscriptPageBytes = clampPageBytes(merged.maxTranscriptPageBytes);
+ cached = merged;
+ return cached;
+}
+
+function clampPageBytes(value: unknown): number {
+ const n =
+ typeof value === "number" && Number.isFinite(value)
+ ? value
+ : TRANSCRIPT_PAGE_DEFAULT_BYTES;
+ if (n < TRANSCRIPT_PAGE_MIN_BYTES) return TRANSCRIPT_PAGE_MIN_BYTES;
+ if (n > TRANSCRIPT_PAGE_HARD_CAP_BYTES) return TRANSCRIPT_PAGE_HARD_CAP_BYTES;
+ return Math.floor(n);
+}
diff --git a/lib/transcripts-server.ts b/common/lib/transcripts-server.ts
diff --git a/lib/transcripts.ts b/common/lib/transcripts.ts
diff --git a/lib/vtt.ts b/common/lib/vtt.ts
diff --git a/lib/whisper.ts b/common/lib/whisper.ts
diff --git a/common/package.json b/common/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "yt-dlp-transcript-common",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@tanstack/react-query": "^5.99.1",
+ "fs-extra": "^11.3.4",
+ "react-player": "^2.16.1"
+ },
+ "peerDependencies": {
+ "next": "16.2.3",
+ "react": "19.2.4",
+ "react-dom": "19.2.4"
+ },
+ "devDependencies": {
+ "@types/fs-extra": "^11.0.4",
+ "@types/node": "^20.19.39",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/common/tsconfig.json b/common/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "types": ["node"]
+ },
+ "include": ["**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/create-archives.sh b/create-archives.sh
@@ -1,11 +1,11 @@
#!/bin/bash
-git archive -9 --format tar.gz -o "./public/code.tar.gz" main
+git archive -9 --format tar.gz -o "./export/public/code.tar.gz" main
# Transcript archives are too big for CF pages (25mb)
# The comments are kept since they'll likely be useful later
# cd transcripts
# # use xz over gz to get the file below 25MB
-# git archive --format tar main | xz -9 > ../public/transcripts.tar.xz
+# git archive --format tar main | xz -9 > ../export/public/transcripts.tar.xz
# # Uncomment this line to use .tar.gz for compatability and compression speed, but at a significantly higher file size
-# git archive -9 --format tar.gz -o "../public/transcripts.tar.gz" main
+# git archive -9 --format tar.gz -o "../export/public/transcripts.tar.gz" main
diff --git a/app/favicon.ico b/export/app/favicon.ico
Binary files differ.
diff --git a/app/globals.css b/export/app/globals.css
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -0,0 +1,53 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import Link from "next/link";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
+import "./globals.css";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export function generateMetadata(): Metadata {
+ const s = getSettings();
+ return {
+ title: s.siteTitle,
+ description: s.siteDescription,
+ };
+}
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const settings = getSettings();
+ return (
+ <html
+ lang="en"
+ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
+ >
+ <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans">
+ <QueryProvider>
+ <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20">
+ <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
+ <Link href="/" className="font-semibold tracking-tight">
+ {settings.headerTitle}
+ </Link>
+ </div>
+ </header>
+ <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6">
+ {children}
+ </main>
+ </QueryProvider>
+ </body>
+ </html>
+ );
+}
diff --git a/export/app/page.tsx b/export/app/page.tsx
@@ -0,0 +1,34 @@
+import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
+import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
+import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
+
+export default async function Home() {
+ const settings = getSettings();
+ const count = await countTranscripts();
+
+ return (
+ <PlayerProvider>
+ <div className="flex flex-col gap-6">
+ <div>
+ <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 />
+ <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
+ href="/code.tar.gz"
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ code.tar.gz
+ </a>
+ </section>
+ </div>
+ <TranscriptModal />
+ </PlayerProvider>
+ );
+}
diff --git a/eslint.config.mjs b/export/eslint.config.mjs
diff --git a/export/next.config.ts b/export/next.config.ts
@@ -0,0 +1,11 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "export",
+ trailingSlash: true,
+ images: { unoptimized: true },
+ staticPageGenerationTimeout: 600, // Increase timeout to 600 seconds
+ transpilePackages: ["yt-dlp-transcript-common"],
+};
+
+export default nextConfig;
diff --git a/export/package.json b/export/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "export",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build:index": "tsx scripts/build-index.ts",
+ "prebuild": "pnpm run build:index",
+ "build": "next build",
+ "start": "serve out",
+ "lint": "eslint"
+ },
+ "dependencies": {
+ "@sindresorhus/slugify": "^3.0.0",
+ "@tanstack/react-query": "^5.99.1",
+ "execa": "^9.6.1",
+ "lmdb": "^3.5.4",
+ "next": "16.2.3",
+ "p-limit": "^7.3.0",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "react-player": "^2.16.1",
+ "serve": "^14.2.6",
+ "yt-dlp-transcript-common": "workspace:*"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.2.2",
+ "@types/fs-extra": "^11.0.4",
+ "@types/node": "^20.19.39",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "eslint": "^9.39.4",
+ "eslint-config-next": "16.2.3",
+ "fs-extra": "^11.3.4",
+ "tailwindcss": "^4.2.2",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/postcss.config.mjs b/export/postcss.config.mjs
diff --git a/public/file.svg b/export/public/file.svg
diff --git a/public/globe.svg b/export/public/globe.svg
diff --git a/public/next.svg b/export/public/next.svg
diff --git a/public/vercel.svg b/export/public/vercel.svg
diff --git a/public/window.svg b/export/public/window.svg
diff --git a/export/scripts/build-index.ts b/export/scripts/build-index.ts
@@ -0,0 +1,672 @@
+#!/usr/bin/env tsx
+// Preprocess transcripts/channels/<channelSlug>/data/<videoDir>/ into:
+// - LMDB cache at transcripts/index.mdb (for incremental rebuilds)
+// - public/summaries/{manifest,page-NNNN}.json (paginated cross-channel
+// summaries index for browse/search)
+// - public/transcripts/<channelSlug>/{manifest,page-NNNN}.json
+// (per-channel paginated transcript detail + cues, oldest-first so
+// adding a newer video only dirties the last page)
+//
+// Per-channel config.json selects the transcript parser ("youtube" → VTT,
+// "transcribe" → whisper.cpp JSON). Short-circuits when mtimes already match.
+
+import path from "node:path";
+import { createHash } from "node:crypto";
+import {
+ mkdir,
+ readdir,
+ readFile,
+ rename,
+ rm,
+ stat,
+ writeFile,
+} from "node:fs/promises";
+import type { Dirent } from "node:fs";
+import { open } from "lmdb";
+import { fileURLToPath } from "node:url";
+import { parseVtt, type Cue } from "yt-dlp-transcript-common/lib/vtt";
+import { parseWhisper } from "yt-dlp-transcript-common/lib/whisper";
+import {
+ summarize,
+ toDisplaySummary,
+ type RawMetadata,
+} from "yt-dlp-transcript-common/lib/transcripts-server";
+import type {
+ TranscriptSummary,
+ TranscriptDetail,
+ DisplaySummary,
+} from "yt-dlp-transcript-common/lib/transcripts";
+import type {
+ Manifest,
+ ChannelEntry,
+ ChannelTranscriptsManifest,
+} from "yt-dlp-transcript-common/lib/manifest";
+import {
+ MANIFEST_VERSION,
+ SUMMARIES_PAGE_SIZE,
+ TRANSCRIPTS_MANIFEST_VERSION,
+ pageFileName,
+ transcriptPageFileName,
+} from "yt-dlp-transcript-common/lib/manifest";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+
+// Self-locating: the script lives at <repoRoot>/export/scripts/build-index.ts,
+// so the repo root is two levels up regardless of the cwd it was invoked from.
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..", "..");
+const CHANNELS_DIR = path.join(ROOT, "transcripts", "channels");
+const DB_PATH = path.join(ROOT, "transcripts", "index.mdb");
+const PUBLIC_DIR = path.join(ROOT, "export", "public");
+const SUMMARIES_DIR = path.join(PUBLIC_DIR, "summaries");
+const TRANSCRIPTS_DIR = path.join(PUBLIC_DIR, "transcripts");
+const MANIFEST_PATH = path.join(SUMMARIES_DIR, "manifest.json");
+
+const SCHEMA_VERSION = 5;
+
+type Handling = "youtube" | "transcribe";
+type ChannelConfig = { handling: Handling; name?: string };
+
+// Primary sort/lookup key: [uploadDate, channelSlug, id]. Reverse iteration
+// yields newest-first directly (uploadDate is YYYYMMDD).
+type IndexKey = [string, string, string];
+// Per-channel forward-scan index: [channelSlug, uploadDate, id].
+type ChannelKey = [string, string, string];
+// Page-hash key: [channelSlug, pageIndex].
+type PageHashKey = [string, number];
+// Path key: [channelSlug, videoDir]. Stable regardless of metadata contents,
+// so diffing by mtime doesn't require parsing metadata.info.json.
+type PathKey = [string, string];
+
+type MtimeRecord = {
+ metaMs: number;
+ transcriptMs: number | null;
+ indexKey: IndexKey;
+};
+
+type PageHashRecord = {
+ hash: string;
+ entryCount: number;
+ sizeBytes: number;
+};
+
+function indexToChannelKey(k: IndexKey): ChannelKey {
+ return [k[1], k[0], k[2]];
+}
+
+type LiveEntry = {
+ channelSlug: string;
+ handling: Handling;
+ configName: string | undefined;
+ videoDir: string;
+ metaPath: string;
+ metaMs: number;
+ transcriptPath: string;
+ transcriptMs: number | null;
+};
+
+async function exists(p: string): Promise<boolean> {
+ try {
+ await stat(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function readChannelConfig(dir: string): Promise<ChannelConfig | null> {
+ try {
+ const raw = await readFile(path.join(dir, "config.json"), "utf8");
+ const parsed = JSON.parse(raw) as ChannelConfig;
+ if (parsed.handling !== "youtube" && parsed.handling !== "transcribe") {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+async function scanSource(): Promise<{
+ live: LiveEntry[];
+ channels: Map<string, ChannelConfig>;
+}> {
+ const channels = new Map<string, ChannelConfig>();
+ const live: LiveEntry[] = [];
+ const channelEntries = await readdir(CHANNELS_DIR, { withFileTypes: true });
+ for (const ch of channelEntries) {
+ if (!ch.isDirectory()) continue;
+ const channelDir = path.join(CHANNELS_DIR, ch.name);
+ const cfg = await readChannelConfig(channelDir);
+ if (!cfg) {
+ console.warn(
+ `Skipping channel ${ch.name}: missing or invalid config.json`,
+ );
+ continue;
+ }
+ channels.set(ch.name, cfg);
+ const dataDir = path.join(channelDir, "data");
+ let videoEntries: Dirent[];
+ try {
+ videoEntries = await readdir(dataDir, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ const transcriptName =
+ cfg.handling === "youtube" ? "transcript.en.vtt" : "transcript.json";
+ for (const v of videoEntries) {
+ if (!v.isDirectory()) continue;
+ const videoDir = v.name;
+ const metaPath = path.join(dataDir, videoDir, "metadata.info.json");
+ const transcriptPath = path.join(dataDir, videoDir, transcriptName);
+ let metaMs: number;
+ try {
+ metaMs = (await stat(metaPath)).mtimeMs;
+ } catch {
+ continue;
+ }
+ let transcriptMs: number | null = null;
+ try {
+ transcriptMs = (await stat(transcriptPath)).mtimeMs;
+ } catch {
+ transcriptMs = null;
+ }
+ live.push({
+ channelSlug: ch.name,
+ handling: cfg.handling,
+ configName: cfg.name,
+ videoDir,
+ metaPath,
+ metaMs,
+ transcriptPath,
+ transcriptMs,
+ });
+ }
+ }
+ return { live, channels };
+}
+
+function pathKeyId(k: PathKey): string {
+ return `${k[0]}\x00${k[1]}`;
+}
+
+function indexKeysEqual(a: IndexKey, b: IndexKey): boolean {
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
+}
+
+async function writeJsonAtomic(
+ filePath: string,
+ value: unknown,
+): Promise<void> {
+ const tmp = `${filePath}.tmp-${process.pid}`;
+ await writeFile(tmp, JSON.stringify(value));
+ await rename(tmp, filePath);
+}
+
+async function main(): Promise<void> {
+ const t0 = Date.now();
+ await mkdir(path.dirname(DB_PATH), { recursive: true });
+ await mkdir(SUMMARIES_DIR, { recursive: true });
+ await mkdir(TRANSCRIPTS_DIR, { recursive: true });
+
+ const root = open({
+ path: DB_PATH,
+ maxDbs: 8,
+ compression: true,
+ });
+ const sums = root.openDB<TranscriptSummary, IndexKey>({
+ name: "sums",
+ encoding: "msgpack",
+ });
+ const cues = root.openDB<Cue[], IndexKey>({
+ name: "cues",
+ encoding: "msgpack",
+ });
+ const mtimes = root.openDB<MtimeRecord, PathKey>({
+ name: "mtimes",
+ encoding: "msgpack",
+ });
+ // Per-channel forward index: lets us stream [channelSlug, uploadDate, id]
+ // in oldest-first order for size-based page packing without loading every
+ // channel's entries into memory.
+ const byChannel = root.openDB<number, ChannelKey>({
+ name: "byChannel",
+ encoding: "msgpack",
+ });
+ // Content hashes of emitted transcript pages, so unchanged pages skip the
+ // write. Since page 1 = oldest videos, adding a new newest-upload video
+ // only dirties the last page's hash.
+ const pageHashes = root.openDB<PageHashRecord, PageHashKey>({
+ name: "pageHashes",
+ encoding: "msgpack",
+ });
+ const meta = root.openDB<unknown, string>({
+ name: "meta",
+ encoding: "msgpack",
+ });
+
+ const storedSchema = meta.get("schema") as number | undefined;
+ const schemaBumped = storedSchema !== SCHEMA_VERSION;
+ if (schemaBumped) {
+ console.log(
+ `Schema change (${storedSchema ?? "<none>"} -> ${SCHEMA_VERSION}); invalidating LMDB cache.`,
+ );
+ await sums.clearAsync();
+ await cues.clearAsync();
+ await mtimes.clearAsync();
+ await byChannel.clearAsync();
+ await pageHashes.clearAsync();
+ await meta.put("schema", SCHEMA_VERSION);
+ }
+
+ const { live, channels: channelConfigs } = await scanSource();
+ const livePathIds = new Set<string>();
+ const liveByPathId = new Map<string, LiveEntry>();
+ for (const s of live) {
+ const id = pathKeyId([s.channelSlug, s.videoDir]);
+ livePathIds.add(id);
+ liveByPathId.set(id, s);
+ }
+
+ // Diff against prior mtimes (path-keyed — no metadata reads required).
+ const added: LiveEntry[] = [];
+ const changed: LiveEntry[] = [];
+ const removed: { pathKey: PathKey; indexKey: IndexKey }[] = [];
+
+ for (const s of live) {
+ const pk: PathKey = [s.channelSlug, s.videoDir];
+ const prev = mtimes.get(pk);
+ if (!prev) {
+ added.push(s);
+ } else if (
+ prev.metaMs !== s.metaMs ||
+ prev.transcriptMs !== s.transcriptMs
+ ) {
+ changed.push(s);
+ }
+ }
+ for (const { key, value } of mtimes.getRange()) {
+ const k = key as PathKey;
+ if (!livePathIds.has(pathKeyId(k))) {
+ removed.push({ pathKey: k, indexKey: (value as MtimeRecord).indexKey });
+ }
+ }
+
+ const anyMutations =
+ added.length > 0 || changed.length > 0 || removed.length > 0;
+
+ // Short-circuit: nothing changed AND public/ is intact AND the configured
+ // max page size matches what each channel was last paginated with.
+ const { maxTranscriptPageBytes: configuredMaxPageBytes } = getSettings();
+ if (!anyMutations && !schemaBumped) {
+ const manifestRaw = await readFile(MANIFEST_PATH, "utf8").catch(() => null);
+ if (manifestRaw) {
+ try {
+ const parsed = JSON.parse(manifestRaw) as Manifest;
+ if (
+ parsed.version === MANIFEST_VERSION &&
+ parsed.totalCount === live.length &&
+ parsed.pageSize === SUMMARIES_PAGE_SIZE
+ ) {
+ const firstPage = path.join(SUMMARIES_DIR, pageFileName(0));
+ const lastPage = path.join(
+ SUMMARIES_DIR,
+ pageFileName(Math.max(0, parsed.pageCount - 1)),
+ );
+ let transcriptsIntact = true;
+ for (const channelSlug of channelConfigs.keys()) {
+ const mPath = path.join(
+ TRANSCRIPTS_DIR,
+ channelSlug,
+ "manifest.json",
+ );
+ const raw = await readFile(mPath, "utf8").catch(() => null);
+ if (!raw) {
+ transcriptsIntact = false;
+ break;
+ }
+ try {
+ const cm = JSON.parse(raw) as ChannelTranscriptsManifest;
+ if (
+ cm.version !== TRANSCRIPTS_MANIFEST_VERSION ||
+ cm.maxPageBytes !== configuredMaxPageBytes
+ ) {
+ transcriptsIntact = false;
+ break;
+ }
+ } catch {
+ transcriptsIntact = false;
+ break;
+ }
+ }
+ if (
+ transcriptsIntact &&
+ (await exists(firstPage)) &&
+ (await exists(lastPage))
+ ) {
+ console.log(
+ `Index up to date (${live.length} transcripts, ${parsed.pageCount} pages). Skipping.`,
+ );
+ await root.close();
+ return;
+ }
+ }
+ } catch {
+ // fall through
+ }
+ }
+ }
+
+ console.log(
+ `Diff: +${added.length} added, ~${changed.length} changed, -${removed.length} removed, ${live.length} total.`,
+ );
+
+ // Ensure per-channel public/transcripts/<channelSlug>/ subdirs exist.
+ for (const channelSlug of channelConfigs.keys()) {
+ await mkdir(path.join(TRANSCRIPTS_DIR, channelSlug), { recursive: true });
+ }
+
+ // Process mutations.
+ const toProcess = [...added, ...changed];
+ let processed = 0;
+ const BATCH = 200;
+ for (let i = 0; i < toProcess.length; i += BATCH) {
+ const slice = toProcess.slice(i, i + BATCH);
+ await Promise.all(
+ slice.map(async (s) => {
+ try {
+ const metaRaw = await readFile(s.metaPath, "utf8");
+ const parsedMeta = JSON.parse(metaRaw) as RawMetadata;
+ const summary = summarize(
+ s.channelSlug,
+ s.videoDir,
+ parsedMeta,
+ s.configName,
+ );
+ if (!summary.uploadDate) {
+ console.warn(
+ `Skipping ${s.channelSlug}/${s.videoDir}: no upload_date`,
+ );
+ return;
+ }
+ const indexKey: IndexKey = [
+ summary.uploadDate,
+ s.channelSlug,
+ summary.id,
+ ];
+ let cueList: Cue[] | undefined;
+ if (s.transcriptMs !== null) {
+ try {
+ const raw = await readFile(s.transcriptPath, "utf8");
+ cueList =
+ s.handling === "youtube" ? parseVtt(raw) : parseWhisper(raw);
+ } catch {
+ cueList = undefined;
+ }
+ }
+
+ // If upload_date shifted (metadata edit), the old composite key is
+ // stale — drop it before writing the new one.
+ const pk: PathKey = [s.channelSlug, s.videoDir];
+ const prev = mtimes.get(pk);
+ if (prev && !indexKeysEqual(prev.indexKey, indexKey)) {
+ sums.remove(prev.indexKey);
+ cues.remove(prev.indexKey);
+ byChannel.remove(indexToChannelKey(prev.indexKey));
+ }
+
+ sums.put(indexKey, summary);
+ if (cueList) cues.put(indexKey, cueList);
+ else cues.remove(indexKey);
+ byChannel.put(indexToChannelKey(indexKey), 1);
+ mtimes.put(pk, {
+ metaMs: s.metaMs,
+ transcriptMs: s.transcriptMs,
+ indexKey,
+ });
+ } catch (err) {
+ console.warn(
+ `Failed to process ${s.channelSlug}/${s.videoDir}:`,
+ err,
+ );
+ }
+ }),
+ );
+ processed += slice.length;
+ if (toProcess.length > BATCH) {
+ process.stdout.write(` processed ${processed}/${toProcess.length}\r`);
+ }
+ }
+ if (toProcess.length > BATCH) process.stdout.write("\n");
+
+ // Process removals.
+ for (const { pathKey, indexKey } of removed) {
+ sums.remove(indexKey);
+ cues.remove(indexKey);
+ byChannel.remove(indexToChannelKey(indexKey));
+ mtimes.remove(pathKey);
+ }
+
+ await sums.flushed;
+ await cues.flushed;
+ await byChannel.flushed;
+ await mtimes.flushed;
+
+ // Emit per-channel paginated transcript pages. Pages are size-packed
+ // oldest-first, so newer videos land on the last page and older pages
+ // hash-match from build to build.
+ const maxTranscriptPageBytes = configuredMaxPageBytes;
+ const generatedAt = new Date().toISOString();
+ let pagesWritten = 0;
+ let pagesSkipped = 0;
+ let pagesDeleted = 0;
+
+ type PendingEntry = { encoded: string; id: string };
+
+ const writePage = async (
+ channelSlug: string,
+ idx: number,
+ entries: PendingEntry[],
+ ): Promise<void> => {
+ const body = `[${entries.map((e) => e.encoded).join(",")}]`;
+ const hash = createHash("sha1").update(body).digest("hex");
+ const prev = pageHashes.get([channelSlug, idx]);
+ const outPath = path.join(
+ TRANSCRIPTS_DIR,
+ channelSlug,
+ transcriptPageFileName(idx),
+ );
+ if (prev?.hash === hash && (await exists(outPath))) {
+ pagesSkipped++;
+ return;
+ }
+ const sizeBytes = Buffer.byteLength(body, "utf8");
+ const tmp = `${outPath}.tmp-${process.pid}`;
+ await writeFile(tmp, body);
+ await rename(tmp, outPath);
+ pageHashes.put([channelSlug, idx], {
+ hash,
+ entryCount: entries.length,
+ sizeBytes,
+ });
+ pagesWritten++;
+ };
+
+ for (const channelSlug of Array.from(channelConfigs.keys()).sort()) {
+ const channelDir = path.join(TRANSCRIPTS_DIR, channelSlug);
+ await mkdir(channelDir, { recursive: true });
+
+ let pageIdx = 0;
+ let buffer: PendingEntry[] = [];
+ // Running payload size excluding the outer `[` and `]` (accounted at
+ // flush time). Includes the leading commas between elements.
+ let payloadBytes = 0;
+ const slugToPage: Record<string, number> = {};
+
+ for (const { key } of byChannel.getRange({
+ start: [channelSlug],
+ end: [channelSlug, ""],
+ })) {
+ const ck = key as ChannelKey;
+ if (ck[0] !== channelSlug) continue;
+ const indexKey: IndexKey = [ck[1], ck[0], ck[2]];
+ const summary = sums.get(indexKey);
+ if (!summary) continue;
+ const cueList = cues.get(indexKey);
+ const detail: TranscriptDetail = { ...summary, cues: cueList };
+ const encoded = JSON.stringify(detail);
+ const entryBytes = Buffer.byteLength(encoded, "utf8");
+ const commaBytes = buffer.length === 0 ? 0 : 1;
+ const delta = entryBytes + commaBytes;
+
+ // Close current page if this entry would push it past the limit.
+ // `+ 2` accounts for the outer brackets.
+ if (buffer.length > 0 && payloadBytes + delta + 2 > maxTranscriptPageBytes) {
+ await writePage(channelSlug, pageIdx, buffer);
+ pageIdx++;
+ buffer = [];
+ payloadBytes = 0;
+ }
+
+ if (entryBytes + 2 > maxTranscriptPageBytes) {
+ console.warn(
+ ` ${summary.slug}: entry (${entryBytes} bytes) exceeds page budget; emitting solo page.`,
+ );
+ }
+
+ slugToPage[summary.id] = pageIdx;
+ buffer.push({ encoded, id: summary.id });
+ payloadBytes += delta;
+ }
+
+ if (buffer.length > 0) {
+ await writePage(channelSlug, pageIdx, buffer);
+ pageIdx++;
+ }
+
+ const pageCount = pageIdx;
+
+ // Prune stale per-channel page files + drop pageHashes beyond pageCount.
+ const keep = new Set<string>(["manifest.json"]);
+ for (let i = 0; i < pageCount; i++) keep.add(transcriptPageFileName(i));
+ const existing = await readdir(channelDir).catch(() => [] as string[]);
+ for (const name of existing) {
+ if (keep.has(name)) continue;
+ await rm(path.join(channelDir, name), { force: true });
+ pagesDeleted++;
+ }
+ // pageHashes may still have records for page indexes >= pageCount
+ // (channel shrank). Drop them.
+ for (const { key } of pageHashes.getRange({
+ start: [channelSlug, pageCount],
+ end: [channelSlug, Number.MAX_SAFE_INTEGER],
+ })) {
+ pageHashes.remove(key as PageHashKey);
+ }
+
+ const channelManifest: ChannelTranscriptsManifest = {
+ version: TRANSCRIPTS_MANIFEST_VERSION,
+ channelSlug,
+ pageCount,
+ maxPageBytes: maxTranscriptPageBytes,
+ generatedAt,
+ slugToPage,
+ };
+ await writeJsonAtomic(
+ path.join(channelDir, "manifest.json"),
+ channelManifest,
+ );
+ }
+
+ await pageHashes.flushed;
+
+ // Prune channel directories for channels no longer in config, and any
+ // legacy per-video `<id>.json` files that leaked in from pre-schema-4
+ // builds at the top level of transcripts/ (pre-channel-subdir layout).
+ const topEntries = await readdir(TRANSCRIPTS_DIR, {
+ withFileTypes: true,
+ }).catch(() => [] as Dirent[]);
+ for (const e of topEntries) {
+ if (e.isDirectory()) {
+ if (!channelConfigs.has(e.name)) {
+ await rm(path.join(TRANSCRIPTS_DIR, e.name), {
+ recursive: true,
+ force: true,
+ });
+ }
+ } else if (e.isFile()) {
+ await rm(path.join(TRANSCRIPTS_DIR, e.name), { force: true });
+ }
+ }
+
+ console.log(
+ `Transcript pages: ${pagesWritten} written, ${pagesSkipped} unchanged, ${pagesDeleted} stale removed.`,
+ );
+
+ // Stream LMDB (reverse composite-key order = newest-date first) to emit
+ // paginated summaries.
+ const pageSize = SUMMARIES_PAGE_SIZE;
+ const channelCounts = new Map<string, number>();
+ // Seed channel list from config so empty channels still appear in the UI.
+ for (const cfg of channelConfigs.values()) {
+ if (cfg.name) channelCounts.set(cfg.name, 0);
+ }
+ let pageIndex = 0;
+ let buffer: DisplaySummary[] = [];
+ let total = 0;
+
+ const flushPage = async () => {
+ if (buffer.length === 0) return;
+ const p = path.join(SUMMARIES_DIR, pageFileName(pageIndex));
+ await writeJsonAtomic(p, buffer);
+ buffer = [];
+ pageIndex++;
+ };
+
+ for (const { value } of sums.getRange({ reverse: true })) {
+ const s = value as TranscriptSummary;
+ if (s.channel) {
+ channelCounts.set(s.channel, (channelCounts.get(s.channel) ?? 0) + 1);
+ }
+ buffer.push(toDisplaySummary(s));
+ total++;
+ if (buffer.length >= pageSize) await flushPage();
+ }
+ await flushPage();
+
+ // Prune stale page files.
+ const expectedPages = new Set<string>();
+ for (let i = 0; i < pageIndex; i++) expectedPages.add(pageFileName(i));
+ const existing = await readdir(SUMMARIES_DIR).catch(() => [] as string[]);
+ for (const name of existing) {
+ if (!name.startsWith("page-")) continue;
+ if (!expectedPages.has(name)) {
+ await rm(path.join(SUMMARIES_DIR, name), { force: true });
+ }
+ }
+
+ const channelList: ChannelEntry[] = Array.from(channelCounts.entries())
+ .map(([name, count]) => ({ name, count }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ const manifest: Manifest = {
+ version: MANIFEST_VERSION,
+ totalCount: total,
+ pageSize,
+ pageCount: pageIndex,
+ generatedAt: new Date().toISOString(),
+ channels: channelList,
+ };
+ await writeJsonAtomic(MANIFEST_PATH, manifest);
+
+ await root.close();
+
+ const secs = ((Date.now() - t0) / 1000).toFixed(2);
+ console.log(
+ `Done in ${secs}s. ${total} transcripts across ${pageIndex} pages; ${channelList.length} channels.`,
+ );
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/serve.json b/export/serve.json
diff --git a/export/tsconfig.json b/export/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/lib/settings.ts b/lib/settings.ts
@@ -1,49 +0,0 @@
-import fs from "node:fs";
-import path from "node:path";
-
-export type SiteSettings = {
- siteTitle: string;
- siteDescription: string;
- headerTitle: string;
- homeTagline: string;
- maxTranscriptPageBytes: number;
-};
-
-export const TRANSCRIPT_PAGE_HARD_CAP_BYTES = 20 * 1024 * 1024;
-export const TRANSCRIPT_PAGE_MIN_BYTES = 256 * 1024;
-export const TRANSCRIPT_PAGE_DEFAULT_BYTES = 8 * 1024 * 1024;
-
-const DEFAULTS: SiteSettings = {
- siteTitle: "Transcript Browser",
- siteDescription: "Browse and search video transcripts",
- headerTitle: "Transcript Browser",
- homeTagline: "",
- maxTranscriptPageBytes: TRANSCRIPT_PAGE_DEFAULT_BYTES,
-};
-
-let cached: SiteSettings | null = null;
-
-export function getSettings(): SiteSettings {
- if (cached) return cached;
- const file = path.join(process.cwd(), "settings.json");
- let parsed: Partial<SiteSettings> = {};
- try {
- parsed = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<SiteSettings>;
- } catch {
- parsed = {};
- }
- const merged = { ...DEFAULTS, ...parsed };
- merged.maxTranscriptPageBytes = clampPageBytes(merged.maxTranscriptPageBytes);
- cached = merged;
- return cached;
-}
-
-function clampPageBytes(value: unknown): number {
- const n =
- typeof value === "number" && Number.isFinite(value)
- ? value
- : TRANSCRIPT_PAGE_DEFAULT_BYTES;
- if (n < TRANSCRIPT_PAGE_MIN_BYTES) return TRANSCRIPT_PAGE_MIN_BYTES;
- if (n > TRANSCRIPT_PAGE_HARD_CAP_BYTES) return TRANSCRIPT_PAGE_HARD_CAP_BYTES;
- return Math.floor(n);
-}
diff --git a/next.config.ts b/next.config.ts
@@ -1,10 +0,0 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
- output: "export",
- trailingSlash: true,
- images: { unoptimized: true },
- staticPageGenerationTimeout: 600, // Increase timeout to 600 seconds
-};
-
-export default nextConfig;
diff --git a/package.json b/package.json
@@ -1,39 +1,16 @@
{
- "name": "yt-dlp-transcript-browser",
+ "name": "yt-dlp-transcript-browser-monorepo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
- "dev": "next dev",
- "build:index": "tsx scripts/build-index.ts",
- "prebuild": "pnpm build:index",
- "build": "next build",
- "start": "serve out",
- "lint": "eslint"
- },
- "dependencies": {
- "@sindresorhus/slugify": "^3.0.0",
- "@tanstack/react-query": "^5.99.1",
- "execa": "^9.6.1",
- "lmdb": "^3.5.4",
- "next": "16.2.3",
- "p-limit": "^7.3.0",
- "react": "19.2.4",
- "react-dom": "19.2.4",
- "react-player": "^2.16.1",
- "serve": "^14.2.6"
+ "build:index": "pnpm --filter export run build:index",
+ "build:export": "pnpm --filter export run build",
+ "build": "pnpm --filter export run build",
+ "start:export": "pnpm --filter export run start",
+ "lint": "pnpm --filter export run lint"
},
"devDependencies": {
- "@tailwindcss/postcss": "^4.2.2",
- "@types/fs-extra": "^11.0.4",
- "@types/node": "^20.19.39",
- "@types/react": "^19.2.14",
- "@types/react-dom": "^19.2.3",
- "eslint": "^9.39.4",
- "eslint-config-next": "16.2.3",
- "fs-extra": "^11.3.4",
- "tailwindcss": "^4.2.2",
- "tsx": "^4.21.0",
- "typescript": "^5.9.3"
+ "tsx": "^4.21.0"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -7,13 +7,56 @@ settings:
importers:
.:
+ devDependencies:
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+
+ common:
+ dependencies:
+ '@tanstack/react-query':
+ specifier: ^5.99.1
+ version: 5.100.5(react@19.2.4)
+ fs-extra:
+ specifier: ^11.3.4
+ version: 11.3.4
+ next:
+ specifier: 16.2.3
+ version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: 19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: 19.2.4
+ version: 19.2.4(react@19.2.4)
+ react-player:
+ specifier: ^2.16.1
+ version: 2.16.1(react@19.2.4)
+ devDependencies:
+ '@types/fs-extra':
+ specifier: ^11.0.4
+ version: 11.0.4
+ '@types/node':
+ specifier: ^20.19.39
+ version: 20.19.39
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
+ export:
dependencies:
'@sindresorhus/slugify':
specifier: ^3.0.0
version: 3.0.0
'@tanstack/react-query':
specifier: ^5.99.1
- version: 5.99.1(react@19.2.4)
+ version: 5.100.5(react@19.2.4)
execa:
specifier: ^9.6.1
version: 9.6.1
@@ -38,10 +81,13 @@ importers:
serve:
specifier: ^14.2.6
version: 14.2.6
+ yt-dlp-transcript-common:
+ specifier: workspace:*
+ version: link:../common
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.2.2
- version: 4.2.2
+ version: 4.2.4
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@@ -59,13 +105,13 @@ importers:
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 16.2.3
- version: 16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ version: 16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
fs-extra:
specifier: ^11.3.4
version: 11.3.4
tailwindcss:
specifier: ^4.2.2
- version: 4.2.2
+ version: 4.2.4
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -704,69 +750,69 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
- '@tailwindcss/node@4.2.2':
- resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
+ '@tailwindcss/node@4.2.4':
+ resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
- '@tailwindcss/oxide-android-arm64@4.2.2':
- resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
+ '@tailwindcss/oxide-android-arm64@4.2.4':
+ resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
- '@tailwindcss/oxide-darwin-arm64@4.2.2':
- resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
+ resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
- '@tailwindcss/oxide-darwin-x64@4.2.2':
- resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
+ resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
- '@tailwindcss/oxide-freebsd-x64@4.2.2':
- resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
+ resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
- resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
+ resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
- resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
+ resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
- resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
+ resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
- resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
+ resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@tailwindcss/oxide-linux-x64-musl@4.2.2':
- resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
+ resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@tailwindcss/oxide-wasm32-wasi@4.2.2':
- resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
+ resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
@@ -777,30 +823,30 @@ packages:
- '@emnapi/wasi-threads'
- tslib
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
- resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
+ resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
- '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
- resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
+ resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
- '@tailwindcss/oxide@4.2.2':
- resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
+ '@tailwindcss/oxide@4.2.4':
+ resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
engines: {node: '>= 20'}
- '@tailwindcss/postcss@4.2.2':
- resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
+ '@tailwindcss/postcss@4.2.4':
+ resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
- '@tanstack/query-core@5.99.1':
- resolution: {integrity: sha512-5E8xwxyWvr22yt7zvzP3KOZ5TUElOdVA45NP3/Ao1m9mvc9i18NLTDe9m3M00BH2DR5J20cv7xckMPlhKNs+vQ==}
+ '@tanstack/query-core@5.100.5':
+ resolution: {integrity: sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==}
- '@tanstack/react-query@5.99.1':
- resolution: {integrity: sha512-akg5GdwW70lvJvCqVHZ7tizGyc+TATjUzKX9RuF1xknhEe/1leofXk7YLYbrbRsuhNbHJBAayaQUMvvOFZ5L5g==}
+ '@tanstack/react-query@5.100.5':
+ resolution: {integrity: sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==}
peerDependencies:
react: ^18 || ^19
@@ -833,63 +879,63 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
- '@typescript-eslint/eslint-plugin@8.58.2':
- resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
+ '@typescript-eslint/eslint-plugin@8.59.0':
+ resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.58.2
+ '@typescript-eslint/parser': ^8.59.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.58.2':
- resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==}
+ '@typescript-eslint/parser@8.59.0':
+ resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.58.2':
- resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==}
+ '@typescript-eslint/project-service@8.59.0':
+ resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.58.2':
- resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
+ '@typescript-eslint/scope-manager@8.59.0':
+ resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.58.2':
- resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
+ '@typescript-eslint/tsconfig-utils@8.59.0':
+ resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.58.2':
- resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
+ '@typescript-eslint/type-utils@8.59.0':
+ resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.58.2':
- resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
+ '@typescript-eslint/types@8.59.0':
+ resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.58.2':
- resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==}
+ '@typescript-eslint/typescript-estree@8.59.0':
+ resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/utils@8.58.2':
- resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==}
+ '@typescript-eslint/utils@8.59.0':
+ resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.58.2':
- resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
+ '@typescript-eslint/visitor-keys@8.59.0':
+ resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
@@ -1008,8 +1054,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
- ajv@6.14.0:
- resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
@@ -1104,8 +1150,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- baseline-browser-mapping@2.10.20:
- resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==}
+ baseline-browser-mapping@2.10.23:
+ resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1157,8 +1203,8 @@ packages:
resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==}
engines: {node: '>=14.16'}
- caniuse-lite@1.0.30001788:
- resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==}
+ caniuse-lite@1.0.30001791:
+ resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
chalk-template@0.4.0:
resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
@@ -1289,8 +1335,8 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
- electron-to-chromium@1.5.340:
- resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==}
+ electron-to-chromium@1.5.344:
+ resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1298,8 +1344,8 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
- enhanced-resolve@5.20.1:
- resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ enhanced-resolve@5.21.0:
+ resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
es-abstract@1.24.2:
@@ -1858,8 +1904,8 @@ packages:
engines: {node: '>=6'}
hasBin: true
- jsonfile@6.2.0:
- resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
+ jsonfile@6.2.1:
+ resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
@@ -2083,8 +2129,8 @@ packages:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
- node-releases@2.0.37:
- resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
+ node-releases@2.0.38:
+ resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
@@ -2205,8 +2251,8 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.5.10:
- resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
+ postcss@8.5.12:
+ resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -2293,8 +2339,8 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
- safe-array-concat@1.1.3:
- resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
+ safe-array-concat@1.1.4:
+ resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'}
safe-buffer@5.2.1:
@@ -2466,11 +2512,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
- tailwindcss@4.2.2:
- resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
+ tailwindcss@4.2.4:
+ resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
- tapable@2.3.2:
- resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tinyglobby@0.2.16:
@@ -2522,8 +2568,8 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
- typescript-eslint@8.58.2:
- resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==}
+ typescript-eslint@8.59.0:
+ resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -2851,7 +2897,7 @@ snapshots:
'@eslint/eslintrc@3.3.5':
dependencies:
- ajv: 6.14.0
+ ajv: 6.15.0
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
@@ -3113,80 +3159,80 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@tailwindcss/node@4.2.2':
+ '@tailwindcss/node@4.2.4':
dependencies:
'@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.20.1
+ enhanced-resolve: 5.21.0
jiti: 2.6.1
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
- tailwindcss: 4.2.2
+ tailwindcss: 4.2.4
- '@tailwindcss/oxide-android-arm64@4.2.2':
+ '@tailwindcss/oxide-android-arm64@4.2.4':
optional: true
- '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ '@tailwindcss/oxide-darwin-arm64@4.2.4':
optional: true
- '@tailwindcss/oxide-darwin-x64@4.2.2':
+ '@tailwindcss/oxide-darwin-x64@4.2.4':
optional: true
- '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ '@tailwindcss/oxide-freebsd-x64@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
optional: true
- '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ '@tailwindcss/oxide-linux-x64-musl@4.2.4':
optional: true
- '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ '@tailwindcss/oxide-wasm32-wasi@4.2.4':
optional: true
- '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
optional: true
- '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.4':
optional: true
- '@tailwindcss/oxide@4.2.2':
+ '@tailwindcss/oxide@4.2.4':
optionalDependencies:
- '@tailwindcss/oxide-android-arm64': 4.2.2
- '@tailwindcss/oxide-darwin-arm64': 4.2.2
- '@tailwindcss/oxide-darwin-x64': 4.2.2
- '@tailwindcss/oxide-freebsd-x64': 4.2.2
- '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2
- '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2
- '@tailwindcss/oxide-linux-arm64-musl': 4.2.2
- '@tailwindcss/oxide-linux-x64-gnu': 4.2.2
- '@tailwindcss/oxide-linux-x64-musl': 4.2.2
- '@tailwindcss/oxide-wasm32-wasi': 4.2.2
- '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
- '@tailwindcss/oxide-win32-x64-msvc': 4.2.2
-
- '@tailwindcss/postcss@4.2.2':
+ '@tailwindcss/oxide-android-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-arm64': 4.2.4
+ '@tailwindcss/oxide-darwin-x64': 4.2.4
+ '@tailwindcss/oxide-freebsd-x64': 4.2.4
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.4
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.4
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.4
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.4
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.4
+
+ '@tailwindcss/postcss@4.2.4':
dependencies:
'@alloc/quick-lru': 5.2.0
- '@tailwindcss/node': 4.2.2
- '@tailwindcss/oxide': 4.2.2
- postcss: 8.5.10
- tailwindcss: 4.2.2
+ '@tailwindcss/node': 4.2.4
+ '@tailwindcss/oxide': 4.2.4
+ postcss: 8.5.12
+ tailwindcss: 4.2.4
- '@tanstack/query-core@5.99.1': {}
+ '@tanstack/query-core@5.100.5': {}
- '@tanstack/react-query@5.99.1(react@19.2.4)':
+ '@tanstack/react-query@5.100.5(react@19.2.4)':
dependencies:
- '@tanstack/query-core': 5.99.1
+ '@tanstack/query-core': 5.100.5
react: 19.2.4
'@tybys/wasm-util@0.10.1':
@@ -3221,14 +3267,14 @@ snapshots:
dependencies:
csstype: 3.2.3
- '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.58.2
- '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.58.2
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.59.0
+ '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.0
eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
@@ -3237,41 +3283,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.58.2
- '@typescript-eslint/types': 8.58.2
- '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.58.2
+ '@typescript-eslint/scope-manager': 8.59.0
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.59.0
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)':
+ '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.58.2':
+ '@typescript-eslint/scope-manager@8.59.0':
dependencies:
- '@typescript-eslint/types': 8.58.2
- '@typescript-eslint/visitor-keys': 8.58.2
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/visitor-keys': 8.59.0
- '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)':
+ '@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.58.2
- '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -3279,14 +3325,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.58.2': {}
+ '@typescript-eslint/types@8.59.0': {}
- '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/types': 8.58.2
- '@typescript-eslint/visitor-keys': 8.58.2
+ '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/visitor-keys': 8.59.0
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
@@ -3296,20 +3342,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
- '@typescript-eslint/scope-manager': 8.58.2
- '@typescript-eslint/types': 8.58.2
- '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.59.0
+ '@typescript-eslint/types': 8.59.0
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.58.2':
+ '@typescript-eslint/visitor-keys@8.59.0':
dependencies:
- '@typescript-eslint/types': 8.58.2
+ '@typescript-eslint/types': 8.59.0
eslint-visitor-keys: 5.0.1
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
@@ -3379,7 +3425,7 @@ snapshots:
acorn@8.16.0: {}
- ajv@6.14.0:
+ ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
@@ -3498,7 +3544,7 @@ snapshots:
balanced-match@4.0.4: {}
- baseline-browser-mapping@2.10.20: {}
+ baseline-browser-mapping@2.10.23: {}
boxen@7.0.0:
dependencies:
@@ -3526,10 +3572,10 @@ snapshots:
browserslist@4.28.2:
dependencies:
- baseline-browser-mapping: 2.10.20
- caniuse-lite: 1.0.30001788
- electron-to-chromium: 1.5.340
- node-releases: 2.0.37
+ baseline-browser-mapping: 2.10.23
+ caniuse-lite: 1.0.30001791
+ electron-to-chromium: 1.5.344
+ node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
bytes@3.0.0: {}
@@ -3557,7 +3603,7 @@ snapshots:
camelcase@7.0.1: {}
- caniuse-lite@1.0.30001788: {}
+ caniuse-lite@1.0.30001791: {}
chalk-template@0.4.0:
dependencies:
@@ -3680,16 +3726,16 @@ snapshots:
eastasianwidth@0.2.0: {}
- electron-to-chromium@1.5.340: {}
+ electron-to-chromium@1.5.344: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
- enhanced-resolve@5.20.1:
+ enhanced-resolve@5.21.0:
dependencies:
graceful-fs: 4.2.11
- tapable: 2.3.2
+ tapable: 2.3.3
es-abstract@1.24.2:
dependencies:
@@ -3733,7 +3779,7 @@ snapshots:
object.assign: 4.1.7
own-keys: 1.0.1
regexp.prototype.flags: 1.5.4
- safe-array-concat: 1.1.3
+ safe-array-concat: 1.1.4
safe-push-apply: 1.0.0
safe-regex-test: 1.1.0
set-proto: 1.0.0
@@ -3827,18 +3873,18 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-next@16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ eslint-config-next@16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.2.3
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1))
globals: 16.4.0
- typescript-eslint: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ typescript-eslint: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -3866,22 +3912,22 @@ snapshots:
tinyglobby: 0.2.16
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -3892,7 +3938,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.3
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -3904,7 +3950,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -3987,7 +4033,7 @@ snapshots:
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
- ajv: 6.14.0
+ ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
@@ -4114,7 +4160,7 @@ snapshots:
fs-extra@11.3.4:
dependencies:
graceful-fs: 4.2.11
- jsonfile: 6.2.0
+ jsonfile: 6.2.1
universalify: 2.0.1
fsevents@2.3.3:
@@ -4411,7 +4457,7 @@ snapshots:
json5@2.2.3: {}
- jsonfile@6.2.0:
+ jsonfile@6.2.1:
dependencies:
universalify: 2.0.1
optionalDependencies:
@@ -4590,8 +4636,8 @@ snapshots:
dependencies:
'@next/env': 16.2.3
'@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.10.20
- caniuse-lite: 1.0.30001788
+ baseline-browser-mapping: 2.10.23
+ caniuse-lite: 1.0.30001791
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@@ -4623,7 +4669,7 @@ snapshots:
dependencies:
detect-libc: 2.1.2
- node-releases@2.0.37: {}
+ node-releases@2.0.38: {}
npm-run-path@4.0.1:
dependencies:
@@ -4743,7 +4789,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
- postcss@8.5.10:
+ postcss@8.5.12:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -4844,7 +4890,7 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
- safe-array-concat@1.1.3:
+ safe-array-concat@1.1.4:
dependencies:
call-bind: 1.0.9
call-bound: 1.0.4
@@ -5091,9 +5137,9 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
- tailwindcss@4.2.2: {}
+ tailwindcss@4.2.4: {}
- tapable@2.3.2: {}
+ tapable@2.3.3: {}
tinyglobby@0.2.16:
dependencies:
@@ -5163,12 +5209,12 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
- '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
@@ -1,3 +1,8 @@
+packages:
+ - common
+ - editor
+ - export
+
ignoredBuiltDependencies:
- sharp
- unrs-resolver
diff --git a/scripts/build-index.ts b/scripts/build-index.ts
@@ -1,668 +0,0 @@
-#!/usr/bin/env tsx
-// Preprocess transcripts/channels/<channelSlug>/data/<videoDir>/ into:
-// - LMDB cache at transcripts/index.mdb (for incremental rebuilds)
-// - public/summaries/{manifest,page-NNNN}.json (paginated cross-channel
-// summaries index for browse/search)
-// - public/transcripts/<channelSlug>/{manifest,page-NNNN}.json
-// (per-channel paginated transcript detail + cues, oldest-first so
-// adding a newer video only dirties the last page)
-//
-// Per-channel config.json selects the transcript parser ("youtube" → VTT,
-// "transcribe" → whisper.cpp JSON). Short-circuits when mtimes already match.
-
-import path from "node:path";
-import { createHash } from "node:crypto";
-import {
- mkdir,
- readdir,
- readFile,
- rename,
- rm,
- stat,
- writeFile,
-} from "node:fs/promises";
-import type { Dirent } from "node:fs";
-import { open } from "lmdb";
-import { parseVtt, type Cue } from "../lib/vtt";
-import { parseWhisper } from "../lib/whisper";
-import {
- summarize,
- toDisplaySummary,
- type RawMetadata,
-} from "../lib/transcripts-server";
-import type {
- TranscriptSummary,
- TranscriptDetail,
- DisplaySummary,
-} from "../lib/transcripts";
-import type {
- Manifest,
- ChannelEntry,
- ChannelTranscriptsManifest,
-} from "../lib/manifest";
-import {
- MANIFEST_VERSION,
- SUMMARIES_PAGE_SIZE,
- TRANSCRIPTS_MANIFEST_VERSION,
- pageFileName,
- transcriptPageFileName,
-} from "../lib/manifest";
-import { getSettings } from "../lib/settings";
-
-const ROOT = process.cwd();
-const CHANNELS_DIR = path.join(ROOT, "transcripts", "channels");
-const DB_PATH = path.join(ROOT, "transcripts", "index.mdb");
-const PUBLIC_DIR = path.join(ROOT, "public");
-const SUMMARIES_DIR = path.join(PUBLIC_DIR, "summaries");
-const TRANSCRIPTS_DIR = path.join(PUBLIC_DIR, "transcripts");
-const MANIFEST_PATH = path.join(SUMMARIES_DIR, "manifest.json");
-
-const SCHEMA_VERSION = 5;
-
-type Handling = "youtube" | "transcribe";
-type ChannelConfig = { handling: Handling; name?: string };
-
-// Primary sort/lookup key: [uploadDate, channelSlug, id]. Reverse iteration
-// yields newest-first directly (uploadDate is YYYYMMDD).
-type IndexKey = [string, string, string];
-// Per-channel forward-scan index: [channelSlug, uploadDate, id].
-type ChannelKey = [string, string, string];
-// Page-hash key: [channelSlug, pageIndex].
-type PageHashKey = [string, number];
-// Path key: [channelSlug, videoDir]. Stable regardless of metadata contents,
-// so diffing by mtime doesn't require parsing metadata.info.json.
-type PathKey = [string, string];
-
-type MtimeRecord = {
- metaMs: number;
- transcriptMs: number | null;
- indexKey: IndexKey;
-};
-
-type PageHashRecord = {
- hash: string;
- entryCount: number;
- sizeBytes: number;
-};
-
-function indexToChannelKey(k: IndexKey): ChannelKey {
- return [k[1], k[0], k[2]];
-}
-
-type LiveEntry = {
- channelSlug: string;
- handling: Handling;
- configName: string | undefined;
- videoDir: string;
- metaPath: string;
- metaMs: number;
- transcriptPath: string;
- transcriptMs: number | null;
-};
-
-async function exists(p: string): Promise<boolean> {
- try {
- await stat(p);
- return true;
- } catch {
- return false;
- }
-}
-
-async function readChannelConfig(dir: string): Promise<ChannelConfig | null> {
- try {
- const raw = await readFile(path.join(dir, "config.json"), "utf8");
- const parsed = JSON.parse(raw) as ChannelConfig;
- if (parsed.handling !== "youtube" && parsed.handling !== "transcribe") {
- return null;
- }
- return parsed;
- } catch {
- return null;
- }
-}
-
-async function scanSource(): Promise<{
- live: LiveEntry[];
- channels: Map<string, ChannelConfig>;
-}> {
- const channels = new Map<string, ChannelConfig>();
- const live: LiveEntry[] = [];
- const channelEntries = await readdir(CHANNELS_DIR, { withFileTypes: true });
- for (const ch of channelEntries) {
- if (!ch.isDirectory()) continue;
- const channelDir = path.join(CHANNELS_DIR, ch.name);
- const cfg = await readChannelConfig(channelDir);
- if (!cfg) {
- console.warn(
- `Skipping channel ${ch.name}: missing or invalid config.json`,
- );
- continue;
- }
- channels.set(ch.name, cfg);
- const dataDir = path.join(channelDir, "data");
- let videoEntries: Dirent[];
- try {
- videoEntries = await readdir(dataDir, { withFileTypes: true });
- } catch {
- continue;
- }
- const transcriptName =
- cfg.handling === "youtube" ? "transcript.en.vtt" : "transcript.json";
- for (const v of videoEntries) {
- if (!v.isDirectory()) continue;
- const videoDir = v.name;
- const metaPath = path.join(dataDir, videoDir, "metadata.info.json");
- const transcriptPath = path.join(dataDir, videoDir, transcriptName);
- let metaMs: number;
- try {
- metaMs = (await stat(metaPath)).mtimeMs;
- } catch {
- continue;
- }
- let transcriptMs: number | null = null;
- try {
- transcriptMs = (await stat(transcriptPath)).mtimeMs;
- } catch {
- transcriptMs = null;
- }
- live.push({
- channelSlug: ch.name,
- handling: cfg.handling,
- configName: cfg.name,
- videoDir,
- metaPath,
- metaMs,
- transcriptPath,
- transcriptMs,
- });
- }
- }
- return { live, channels };
-}
-
-function pathKeyId(k: PathKey): string {
- return `${k[0]}\x00${k[1]}`;
-}
-
-function indexKeysEqual(a: IndexKey, b: IndexKey): boolean {
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
-}
-
-async function writeJsonAtomic(
- filePath: string,
- value: unknown,
-): Promise<void> {
- const tmp = `${filePath}.tmp-${process.pid}`;
- await writeFile(tmp, JSON.stringify(value));
- await rename(tmp, filePath);
-}
-
-async function main(): Promise<void> {
- const t0 = Date.now();
- await mkdir(path.dirname(DB_PATH), { recursive: true });
- await mkdir(SUMMARIES_DIR, { recursive: true });
- await mkdir(TRANSCRIPTS_DIR, { recursive: true });
-
- const root = open({
- path: DB_PATH,
- maxDbs: 8,
- compression: true,
- });
- const sums = root.openDB<TranscriptSummary, IndexKey>({
- name: "sums",
- encoding: "msgpack",
- });
- const cues = root.openDB<Cue[], IndexKey>({
- name: "cues",
- encoding: "msgpack",
- });
- const mtimes = root.openDB<MtimeRecord, PathKey>({
- name: "mtimes",
- encoding: "msgpack",
- });
- // Per-channel forward index: lets us stream [channelSlug, uploadDate, id]
- // in oldest-first order for size-based page packing without loading every
- // channel's entries into memory.
- const byChannel = root.openDB<number, ChannelKey>({
- name: "byChannel",
- encoding: "msgpack",
- });
- // Content hashes of emitted transcript pages, so unchanged pages skip the
- // write. Since page 1 = oldest videos, adding a new newest-upload video
- // only dirties the last page's hash.
- const pageHashes = root.openDB<PageHashRecord, PageHashKey>({
- name: "pageHashes",
- encoding: "msgpack",
- });
- const meta = root.openDB<unknown, string>({
- name: "meta",
- encoding: "msgpack",
- });
-
- const storedSchema = meta.get("schema") as number | undefined;
- const schemaBumped = storedSchema !== SCHEMA_VERSION;
- if (schemaBumped) {
- console.log(
- `Schema change (${storedSchema ?? "<none>"} -> ${SCHEMA_VERSION}); invalidating LMDB cache.`,
- );
- await sums.clearAsync();
- await cues.clearAsync();
- await mtimes.clearAsync();
- await byChannel.clearAsync();
- await pageHashes.clearAsync();
- await meta.put("schema", SCHEMA_VERSION);
- }
-
- const { live, channels: channelConfigs } = await scanSource();
- const livePathIds = new Set<string>();
- const liveByPathId = new Map<string, LiveEntry>();
- for (const s of live) {
- const id = pathKeyId([s.channelSlug, s.videoDir]);
- livePathIds.add(id);
- liveByPathId.set(id, s);
- }
-
- // Diff against prior mtimes (path-keyed — no metadata reads required).
- const added: LiveEntry[] = [];
- const changed: LiveEntry[] = [];
- const removed: { pathKey: PathKey; indexKey: IndexKey }[] = [];
-
- for (const s of live) {
- const pk: PathKey = [s.channelSlug, s.videoDir];
- const prev = mtimes.get(pk);
- if (!prev) {
- added.push(s);
- } else if (
- prev.metaMs !== s.metaMs ||
- prev.transcriptMs !== s.transcriptMs
- ) {
- changed.push(s);
- }
- }
- for (const { key, value } of mtimes.getRange()) {
- const k = key as PathKey;
- if (!livePathIds.has(pathKeyId(k))) {
- removed.push({ pathKey: k, indexKey: (value as MtimeRecord).indexKey });
- }
- }
-
- const anyMutations =
- added.length > 0 || changed.length > 0 || removed.length > 0;
-
- // Short-circuit: nothing changed AND public/ is intact AND the configured
- // max page size matches what each channel was last paginated with.
- const { maxTranscriptPageBytes: configuredMaxPageBytes } = getSettings();
- if (!anyMutations && !schemaBumped) {
- const manifestRaw = await readFile(MANIFEST_PATH, "utf8").catch(() => null);
- if (manifestRaw) {
- try {
- const parsed = JSON.parse(manifestRaw) as Manifest;
- if (
- parsed.version === MANIFEST_VERSION &&
- parsed.totalCount === live.length &&
- parsed.pageSize === SUMMARIES_PAGE_SIZE
- ) {
- const firstPage = path.join(SUMMARIES_DIR, pageFileName(0));
- const lastPage = path.join(
- SUMMARIES_DIR,
- pageFileName(Math.max(0, parsed.pageCount - 1)),
- );
- let transcriptsIntact = true;
- for (const channelSlug of channelConfigs.keys()) {
- const mPath = path.join(
- TRANSCRIPTS_DIR,
- channelSlug,
- "manifest.json",
- );
- const raw = await readFile(mPath, "utf8").catch(() => null);
- if (!raw) {
- transcriptsIntact = false;
- break;
- }
- try {
- const cm = JSON.parse(raw) as ChannelTranscriptsManifest;
- if (
- cm.version !== TRANSCRIPTS_MANIFEST_VERSION ||
- cm.maxPageBytes !== configuredMaxPageBytes
- ) {
- transcriptsIntact = false;
- break;
- }
- } catch {
- transcriptsIntact = false;
- break;
- }
- }
- if (
- transcriptsIntact &&
- (await exists(firstPage)) &&
- (await exists(lastPage))
- ) {
- console.log(
- `Index up to date (${live.length} transcripts, ${parsed.pageCount} pages). Skipping.`,
- );
- await root.close();
- return;
- }
- }
- } catch {
- // fall through
- }
- }
- }
-
- console.log(
- `Diff: +${added.length} added, ~${changed.length} changed, -${removed.length} removed, ${live.length} total.`,
- );
-
- // Ensure per-channel public/transcripts/<channelSlug>/ subdirs exist.
- for (const channelSlug of channelConfigs.keys()) {
- await mkdir(path.join(TRANSCRIPTS_DIR, channelSlug), { recursive: true });
- }
-
- // Process mutations.
- const toProcess = [...added, ...changed];
- let processed = 0;
- const BATCH = 200;
- for (let i = 0; i < toProcess.length; i += BATCH) {
- const slice = toProcess.slice(i, i + BATCH);
- await Promise.all(
- slice.map(async (s) => {
- try {
- const metaRaw = await readFile(s.metaPath, "utf8");
- const parsedMeta = JSON.parse(metaRaw) as RawMetadata;
- const summary = summarize(
- s.channelSlug,
- s.videoDir,
- parsedMeta,
- s.configName,
- );
- if (!summary.uploadDate) {
- console.warn(
- `Skipping ${s.channelSlug}/${s.videoDir}: no upload_date`,
- );
- return;
- }
- const indexKey: IndexKey = [
- summary.uploadDate,
- s.channelSlug,
- summary.id,
- ];
- let cueList: Cue[] | undefined;
- if (s.transcriptMs !== null) {
- try {
- const raw = await readFile(s.transcriptPath, "utf8");
- cueList =
- s.handling === "youtube" ? parseVtt(raw) : parseWhisper(raw);
- } catch {
- cueList = undefined;
- }
- }
-
- // If upload_date shifted (metadata edit), the old composite key is
- // stale — drop it before writing the new one.
- const pk: PathKey = [s.channelSlug, s.videoDir];
- const prev = mtimes.get(pk);
- if (prev && !indexKeysEqual(prev.indexKey, indexKey)) {
- sums.remove(prev.indexKey);
- cues.remove(prev.indexKey);
- byChannel.remove(indexToChannelKey(prev.indexKey));
- }
-
- sums.put(indexKey, summary);
- if (cueList) cues.put(indexKey, cueList);
- else cues.remove(indexKey);
- byChannel.put(indexToChannelKey(indexKey), 1);
- mtimes.put(pk, {
- metaMs: s.metaMs,
- transcriptMs: s.transcriptMs,
- indexKey,
- });
- } catch (err) {
- console.warn(
- `Failed to process ${s.channelSlug}/${s.videoDir}:`,
- err,
- );
- }
- }),
- );
- processed += slice.length;
- if (toProcess.length > BATCH) {
- process.stdout.write(` processed ${processed}/${toProcess.length}\r`);
- }
- }
- if (toProcess.length > BATCH) process.stdout.write("\n");
-
- // Process removals.
- for (const { pathKey, indexKey } of removed) {
- sums.remove(indexKey);
- cues.remove(indexKey);
- byChannel.remove(indexToChannelKey(indexKey));
- mtimes.remove(pathKey);
- }
-
- await sums.flushed;
- await cues.flushed;
- await byChannel.flushed;
- await mtimes.flushed;
-
- // Emit per-channel paginated transcript pages. Pages are size-packed
- // oldest-first, so newer videos land on the last page and older pages
- // hash-match from build to build.
- const maxTranscriptPageBytes = configuredMaxPageBytes;
- const generatedAt = new Date().toISOString();
- let pagesWritten = 0;
- let pagesSkipped = 0;
- let pagesDeleted = 0;
-
- type PendingEntry = { encoded: string; id: string };
-
- const writePage = async (
- channelSlug: string,
- idx: number,
- entries: PendingEntry[],
- ): Promise<void> => {
- const body = `[${entries.map((e) => e.encoded).join(",")}]`;
- const hash = createHash("sha1").update(body).digest("hex");
- const prev = pageHashes.get([channelSlug, idx]);
- const outPath = path.join(
- TRANSCRIPTS_DIR,
- channelSlug,
- transcriptPageFileName(idx),
- );
- if (prev?.hash === hash && (await exists(outPath))) {
- pagesSkipped++;
- return;
- }
- const sizeBytes = Buffer.byteLength(body, "utf8");
- const tmp = `${outPath}.tmp-${process.pid}`;
- await writeFile(tmp, body);
- await rename(tmp, outPath);
- pageHashes.put([channelSlug, idx], {
- hash,
- entryCount: entries.length,
- sizeBytes,
- });
- pagesWritten++;
- };
-
- for (const channelSlug of Array.from(channelConfigs.keys()).sort()) {
- const channelDir = path.join(TRANSCRIPTS_DIR, channelSlug);
- await mkdir(channelDir, { recursive: true });
-
- let pageIdx = 0;
- let buffer: PendingEntry[] = [];
- // Running payload size excluding the outer `[` and `]` (accounted at
- // flush time). Includes the leading commas between elements.
- let payloadBytes = 0;
- const slugToPage: Record<string, number> = {};
-
- for (const { key } of byChannel.getRange({
- start: [channelSlug],
- end: [channelSlug, ""],
- })) {
- const ck = key as ChannelKey;
- if (ck[0] !== channelSlug) continue;
- const indexKey: IndexKey = [ck[1], ck[0], ck[2]];
- const summary = sums.get(indexKey);
- if (!summary) continue;
- const cueList = cues.get(indexKey);
- const detail: TranscriptDetail = { ...summary, cues: cueList };
- const encoded = JSON.stringify(detail);
- const entryBytes = Buffer.byteLength(encoded, "utf8");
- const commaBytes = buffer.length === 0 ? 0 : 1;
- const delta = entryBytes + commaBytes;
-
- // Close current page if this entry would push it past the limit.
- // `+ 2` accounts for the outer brackets.
- if (buffer.length > 0 && payloadBytes + delta + 2 > maxTranscriptPageBytes) {
- await writePage(channelSlug, pageIdx, buffer);
- pageIdx++;
- buffer = [];
- payloadBytes = 0;
- }
-
- if (entryBytes + 2 > maxTranscriptPageBytes) {
- console.warn(
- ` ${summary.slug}: entry (${entryBytes} bytes) exceeds page budget; emitting solo page.`,
- );
- }
-
- slugToPage[summary.id] = pageIdx;
- buffer.push({ encoded, id: summary.id });
- payloadBytes += delta;
- }
-
- if (buffer.length > 0) {
- await writePage(channelSlug, pageIdx, buffer);
- pageIdx++;
- }
-
- const pageCount = pageIdx;
-
- // Prune stale per-channel page files + drop pageHashes beyond pageCount.
- const keep = new Set<string>(["manifest.json"]);
- for (let i = 0; i < pageCount; i++) keep.add(transcriptPageFileName(i));
- const existing = await readdir(channelDir).catch(() => [] as string[]);
- for (const name of existing) {
- if (keep.has(name)) continue;
- await rm(path.join(channelDir, name), { force: true });
- pagesDeleted++;
- }
- // pageHashes may still have records for page indexes >= pageCount
- // (channel shrank). Drop them.
- for (const { key } of pageHashes.getRange({
- start: [channelSlug, pageCount],
- end: [channelSlug, Number.MAX_SAFE_INTEGER],
- })) {
- pageHashes.remove(key as PageHashKey);
- }
-
- const channelManifest: ChannelTranscriptsManifest = {
- version: TRANSCRIPTS_MANIFEST_VERSION,
- channelSlug,
- pageCount,
- maxPageBytes: maxTranscriptPageBytes,
- generatedAt,
- slugToPage,
- };
- await writeJsonAtomic(
- path.join(channelDir, "manifest.json"),
- channelManifest,
- );
- }
-
- await pageHashes.flushed;
-
- // Prune channel directories for channels no longer in config, and any
- // legacy per-video `<id>.json` files that leaked in from pre-schema-4
- // builds at the top level of transcripts/ (pre-channel-subdir layout).
- const topEntries = await readdir(TRANSCRIPTS_DIR, {
- withFileTypes: true,
- }).catch(() => [] as Dirent[]);
- for (const e of topEntries) {
- if (e.isDirectory()) {
- if (!channelConfigs.has(e.name)) {
- await rm(path.join(TRANSCRIPTS_DIR, e.name), {
- recursive: true,
- force: true,
- });
- }
- } else if (e.isFile()) {
- await rm(path.join(TRANSCRIPTS_DIR, e.name), { force: true });
- }
- }
-
- console.log(
- `Transcript pages: ${pagesWritten} written, ${pagesSkipped} unchanged, ${pagesDeleted} stale removed.`,
- );
-
- // Stream LMDB (reverse composite-key order = newest-date first) to emit
- // paginated summaries.
- const pageSize = SUMMARIES_PAGE_SIZE;
- const channelCounts = new Map<string, number>();
- // Seed channel list from config so empty channels still appear in the UI.
- for (const cfg of channelConfigs.values()) {
- if (cfg.name) channelCounts.set(cfg.name, 0);
- }
- let pageIndex = 0;
- let buffer: DisplaySummary[] = [];
- let total = 0;
-
- const flushPage = async () => {
- if (buffer.length === 0) return;
- const p = path.join(SUMMARIES_DIR, pageFileName(pageIndex));
- await writeJsonAtomic(p, buffer);
- buffer = [];
- pageIndex++;
- };
-
- for (const { value } of sums.getRange({ reverse: true })) {
- const s = value as TranscriptSummary;
- if (s.channel) {
- channelCounts.set(s.channel, (channelCounts.get(s.channel) ?? 0) + 1);
- }
- buffer.push(toDisplaySummary(s));
- total++;
- if (buffer.length >= pageSize) await flushPage();
- }
- await flushPage();
-
- // Prune stale page files.
- const expectedPages = new Set<string>();
- for (let i = 0; i < pageIndex; i++) expectedPages.add(pageFileName(i));
- const existing = await readdir(SUMMARIES_DIR).catch(() => [] as string[]);
- for (const name of existing) {
- if (!name.startsWith("page-")) continue;
- if (!expectedPages.has(name)) {
- await rm(path.join(SUMMARIES_DIR, name), { force: true });
- }
- }
-
- const channelList: ChannelEntry[] = Array.from(channelCounts.entries())
- .map(([name, count]) => ({ name, count }))
- .sort((a, b) => a.name.localeCompare(b.name));
-
- const manifest: Manifest = {
- version: MANIFEST_VERSION,
- totalCount: total,
- pageSize,
- pageCount: pageIndex,
- generatedAt: new Date().toISOString(),
- channels: channelList,
- };
- await writeJsonAtomic(MANIFEST_PATH, manifest);
-
- await root.close();
-
- const secs = ((Date.now() - t0) / 1000).toFixed(2);
- console.log(
- `Done in ${secs}s. ${total} transcripts across ${pageIndex} pages; ${channelList.length} channels.`,
- );
-}
-
-main().catch((err) => {
- console.error(err);
- process.exit(1);
-});
diff --git a/tsconfig.base.json b/tsconfig.base.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "incremental": true
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
@@ -1,34 +0,0 @@
-{
- "compilerOptions": {
- "target": "ESNext",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": [
- "next-env.d.ts",
- "**/*.ts",
- "**/*.tsx",
- ".next/types/**/*.ts",
- ".next/dev/types/**/*.ts",
- "**/*.mts"
- ],
- "exclude": ["node_modules"]
-}