export type VideoRowStatus = | "transcribed" | "downloaded_no_transcript" | "no_audio" | "untranscribable" | "partial_download" | "not_downloaded" | "corrupt_source" | "corrupt_full_source" | "failed"; export type VideoRow = { id: string; // What the video is called, when anything on disk says so — the transcript // index, the channel's metadata scan, or data//metadata.info.json, in // that order (common/controller/videoTitles.ts). Absent means the list shows // the id alone. title?: string; downloaded: boolean; transcribed: boolean; untranscribable: boolean; partial: boolean; // The audio-check pipeline gave up on a malformed source (download-outcome // "failed-corrupt-source"), leaving no real audio. A download/source problem — // surfaced distinctly so it isn't conflated with a failed transcription. corruptSource: boolean; // The download COMPLETED but its audio stream stayed malformed after one // re-download (download-outcome "corrupt-full-source"). The raw container is // kept on disk for inspection; re-downloading is futile. Terminal and distinct // from corruptSource (which never finished) and from a failed transcription. corruptFullSource: boolean; failedTranscription: boolean; // Has at least one finalized audio file not in the channel's target format — // the failed-extract orphans (audio.m4a with no audio.mp3) plus dirs with the // target alongside extra formats. Drives the "Select wrong-format" quick-select // and matches what the wrong-format-audio removal deletes. wrongFormatAudio: boolean; // Members-only / deleted / private — listed in the channel but unactionable. excluded: boolean; // Transcribed, but the transcript covers only a small fraction of the video's // duration — the audio download truncated silently. Independent flag (not a // `status`) so it composes with `transcribed`. See transcriptCoverage. incompleteTranscript: boolean; // The digest pass recorded warnings, or failed outright and wrote no section // at all. Independent flag (it composes with `transcribed`, and a total // failure is also reachable work in `backfill.digest`) so the review queue can // ask "what did the model do badly here?" separately from "what has no digest // yet?". digestWarnings: boolean; // Download completed but the audio was far shorter than the video — the source // served a truncated stream (download-outcome "failed-short-audio"). The stub // is kept on disk; re-download with a different format to fix. Independent flag // (the video isn't transcribed), mirroring incompleteTranscript. shortAudio: boolean; running: boolean; status: VideoRowStatus; // Curated tags PINNED on this video (transcripts/tags.json), resolved from // the record id the assignments are keyed by. Rule hits are deliberately NOT // here: they are derived at index-build time and never stored, so a list // rendered from disk cannot know them without re-running every rule over // every video. The chips therefore filter on what a person or an agent // asserted, which is also the only thing this list can offer to change. curatedTags?: string[]; }; // "all" is not a real filter — it's the empty selection. The rest are the // toggleable chips; multiple can be active at once and combine as an // intersection (a row must match every selected filter). The two flag-based // cases (transcribed, partial) are intentionally NOT keyed off the // mutually-exclusive `status` enum so they can be meaningfully combined — e.g. // "transcribed AND partial" surfaces an already-transcribed video that still // has a leftover .part download. export type VideoFilter = | "needs_action" | "failed" | "no_audio" | "downloaded_no_transcript" | "transcribed" | "partial" | "incomplete_transcript" | "short_audio" | "untranscribable" | "digest_warnings" | "running"; const VIDEO_FILTERS: readonly VideoFilter[] = [ "needs_action", "failed", "no_audio", "downloaded_no_transcript", "transcribed", "partial", "incomplete_transcript", "short_audio", "untranscribable", "digest_warnings", "running", ]; function matchesFilter(r: VideoRow, filter: VideoFilter): boolean { switch (filter) { case "needs_action": return ( (r.status !== "transcribed" && r.status !== "untranscribable" && !r.excluded) || r.failedTranscription ); case "failed": return r.failedTranscription; case "no_audio": return ( r.status === "no_audio" || r.status === "not_downloaded" || r.status === "corrupt_source" || r.status === "corrupt_full_source" ); case "downloaded_no_transcript": return r.status === "downloaded_no_transcript"; case "transcribed": return r.transcribed; case "partial": return r.partial; case "incomplete_transcript": return r.incompleteTranscript; case "short_audio": return r.shortAudio; case "untranscribable": return r.untranscribable; case "digest_warnings": return r.digestWarnings; case "running": return r.running; } } // Intersection: a row is visible only if it matches EVERY selected filter. An // empty set means no constraint (the "All" chip) — every row is returned. export function filterRows( rows: VideoRow[], filters: Set, ): VideoRow[] { if (filters.size === 0) return rows; return rows.filter((r) => [...filters].every((filter) => matchesFilter(r, filter)), ); } // The search box: a case-insensitive substring of the id OR the title. export function matchesVideoQuery(r: VideoRow, q: string): boolean { const needle = q.trim().toLowerCase(); if (!needle) return true; return ( r.id.toLowerCase().includes(needle) || (r.title?.toLowerCase().includes(needle) ?? false) ); } // Parse the comma-separated `?filter=` value into a set of valid filters, // ignoring unknown/legacy tokens (including "all"). A single legacy // `?filter=transcribed` parses to a one-element set, so old links still work. export function parseFilters(raw: string | undefined): Set { const out = new Set(); if (!raw) return out; for (const token of raw.split(",")) { const t = token.trim(); if ((VIDEO_FILTERS as readonly string[]).includes(t)) { out.add(t as VideoFilter); } } return out; } // Serialize a filter set back into the `?filter=` value, or null when empty // (so the param is dropped). Order follows VIDEO_FILTERS for stable URLs. export function serializeFilters(filters: Set): string | null { if (filters.size === 0) return null; const ordered = VIDEO_FILTERS.filter((f) => filters.has(f)); return ordered.join(","); }