"use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useRef, useState, useTransition } from "react"; import { useVirtualizer } from "yt-dlp-transcript-common/components/virtualizer"; import { VirtualRow } from "yt-dlp-transcript-common/components/VirtualRow"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { VideoRow, VideoFilter } from "../lib/videoRows"; import type { TagDef } from "../lib/videoTagRows"; import { filterRows, matchesVideoQuery, serializeFilters, } from "../lib/videoRows"; import { QueueControl } from "../../../components/QueueControl"; import { bulkClearFailedMarkersAction, bulkClearIncompleteAction, bulkDeleteVideoDirsAction, bulkMarkUntranscribableAction, bulkRedownloadIncompleteAction, bulkRedownloadShortAudioAction, bulkRemoveAudioAction, bulkRemoveWrongFormatAudioAction, bulkRetryDownloadAction, bulkTranscribeAction, bulkApplyTagAction, type BulkActionSummary, } from "../bulkVideoActions"; type BulkAction = | "transcribe" | "retry" | "redownload_incomplete" | "clear_incomplete" | "redownload_short_audio" | "untranscribable" | "clear_failed" | "remove_audio" | "remove_wrong_format" | "tag" | "untag" | "suppress_tag" | "delete"; const BULK_ACTION_OPTIONS: { value: BulkAction; label: string }[] = [ { value: "transcribe", label: "Transcribe" }, { value: "retry", label: "Retry download" }, { value: "redownload_incomplete", label: "Re-download & re-transcribe" }, { value: "clear_incomplete", label: "Clear incomplete (audio+transcript)" }, { value: "redownload_short_audio", label: "Re-download truncated (short audio)" }, { value: "untranscribable", label: "Mark untranscribable" }, { value: "clear_failed", label: "Clear failed markers" }, { value: "remove_audio", label: "Remove audio files" }, { value: "remove_wrong_format", label: "Remove wrong-format audio" }, { value: "tag", label: "Tag selected" }, { value: "untag", label: "Untag selected" }, { value: "suppress_tag", label: "Suppress on selected" }, { value: "delete", label: "Delete directories" }, ]; type Props = { slug: string; rows: VideoRow[]; // The corpus vocabulary, for the bulk tag picker and the chip row. Empty when // nothing has been defined on /tags, and then neither appears. tagDefs: TagDef[]; selectedVideoId: string | null; initialFilters: VideoFilter[]; initialQuery: string; initialTagFilter: string[]; defaultTranscribeQueue: string; defaultDownloadQueue: string; existingQueues: string[]; }; // Toggleable filter chips. They combine as an intersection (a row must match // every active chip), so e.g. Transcribed + Partial finds already-transcribed // videos that still carry a leftover .part download. "All" (rendered // separately) is the cleared state, not a chip in this list. const FILTER_OPTIONS: { value: VideoFilter; label: string }[] = [ { value: "needs_action", label: "Needs action" }, { value: "failed", label: "Failed" }, { value: "no_audio", label: "No audio" }, { value: "downloaded_no_transcript", label: "No transcript" }, { value: "partial", label: "Partial" }, { value: "incomplete_transcript", label: "Incomplete transcript" }, { value: "digest_warnings", label: "Digest warnings" }, { value: "short_audio", label: "Short audio" }, { value: "untranscribable", label: "Untranscribable" }, { value: "running", label: "Running" }, { value: "transcribed", label: "Transcribed" }, ]; // One row is a checkbox, three status dots and a monospace id on one line. The // virtualizer still measures each mounted row (ResizeObserver), so this is only // the first-paint guess and a wrapped id costs nothing but a reflow. const ROW_ESTIMATE_PX = 30; // The viewport the virtualizer assumes for its FIRST render, before the scroll // element has been measured. Roughly the pane's real height on a laptop — the // exact number only decides how many rows the first paint contains, and the // ResizeObserver corrects it immediately afterwards. const INITIAL_LIST_HEIGHT_PX = 900; const INITIAL_LIST_WIDTH_PX = 384; export function VideoListPane({ slug, rows, tagDefs, selectedVideoId, initialFilters, initialQuery, initialTagFilter, defaultTranscribeQueue, defaultDownloadQueue, existingQueues, }: Props) { const router = useRouter(); const [filters, setFilters] = useState>( () => new Set(initialFilters), ); const [query, setQuery] = useState(initialQuery); const [selected, setSelected] = useState>(new Set()); const [pending, startTransition] = useTransition(); const [lastResult, setLastResult] = useState(null); const [error, setError] = useState(null); const [transcribeQueue, setTranscribeQueue] = useState(defaultTranscribeQueue); const [retryQueue, setRetryQueue] = useState(defaultDownloadQueue); // Re-download & re-transcribe runs download+whisper inline; queue it on the // transcription queue (same default as bulk transcribe). const [incompleteQueue, setIncompleteQueue] = useState(defaultTranscribeQueue); const [retryAbortOnError, setRetryAbortOnError] = useState(false); const [action, setAction] = useState("transcribe"); const [deleteConfirm, setDeleteConfirm] = useState(""); const [bulkTag, setBulkTag] = useState(tagDefs[0]?.id ?? ""); // TAG CHIPS ARE AN OR, and deliberately not part of the intersection above: // the status chips answer "what state is this video in" (a video is in all of // them at once or it is not), while tags answer "which of these subjects is // this video about" — and an intersection there would mean "on mic AND in // chat", which is not a question anybody asks of a subject list. const [tagFilter, setTagFilter] = useState>( () => new Set(initialTagFilter), ); const visibleRows = useMemo(() => { let filtered = filterRows(rows, filters); if (tagFilter.size > 0) { filtered = filtered.filter((r) => (r.curatedTags ?? []).some((t) => tagFilter.has(t)), ); } if (!query.trim()) return filtered; // Id OR title, case-insensitive — the same predicate the server applies to // `?q=` for the detail pane's prev/next. return filtered.filter((r) => matchesVideoQuery(r, query)); }, [rows, filters, query, tagFilter]); // VIRTUALIZED. The largest channel has ~11,000 videos, and one
  • + // each is ~11,000 DOM nodes for a pane that shows twenty of them. The scroll // container is this list, not the window, so this is the element virtualizer // (the corpus search results use the window one — same VirtualRow helper, // different scroller). const listRef = useRef(null); const rowVirtualizer = useVirtualizer({ count: visibleRows.length, getScrollElement: () => listRef.current, estimateSize: () => ROW_ESTIMATE_PX, overscan: 12, // WITHOUT THIS THE FIRST PAINT IS EMPTY. The virtualizer derives its window // from the scroll element's measured rect, and before the ResizeObserver // fires that rect is 0×0 — so getVirtualItems() returns nothing and the list // renders no rows at all until a frame later. That is invisible to a human // and lethal to anything that reads the DOM immediately after navigation: // it turns "assert this row is present" into a silent zero. Seeding a // plausible viewport makes the first render produce a full window, which // measurement then corrects. initialRect: { width: INITIAL_LIST_WIDTH_PX, height: INITIAL_LIST_HEIGHT_PX }, }); const virtualItems = rowVirtualizer.getVirtualItems(); const allSelected = visibleRows.length > 0 && visibleRows.every((r) => selected.has(r.id)); const someSelected = selected.size > 0; function toggleOne(id: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function toggleAll() { setSelected((prev) => { const next = new Set(prev); const everyVisible = visibleRows.every((r) => next.has(r.id)); if (everyVisible) { for (const r of visibleRows) next.delete(r.id); } else { for (const r of visibleRows) next.add(r.id); } return next; }); } // Filtering and search are entirely client-side, so the URL only has to // MIRROR them for reload/share — it is not a navigation. history.replaceState // (which Next's useSearchParams still reflects) rather than router.replace: // that avoids an RSC refetch per keystroke and, crucially, won't be dropped // when it races the global AutoRefresh router.refresh() transition. function syncUrl(nextFilters: Set, nextQuery: string) { const url = new URL(window.location.href); const serialized = serializeFilters(nextFilters); if (serialized) url.searchParams.set("filter", serialized); else url.searchParams.delete("filter"); if (nextQuery.trim()) url.searchParams.set("q", nextQuery); else url.searchParams.delete("q"); window.history.replaceState(null, "", url.toString()); } // Toggle one chip in/out of the active set (intersection filtering). function toggleFilter(value: VideoFilter) { const next = new Set(filters); if (next.has(value)) next.delete(value); else next.add(value); setFilters(next); syncUrl(next, query); } // Tags are an OR over chips (see the state declaration), and they ride in the // URL as `tg` — the same param name the export viewer uses for the same // question, so a link is readable on either side. function toggleTagFilter(value: string) { const next = new Set(tagFilter); if (next.has(value)) next.delete(value); else next.add(value); setTagFilter(next); const url = new URL(window.location.href); url.searchParams.delete("tg"); for (const t of next) url.searchParams.append("tg", t); window.history.replaceState(null, "", url.toString()); } // The "All" chip clears every active filter. function clearFilters() { const next = new Set(); setFilters(next); syncUrl(next, query); } function changeQuery(value: string) { setQuery(value); syncUrl(filters, value); } // Built from local state rather than useSearchParams: the state is what the // list is actually showing, and after a replaceState the two agree anyway. function buildHref(videoId: string): string { const params = new URLSearchParams(); const serialized = serializeFilters(filters); if (serialized) params.set("filter", serialized); if (query.trim()) params.set("q", query); for (const t of tagFilter) params.append("tg", t); params.set("video", videoId); return `/channels/${slug}/videos?${params.toString()}`; } // Transcribe/retry submit ONE managed batch job; we don't consume its stream // (the job runs to completion regardless) and just refresh so it appears in // the page's running-jobs list like any other batch. function doStreamingBulk( action: (slug: string, ids: string[]) => Promise, ) { const ids = Array.from(selected); if (ids.length === 0) return; setLastResult(null); setError(null); startTransition(async () => { try { const result = await action(slug, ids); if (!result.ok) { setError(result.error); return; } // Fire-and-forget: cancel the stream so we don't hold the page open; // the job keeps running and shows up at the top of the page. void result.stream.cancel(); setSelected(new Set()); } catch (e) { setError((e as Error).message); } finally { router.refresh(); } }); } // Mark-untranscribable / clear-markers / delete are synchronous metadata or // filesystem ops with a per-id summary — no queued job. function doSummaryBulk( summaryAction: (slug: string, ids: string[]) => Promise, onOk?: () => void, ) { const ids = Array.from(selected); if (ids.length === 0) return; setLastResult(null); setError(null); startTransition(async () => { const result = await summaryAction(slug, ids); setLastResult(result); if (result.ok) { setSelected(new Set()); onOk?.(); } router.refresh(); }); } // Quick-select helpers. Both merge into the current selection so they compose // across filter changes (e.g. select failed, then narrow and select more). function selectFailed() { setSelected((prev) => { const next = new Set(prev); for (const r of rows) { if (r.failedTranscription) next.add(r.id); } return next; }); } function selectFiltered() { setSelected((prev) => { const next = new Set(prev); for (const r of visibleRows) next.add(r.id); return next; }); } // Videos with a finalized audio file not in the channel's target format — // the failed-extract leftovers the wrong-format removal targets. const hasWrongFormat = useMemo( () => rows.some((r) => r.wrongFormatAudio), [rows], ); function selectWrongFormat() { setSelected((prev) => { const next = new Set(prev); for (const r of rows) { if (r.wrongFormatAudio) next.add(r.id); } return next; }); } // Videos whose transcript is truncated (covers a fraction of the runtime) — // the targets of the re-download/clear actions. const hasIncomplete = useMemo( () => rows.some((r) => r.incompleteTranscript), [rows], ); function selectIncomplete() { setSelected((prev) => { const next = new Set(prev); for (const r of rows) { if (r.incompleteTranscript) next.add(r.id); } return next; }); } // Videos whose download was truncated at the source (short audio kept on disk). const hasShortAudio = useMemo(() => rows.some((r) => r.shortAudio), [rows]); function selectShortAudio() { setSelected((prev) => { const next = new Set(prev); for (const r of rows) { if (r.shortAudio) next.add(r.id); } return next; }); } const deleteArmed = deleteConfirm.trim().toLowerCase() === "delete"; const applyDisabled = pending || (action === "delete" && !deleteArmed) || ((action === "tag" || action === "untag" || action === "suppress_tag") && !bulkTag); function handleApply() { switch (action) { case "transcribe": doStreamingBulk((s, ids) => bulkTranscribeAction(s, ids, transcribeQueue), ); break; case "retry": doStreamingBulk((s, ids) => bulkRetryDownloadAction(s, ids, retryQueue, retryAbortOnError), ); break; case "redownload_incomplete": doStreamingBulk((s, ids) => bulkRedownloadIncompleteAction(s, ids, incompleteQueue), ); break; case "redownload_short_audio": doStreamingBulk((s, ids) => bulkRedownloadShortAudioAction(s, ids, incompleteQueue), ); break; case "clear_incomplete": if ( !confirm( `Clear the truncated audio + transcript for ${selected.size} video${selected.size === 1 ? "" : "s"} and enable the auto-download/transcribe runners? The current partial transcripts are removed and re-fetched. This cannot be undone.`, ) ) return; doSummaryBulk(bulkClearIncompleteAction); break; case "untranscribable": if ( !confirm( `Mark ${selected.size} video${selected.size === 1 ? "" : "s"} untranscribable?`, ) ) return; doSummaryBulk(bulkMarkUntranscribableAction); break; case "clear_failed": doSummaryBulk(bulkClearFailedMarkersAction); break; case "remove_audio": if ( !confirm( `Remove audio files from ${selected.size} video${selected.size === 1 ? "" : "s"}? Transcripts and metadata are kept; .part downloads are left to resume.`, ) ) return; doSummaryBulk(bulkRemoveAudioAction); break; case "remove_wrong_format": if ( !confirm( `Remove wrong-format audio from ${selected.size} video${selected.size === 1 ? "" : "s"}? Only audio files not in the channel's target format are deleted.`, ) ) return; doSummaryBulk(bulkRemoveWrongFormatAudioAction); break; case "tag": case "untag": case "suppress_tag": { if (!bulkTag) return; const op = action === "tag" ? "add" : action === "untag" ? "remove" : "suppress"; doSummaryBulk((s, ids) => bulkApplyTagAction(s, ids, bulkTag, op)); break; } case "delete": if (!deleteArmed) return; doSummaryBulk(bulkDeleteVideoDirsAction, () => setDeleteConfirm("")); break; } } return (
    changeQuery(e.target.value)} aria-label="search videos" className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm" />
    {FILTER_OPTIONS.map((opt) => { const active = filters.has(opt.value); return ( ); })}
    {tagDefs.length > 0 && (
    {tagDefs.map((t) => { const active = tagFilter.has(t.id); const count = rows.filter((r) => (r.curatedTags ?? []).includes(t.id), ).length; if (count === 0 && !active) return null; return ( ); })}
    )}
    {visibleRows.length} of {rows.length} videos
    {hasWrongFormat && ( )} {hasIncomplete && ( )} {hasShortAudio && ( )} {visibleRows.length > 0 && ( <> )}
    {/* The scroll container is unlabelled and the
      carries the name. That split is not cosmetic: an accessible name only attaches to an element whose role permits one, and a bare
      is role=generic, which prohibits it — so `getByLabel("videos")` on the div matches NOTHING and every assertion scoped to it passes vacuously. A
        is role=list, which is nameable. It is also always rendered, empty state included, so the locator resolves whether or not the filter matched. */}
          {visibleRows.length === 0 ? (
        • No videos match this filter.
        • ) : ( virtualItems.map((vi) => { const row = visibleRows[vi.index]; const isActive = row.id === selectedVideoId; const isSelected = selected.has(row.id); return ( toggleOne(row.id)} aria-label={`select ${row.id}`} className="flex-shrink-0" /> {row.title ? ( // Title first, id underneath: the id is still what the // row's accessible name and the URL carry. {row.title} {row.id} ) : ( {row.id} )} {row.running && ( running )} ); }) )}
        {someSelected && (
        {selected.size} selected
        {/* Contextual controls for the selected action */} {action === "transcribe" && ( )} {action === "retry" && ( <> )} {(action === "redownload_incomplete" || action === "redownload_short_audio") && ( )} {(action === "tag" || action === "untag" || action === "suppress_tag") && ( )} {action === "delete" && ( )}
        {/* UNTAG IS NOT THE OPPOSITE OF TAG, and the chips cannot show the difference: they list PINS, while a rule's hit is derived at index build and never stored. So an untagged video whose rule still matches looks clear here and ships tagged. */} {(action === "tag" || action === "untag" || action === "suppress_tag") && (

        Untag unpins; a rule that still matches keeps the tag — use Suppress to reject it.

        )}
        {error && (

        {error}

        )} {lastResult && (

        {lastResult.succeeded}/{lastResult.attempted} succeeded {lastResult.failures.length > 0 && ` — ${lastResult.failures.length} failed`}

        )}
        )}
      ); } function StatusGlyphs({ row }: { row: VideoRow }) { const dlColor = row.partial || row.corruptSource || row.corruptFullSource ? "bg-warning" : row.downloaded ? "bg-success" : "bg-muted"; const trColor = row.untranscribable ? "bg-muted-foreground" : row.incompleteTranscript ? "bg-warning" : row.transcribed ? "bg-success" : "bg-muted"; const failureColor = row.failedTranscription ? "bg-destructive" : null; return ( {failureColor && ( )} ); }