Archilyzer · Source

archilyzer

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

commit 9432cc210af3cca0c483b4eb06ce436afa55e7ea
parent ac2cea66f2a93bd5d37917bcad091fe77d594c10
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 10 May 2026 23:23:58 -0400

Add availability and fix reports OOM

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 35++++++++++++++++++++++++++++++++---
Mcommon/components/urlState.ts | 14++++++++++++++
Mcommon/controller/archiveTranscripts.ts | 38++++++++++++--------------------------
Mcommon/controller/buildIndex.ts | 26+++++++++++++++++++++++---
Mcommon/controller/channelSnapshot.ts | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Acommon/controller/checkAvailability.ts | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/archiveOptions.ts | 31+++++++++++++++++++++++++++++++
Acommon/lib/availability-server.ts | 40++++++++++++++++++++++++++++++++++++++++
Acommon/lib/availability.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/transcripts-server.ts | 6+++++-
Mcommon/lib/transcripts.ts | 1+
Mcommon/lib/videoStatus.ts | 21++++++++++++++++-----
Meditor/app/build/components/ArchiveOptionsControl.tsx | 2+-
Meditor/app/build/components/BuildButtons.tsx | 2+-
Aeditor/app/channels/[slug]/availabilityActions.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/lib/stageStatus.ts | 11+++++++----
Meditor/app/channels/[slug]/page.tsx | 4++++
Aeditor/e2e/availability.spec.ts | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 44++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/config.json | 5+++++
Aeditor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/viddeleted1/metadata.info.json | 15+++++++++++++++
Aeditor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidmembers1/metadata.info.json | 15+++++++++++++++
Aeditor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidneedsauth1/metadata.info.json | 15+++++++++++++++
Aeditor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidprivate1/metadata.info.json | 15+++++++++++++++
Aeditor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidpublic1/metadata.info.json | 15+++++++++++++++
Aeditor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidpublic2/metadata.info.json | 15+++++++++++++++
27 files changed, 1076 insertions(+), 62 deletions(-)

diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -41,6 +41,8 @@ export default function TranscriptSearch() { nol, naa, nar, + nav, + nd, } = 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 @@ -66,6 +68,8 @@ export default function TranscriptSearch() { const [draftNol, setDraftNol] = useState(nol); const [draftNaa, setDraftNaa] = useState(naa); const [draftNar, setDraftNar] = useState(nar); + const [draftNav, setDraftNav] = useState(nav); + const [draftNd, setDraftNd] = useState(nd); useEffect(() => { setDraftExcludedChannels(new Set(committedChannelList)); }, [committedChannelList]); @@ -73,6 +77,8 @@ export default function TranscriptSearch() { useEffect(() => setDraftNol(nol), [nol]); useEffect(() => setDraftNaa(naa), [naa]); useEffect(() => setDraftNar(nar), [nar]); + useEffect(() => setDraftNav(nav), [nav]); + useEffect(() => setDraftNd(nd), [nd]); const [searchExecuted, setSearchExecuted] = useState(() => { if (typeof window === "undefined") return true; @@ -145,14 +151,15 @@ export default function TranscriptSearch() { if (committedChannels.has(t.channel)) return false; if (t.isLivestream ? nol : nov) return false; if (t.ageRestricted ? nar : naa) return false; + if (t.isDeleted ? nd : nav) return false; return true; }; - }, [committedChannels, nov, nol, naa, nar]); + }, [committedChannels, nov, nol, naa, nar, nav, nd]); // 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 filterKey = `${committedChannelsKey}|${nov ? 1 : 0}|${nol ? 1 : 0}|${naa ? 1 : 0}|${nar ? 1 : 0}|${nav ? 1 : 0}|${nd ? 1 : 0}`; const regex = useMemo<{ re: RegExp | null; error: string | null }>(() => { if (!trimmed || !useRegex) return { re: null, error: null }; @@ -248,7 +255,9 @@ export default function TranscriptSearch() { draftNov !== nov || draftNol !== nol || draftNaa !== naa || - draftNar !== nar; + draftNar !== nar || + draftNav !== nav || + draftNd !== nd; const queryDirty = input.trim() !== (searchExecuted ? query.trim() : ""); const commitSearch = () => { setSearchExecuted(true); @@ -259,6 +268,8 @@ export default function TranscriptSearch() { nol: draftNol, naa: draftNaa, nar: draftNar, + nav: draftNav, + nd: draftNd, }); }; @@ -383,6 +394,24 @@ export default function TranscriptSearch() { /> Age-restricted </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNav} + onChange={(e) => setDraftNav(!e.target.checked)} + className="accent-blue-600" + /> + Available + </label> + <label className="flex items-center gap-1.5 select-none"> + <input + type="checkbox" + checked={!draftNd} + onChange={(e) => setDraftNd(!e.target.checked)} + className="accent-blue-600" + /> + Deleted + </label> </div> )} diff --git a/common/components/urlState.ts b/common/components/urlState.ts @@ -12,6 +12,8 @@ export type UrlParams = { nol: boolean; naa: boolean; nar: boolean; + nav: boolean; + nd: boolean; }; const listeners = new Set<() => void>(); @@ -47,6 +49,8 @@ function parse(search: string): UrlParams { nol: p.get("nol") === "1", naa: p.get("naa") === "1", nar: p.get("nar") === "1", + nav: p.get("nav") === "1", + nd: p.get("nd") === "1", }; } @@ -65,6 +69,8 @@ type Patch = Partial<{ nol: boolean; naa: boolean; nar: boolean; + nav: boolean; + nd: boolean; }>; export function writeUrlParams(patch: Patch) { @@ -105,6 +111,14 @@ export function writeUrlParams(patch: Patch) { if (patch.nar) params.set("nar", "1"); else params.delete("nar"); } + if (patch.nav !== undefined) { + if (patch.nav) params.set("nav", "1"); + else params.delete("nav"); + } + if (patch.nd !== undefined) { + if (patch.nd) params.set("nd", "1"); + else params.delete("nd"); + } const qs = params.toString(); const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`; if (next === window.location.pathname + window.location.search) return; diff --git a/common/controller/archiveTranscripts.ts b/common/controller/archiveTranscripts.ts @@ -39,32 +39,18 @@ import { import { CUES_JSON_FILENAME } from "../lib/videoStatus"; import { detectPlatform } from "../lib/platform"; import type { Paths } from "../lib/paths"; - -export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip"; - -export const ARCHIVE_FORMATS: ReadonlyArray<ArchiveFormat> = [ - "tar.gz", - "tar.xz", - "zip", -]; - -export type ArchiveBuildOptions = { - format: ArchiveFormat; - // 1 (fastest, biggest) … 9 (slowest, smallest). Common to all three formats. - compressionLevel: number; - // When false, archive entries are stripped to { version, source, cues }. - // Per-video addressability still comes from the file path. - includeMetadata: boolean; - // When true, cues files are written with 2-space indentation. Channel - // manifests are always indented. - prettyPrint: boolean; -}; - -export const DEFAULT_ARCHIVE_OPTIONS: ArchiveBuildOptions = { - format: "tar.gz", - compressionLevel: 6, - includeMetadata: true, - prettyPrint: false, +import { + ARCHIVE_FORMATS, + DEFAULT_ARCHIVE_OPTIONS, + type ArchiveBuildOptions, + type ArchiveFormat, +} from "../lib/archiveOptions"; + +export { + ARCHIVE_FORMATS, + DEFAULT_ARCHIVE_OPTIONS, + type ArchiveBuildOptions, + type ArchiveFormat, }; export type ArchiveTranscriptsOptions = { diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -62,8 +62,9 @@ import { readVideoFiles, type IndexTranscript, } from "../lib/videoStatus"; +import { loadAvailability } from "../lib/availability-server"; -const SCHEMA_VERSION = 5; +const SCHEMA_VERSION = 6; type IndexKey = [string, string, string]; type ChannelKey = [string, string, string]; @@ -654,6 +655,21 @@ export async function buildIndex({ let buffer: DisplaySummary[] = []; let total = 0; + // Build a lookup from indexKey → isDeleted by reading each video's + // availability.json sidecar. Keyed off mtimes, which holds the canonical + // (channelSlug, videoDir) → indexKey mapping; only "deleted" entries get + // recorded so lookup defaults to "not deleted". + const deletedByIndexKey = new Set<string>(); + for (const { key, value } of mtimes.getRange()) { + const pk = key as PathKey; + const ik = (value as MtimeRecord).indexKey; + const videoDir = path.join(channelsDir, pk[0], "data", pk[1]); + const availability = await loadAvailability(videoDir); + if (availability?.availability === "deleted") { + deletedByIndexKey.add(`${ik[0]}\x00${ik[1]}\x00${ik[2]}`); + } + } + const flushPage = async () => { if (buffer.length === 0) return; const p = path.join(summariesDir, pageFileName(pageIndex)); @@ -662,12 +678,16 @@ export async function buildIndex({ pageIndex++; }; - for (const { value } of sums.getRange({ reverse: true })) { + for (const { key, value } of sums.getRange({ reverse: true })) { + const ik = key as IndexKey; const s = value as TranscriptSummary; if (s.channel) { channelCounts.set(s.channel, (channelCounts.get(s.channel) ?? 0) + 1); } - buffer.push(toDisplaySummary(s)); + const isDeleted = deletedByIndexKey.has( + `${ik[0]}\x00${ik[1]}\x00${ik[2]}`, + ); + buffer.push(toDisplaySummary(s, { isDeleted })); total++; if (buffer.length >= pageSize) await flushPage(); } diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts @@ -1,6 +1,7 @@ import path from "node:path"; import type { Dirent } from "node:fs"; import { readdir, readFile, rename, writeFile } from "node:fs/promises"; +import pLimit from "p-limit"; import { readArchive } from "../lib/archive"; import { isVideoDownloaded, @@ -8,11 +9,21 @@ import { readVideoFiles, type VideoFiles, } from "../lib/videoStatus"; +import { + AVAILABILITY_VALUES, + type Availability, +} from "../lib/availability"; +import { loadAvailability } from "../lib/availability-server"; import type { Paths } from "../lib/paths"; import { extractVideoId, isRumbleUrl } from "../ytdlp/runYtdlp"; import { loadFailedTranscriptions } from "./failedTranscriptions"; import { readChannelConfig } from "./channels"; +export type AvailabilitySnapshot = { + byStatus: Record<Availability, string[]>; + unchecked: string[]; +}; + export type ChannelSnapshot = { generatedAt: string; totals: { @@ -32,10 +43,33 @@ export type ChannelSnapshot = { duplicateDirs: string[]; }; undownloadedIds: string[]; + availability?: AvailabilitySnapshot; }; +function emptyAvailability(): AvailabilitySnapshot { + const byStatus = {} as Record<Availability, string[]>; + for (const v of AVAILABILITY_VALUES) byStatus[v] = []; + return { byStatus, unchecked: [] }; +} + +export function normalizeAvailability( + raw: Partial<AvailabilitySnapshot> | undefined, +): AvailabilitySnapshot { + const out = emptyAvailability(); + if (!raw) return out; + if (raw.byStatus) { + for (const v of AVAILABILITY_VALUES) { + out.byStatus[v] = raw.byStatus[v] ?? []; + } + } + if (raw.unchecked) out.unchecked = raw.unchecked; + return out; +} + export const SNAPSHOT_FILENAME = "snapshot.json"; +const SNAPSHOT_VIDEO_CONCURRENCY = 16; + export function snapshotPath(paths: Paths, slug: string): string { return path.join(paths.channelsDir, slug, SNAPSHOT_FILENAME); } @@ -89,27 +123,31 @@ export async function generateChannelSnapshot( .map((d) => d.name); const wantRumbleIndex = urls.some(isRumbleUrl); + const limit = pLimit(SNAPSHOT_VIDEO_CONCURRENCY); const perVideo = await Promise.all( - videoDirNames.map(async (id) => { - const dir = path.join(dataDir, id); - const files = await readVideoFiles(dir, { checkUntranscribable: true }); - let webpageUrl: string | null = null; - if (wantRumbleIndex && files.hasMeta) { - try { - const raw = await readFile( - path.join(dir, "metadata.info.json"), - "utf8", - ); - const parsed = JSON.parse(raw); - if (typeof parsed?.webpage_url === "string") { - webpageUrl = parsed.webpage_url; + videoDirNames.map((id) => + limit(async () => { + const dir = path.join(dataDir, id); + const files = await readVideoFiles(dir, { checkUntranscribable: true }); + let webpageUrl: string | null = null; + if (wantRumbleIndex && files.hasMeta) { + try { + const raw = await readFile( + path.join(dir, "metadata.info.json"), + "utf8", + ); + const parsed = JSON.parse(raw); + if (typeof parsed?.webpage_url === "string") { + webpageUrl = parsed.webpage_url; + } + } catch { + // ignore } - } catch { - // ignore } - } - return { id, files, webpageUrl }; - }), + const availability = await loadAvailability(dir); + return { id, files, webpageUrl, availability }; + }), + ), ); const filesById = new Map<string, VideoFiles>(); @@ -158,6 +196,18 @@ export async function generateChannelSnapshot( } } + const availability = emptyAvailability(); + for (const v of perVideo) { + if (!v.files.hasMeta) continue; + if (v.availability) { + availability.byStatus[v.availability.availability].push(v.id); + } else { + availability.unchecked.push(v.id); + } + } + for (const v of AVAILABILITY_VALUES) availability.byStatus[v].sort(); + availability.unchecked.sort(); + const missingFromArchive: string[] = []; for (const id of archive.ids) { if (!filesById.has(id)) missingFromArchive.push(id); @@ -195,6 +245,7 @@ export async function generateChannelSnapshot( duplicateDirs: [], }, undownloadedIds, + availability, }; await writeChannelSnapshot(paths, slug, snapshot); diff --git a/common/controller/checkAvailability.ts b/common/controller/checkAvailability.ts @@ -0,0 +1,221 @@ +import path from "node:path"; +import { readdir, readFile } from "node:fs/promises"; +import { execa } from "execa"; +import pLimit from "p-limit"; +import { + AVAILABILITY_VALUES, + availabilityFromJsonField, + parseUnavailableFromStderr, + type Availability, + type AvailabilityRecord, +} from "../lib/availability"; +import { loadAvailability, writeAvailability } from "../lib/availability-server"; +import { readChannelConfig } from "./channels"; +import type { Paths } from "../lib/paths"; + +export type CheckAvailabilityMode = + | "resume" + | "recheck-non-deleted" + | "recheck-all"; + +export const CHECK_AVAILABILITY_MODES: CheckAvailabilityMode[] = [ + "resume", + "recheck-non-deleted", + "recheck-all", +]; + +export type CheckAvailabilityOpts = { + channelSlug: string; + paths: Paths; + mode: CheckAvailabilityMode; + concurrency?: number; + onLog?: (msg: string) => void; + signal?: AbortSignal; +}; + +export type CheckAvailabilityResult = { + attempted: number; + checked: number; + skipped: number; + byStatus: Record<Availability, number>; +}; + +function emptyByStatus(): Record<Availability, number> { + return { + public: 0, + unlisted: 0, + private: 0, + members_only: 0, + needs_auth: 0, + deleted: 0, + error: 0, + }; +} + +async function readWebpageUrl(videoDir: string): Promise<string | null> { + try { + const raw = await readFile( + path.join(videoDir, "metadata.info.json"), + "utf8", + ); + const parsed = JSON.parse(raw); + if (typeof parsed?.webpage_url === "string") return parsed.webpage_url; + return null; + } catch { + return null; + } +} + +function parseStdoutJson(stdout: string): unknown | null { + // yt-dlp --dump-json emits one JSON object per video; for a single URL we + // expect one line. If something else printed warnings to stdout, the JSON + // is the last non-empty line. + const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + return JSON.parse(lines[i]); + } catch { + continue; + } + } + return null; +} + +export async function runAvailabilityCheck({ + channelSlug, + paths, + mode, + concurrency, + onLog, + signal, +}: CheckAvailabilityOpts): Promise<CheckAvailabilityResult> { + const log = onLog ?? ((m: string) => console.log(m)); + const channelDir = path.join(paths.channelsDir, channelSlug); + const dataDir = path.join(channelDir, "data"); + const config = await readChannelConfig(paths, channelSlug); + const cookieArgs = + config?.cookiesFromBrowser + ? ["--cookies-from-browser", config.cookiesFromBrowser] + : []; + const extraArgs = config?.ytdlpExtraArgs ?? []; + + const limit = pLimit(concurrency && concurrency > 0 ? Math.floor(concurrency) : 1); + + const videoDirs = await readdir(dataDir).catch(() => [] as string[]); + + let attempted = 0; + let checked = 0; + let skipped = 0; + const byStatus = emptyByStatus(); + + log( + `Availability check (${mode}): scanning ${videoDirs.length} video dir(s) under ${dataDir}`, + ); + + await Promise.all( + videoDirs.map((id) => + limit(async () => { + if (signal?.aborted) { + skipped++; + return; + } + const videoDir = path.join(dataDir, id); + const existing = await loadAvailability(videoDir); + if (mode === "resume" && existing) { + skipped++; + return; + } + if ( + mode === "recheck-non-deleted" && + existing?.availability === "deleted" + ) { + skipped++; + return; + } + + const url = await readWebpageUrl(videoDir); + if (!url) { + skipped++; + log(`Skip ${id}: no webpage_url in metadata.info.json`); + return; + } + + attempted++; + const args = [ + "--dump-json", + "--skip-download", + "--no-warnings", + ...cookieArgs, + ...extraArgs, + "--", + url, + ]; + + const result = await execa(paths.ytdlpBin, args, { + cwd: channelDir, + cancelSignal: signal, + all: false, + buffer: true, + reject: false, + }).catch((err: unknown) => { + return { + exitCode: 1, + stdout: "", + stderr: (err as Error)?.message ?? String(err), + } as { exitCode: number | null; stdout: string; stderr: string }; + }); + + if (signal?.aborted) { + skipped++; + return; + } + + let availability: Availability; + let error: string | undefined; + if (result.exitCode === 0) { + const parsed = parseStdoutJson(String(result.stdout ?? "")); + if (parsed && typeof parsed === "object") { + availability = availabilityFromJsonField( + (parsed as { availability?: unknown }).availability, + ); + } else { + availability = "public"; + } + } else { + const stderr = String(result.stderr ?? ""); + availability = parseUnavailableFromStderr(stderr); + if (availability === "error") { + // Keep a short tail of stderr to aid diagnosis without bloating + // the sidecar. + const trimmed = stderr.trim().split("\n").slice(-3).join("\n"); + if (trimmed) error = trimmed; + } + } + + const record: AvailabilityRecord = { + checkedAt: new Date().toISOString(), + availability, + webpageUrl: url, + }; + if (error) record.error = error; + + await writeAvailability(videoDir, record); + checked++; + byStatus[availability]++; + log(`Check ${id}: ${availability}`); + }), + ), + ); + + // Type guard: every key in AVAILABILITY_VALUES has been incremented above. + void AVAILABILITY_VALUES; + + const summary = AVAILABILITY_VALUES.map((v) => `${v}=${byStatus[v]}`).join( + " ", + ); + log( + `Availability check done. attempted=${attempted} checked=${checked} skipped=${skipped} (${summary}).`, + ); + + return { attempted, checked, skipped, byStatus }; +} diff --git a/common/lib/archiveOptions.ts b/common/lib/archiveOptions.ts @@ -0,0 +1,31 @@ +// Client-safe archive option types and defaults. The archive controller +// (archiveTranscripts.ts) pulls in node:fs/promises and execa, which makes +// it unsafe to import from "use client" files; the build-page UI needs the +// type and the defaults, so they live here as a pure module. + +export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip"; + +export const ARCHIVE_FORMATS: ReadonlyArray<ArchiveFormat> = [ + "tar.gz", + "tar.xz", + "zip", +]; + +export type ArchiveBuildOptions = { + format: ArchiveFormat; + // 1 (fastest, biggest) … 9 (slowest, smallest). Common to all three formats. + compressionLevel: number; + // When false, archive entries are stripped to { version, source, cues }. + // Per-video addressability still comes from the file path. + includeMetadata: boolean; + // When true, cues files are written with 2-space indentation. Channel + // manifests are always indented. + prettyPrint: boolean; +}; + +export const DEFAULT_ARCHIVE_OPTIONS: ArchiveBuildOptions = { + format: "tar.gz", + compressionLevel: 6, + includeMetadata: true, + prettyPrint: false, +}; diff --git a/common/lib/availability-server.ts b/common/lib/availability-server.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import { readFile, rename, writeFile } from "node:fs/promises"; +import { + AVAILABILITY_FILENAME, + AVAILABILITY_VALUES, + type AvailabilityRecord, +} from "./availability"; + +export function availabilityPath(videoDir: string): string { + return path.join(videoDir, AVAILABILITY_FILENAME); +} + +export async function loadAvailability( + videoDir: string, +): Promise<AvailabilityRecord | null> { + try { + const raw = await readFile(availabilityPath(videoDir), "utf8"); + const parsed = JSON.parse(raw) as Partial<AvailabilityRecord>; + if ( + typeof parsed?.availability === "string" && + (AVAILABILITY_VALUES as string[]).includes(parsed.availability) && + typeof parsed.checkedAt === "string" + ) { + return parsed as AvailabilityRecord; + } + return null; + } catch { + return null; + } +} + +export async function writeAvailability( + videoDir: string, + record: AvailabilityRecord, +): Promise<void> { + const file = availabilityPath(videoDir); + const tmp = `${file}.tmp-${process.pid}`; + await writeFile(tmp, JSON.stringify(record, null, 2) + "\n"); + await rename(tmp, file); +} diff --git a/common/lib/availability.ts b/common/lib/availability.ts @@ -0,0 +1,85 @@ +// Client-safe types and constants. No node-only imports — the editor's +// Diagnostics stage component is a "use client" file that pulls this in. +// Server-only I/O lives in availability-server.ts. + +export type Availability = + | "public" + | "unlisted" + | "private" + | "members_only" + | "needs_auth" + | "deleted" + | "error"; + +export const AVAILABILITY_VALUES: Availability[] = [ + "public", + "unlisted", + "private", + "members_only", + "needs_auth", + "deleted", + "error", +]; + +export type AvailabilityRecord = { + checkedAt: string; + availability: Availability; + error?: string; + webpageUrl?: string; +}; + +export const AVAILABILITY_FILENAME = "availability.json"; + +// Map yt-dlp's stderr text to an Availability. yt-dlp does not expose a +// stable machine-readable reason on failure, so we pattern-match the human +// messages it emits across YouTube, Rumble, and a few other extractors. +export function parseUnavailableFromStderr(stderr: string): Availability { + const s = stderr.toLowerCase(); + if ( + /private video/.test(s) || + /this video is private/.test(s) + ) { + return "private"; + } + if ( + /members[- ]only/.test(s) || + /join this channel/.test(s) || + /join channel/.test(s) + ) { + return "members_only"; + } + if ( + /age[- ]restricted/.test(s) || + /sign in to confirm your age/.test(s) || + /confirm your age/.test(s) + ) { + return "needs_auth"; + } + if ( + /video unavailable/.test(s) || + /has been removed/.test(s) || + /removed by the uploader/.test(s) || + /account .* terminated/.test(s) || + /account .* has been terminated/.test(s) || + /this video has been removed/.test(s) || + /content isn't available/.test(s) || + /no longer available/.test(s) + ) { + return "deleted"; + } + return "error"; +} + +// Map the `availability` field that yt-dlp puts in --dump-json output to our +// enum. yt-dlp uses: public, unlisted, needs_auth, premium_only, subscriber_only, +// public_unlisted (rare), private, etc. +export function availabilityFromJsonField(value: unknown): Availability { + if (typeof value !== "string") return "public"; + const v = value.toLowerCase(); + if (v === "public") return "public"; + if (v === "unlisted" || v === "public_unlisted") return "unlisted"; + if (v === "private") return "private"; + if (v === "subscriber_only" || v === "premium_only") return "members_only"; + if (v === "needs_auth") return "needs_auth"; + return "public"; +} diff --git a/common/lib/transcripts-server.ts b/common/lib/transcripts-server.ts @@ -67,7 +67,10 @@ export function summarize( }; } -export function toDisplaySummary(t: TranscriptSummary): DisplaySummary { +export function toDisplaySummary( + t: TranscriptSummary, + extras: { isDeleted?: boolean } = {}, +): DisplaySummary { return { slug: t.slug, id: t.id, @@ -79,6 +82,7 @@ export function toDisplaySummary(t: TranscriptSummary): DisplaySummary { channel: t.channel, isLivestream: t.isLivestream, ageRestricted: t.ageRestricted, + isDeleted: extras.isDeleted === true, platform: t.platform, webpageUrl: t.webpageUrl, }; diff --git a/common/lib/transcripts.ts b/common/lib/transcripts.ts @@ -32,6 +32,7 @@ export type DisplaySummary = { channel: string; isLivestream: boolean; ageRestricted: boolean; + isDeleted: boolean; platform: Platform; webpageUrl: string; }; diff --git a/common/lib/videoStatus.ts b/common/lib/videoStatus.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { readdir, readFile } from "node:fs/promises"; +import { readdir, readFile, stat } from "node:fs/promises"; export type VideoFiles = { hasMeta: boolean; @@ -19,6 +19,12 @@ export const WHISPER_FILENAME = "transcript.json"; export const CUES_JSON_FILENAME = "transcript.cues.json"; export const META_FILENAME = "metadata.info.json"; +// Whisper "empty transcription" outputs include systeminfo/model/params/result +// keys around an empty `transcription: []`, so they can be up to ~620B in +// practice. Real transcripts observed start at ~2.9KB. A 4KB cutoff lets us +// stat() instead of reading multi-MB JSON for every transcribed video. +const EMPTY_WHISPER_MAX_BYTES = 4096; + export async function readVideoFiles( videoDir: string, opts: { checkUntranscribable?: boolean } = {}, @@ -36,11 +42,16 @@ export async function readVideoFiles( ); let isUntranscribable = false; if (hasWhisper && opts.checkUntranscribable) { + const whisperPath = path.join(videoDir, WHISPER_FILENAME); try { - const raw = await readFile(path.join(videoDir, WHISPER_FILENAME), "utf8"); - const parsed = JSON.parse(raw) as { transcription?: unknown[] }; - isUntranscribable = - Array.isArray(parsed.transcription) && parsed.transcription.length === 0; + const st = await stat(whisperPath); + if (st.size <= EMPTY_WHISPER_MAX_BYTES) { + const raw = await readFile(whisperPath, "utf8"); + const parsed = JSON.parse(raw) as { transcription?: unknown[] }; + isUntranscribable = + Array.isArray(parsed.transcription) && + parsed.transcription.length === 0; + } } catch { isUntranscribable = false; } diff --git a/editor/app/build/components/ArchiveOptionsControl.tsx b/editor/app/build/components/ArchiveOptionsControl.tsx @@ -4,7 +4,7 @@ import { ARCHIVE_FORMATS, type ArchiveBuildOptions, type ArchiveFormat, -} from "yt-dlp-transcript-common/controller/archiveTranscripts"; +} from "yt-dlp-transcript-common/lib/archiveOptions"; type Props = { value: ArchiveBuildOptions; diff --git a/editor/app/build/components/BuildButtons.tsx b/editor/app/build/components/BuildButtons.tsx @@ -5,7 +5,7 @@ import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActio import { DEFAULT_ARCHIVE_OPTIONS, type ArchiveBuildOptions, -} from "yt-dlp-transcript-common/controller/archiveTranscripts"; +} from "yt-dlp-transcript-common/lib/archiveOptions"; import { QueueControl } from "../../components/QueueControl"; import { cancelJobAction } from "../../jobs/actions"; import { diff --git a/editor/app/channels/[slug]/availabilityActions.ts b/editor/app/channels/[slug]/availabilityActions.ts @@ -0,0 +1,53 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + CHECK_AVAILABILITY_MODES, + runAvailabilityCheck, + type CheckAvailabilityMode, +} from "yt-dlp-transcript-common/controller/checkAvailability"; +import { + runManagedFunction, + type StreamActionResult, +} from "yt-dlp-transcript-common/jobs/streamCommand"; + +function defaultQueueKey(slug: string): string { + return `channel:${slug}`; +} + +function sanitizeConcurrency(c: number | undefined): number | undefined { + if (c === undefined) return undefined; + if (!Number.isFinite(c) || c < 1) return undefined; + return Math.floor(c); +} + +export async function checkAvailabilityAction( + slug: string, + mode: CheckAvailabilityMode, + queueKey?: string, + concurrency?: number, +): Promise<StreamActionResult> { + if (!(CHECK_AVAILABILITY_MODES as string[]).includes(mode)) { + return { ok: false, error: `Unsupported mode: ${mode}` }; + } + const paths = getPaths(); + const limit = sanitizeConcurrency(concurrency); + return runManagedFunction({ + kind: "check-availability", + queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), + paths, + channelSlug: slug, + fn: async (onLog, signal) => { + await runAvailabilityCheck({ + channelSlug: slug, + paths, + mode, + concurrency: limit, + onLog, + signal, + }); + revalidatePath(`/channels/${slug}`); + }, + }); +} diff --git a/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx b/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx @@ -1,7 +1,15 @@ "use client"; import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { + AVAILABILITY_VALUES, + type Availability, +} from "yt-dlp-transcript-common/lib/availability"; +import type { AvailabilitySnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { verifyAction, type VerifyResult } from "../../whisperActions"; +import { checkAvailabilityAction } from "../../availabilityActions"; +import { cancelJobAction } from "../../../../jobs/actions"; import { gotoVideoAction } from "../../../actions"; import { VideoIdList } from "../VideoIdList"; @@ -19,6 +27,7 @@ type Props = { missingFromArchiveIds: string[]; duplicateDirIds: string[]; totals: { videos: number; transcribed: number; downloaded: number }; + availability: AvailabilitySnapshot; }; export function DiagnosticsStage({ @@ -28,6 +37,7 @@ export function DiagnosticsStage({ missingFromArchiveIds, duplicateDirIds, totals, + availability, }: Props) { const buckets: DiagnosticBucket[] = [ { @@ -102,6 +112,14 @@ export function DiagnosticsStage({ </button> </form> </div> + <div className="flex flex-col gap-2"> + <Heading + title="Availability checks" + desc="Probe yt-dlp to detect videos that have been deleted, made private, or restricted by the uploader. Results write to channels/<slug>/data/<id>/availability.json." + /> + <AvailabilityButtons slug={slug} /> + <AvailabilitySummary slug={slug} availability={availability} /> + </div> <div className="flex flex-col gap-2" aria-label="channel health"> <div> <h3 className="text-base font-semibold">Channel health</h3> @@ -227,3 +245,131 @@ function Heading({ title, desc }: { title: string; desc: string }) { </div> ); } + +function AvailabilityButtons({ slug }: { slug: string }) { + return ( + <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:gap-4"> + <StreamActionLog + trigger={() => checkAvailabilityAction(slug, "resume")} + cancelAction={cancelJobAction} + buttonLabel="Resume checks" + runningLabel="Checking…" + label="Resume availability checks" + /> + <StreamActionLog + trigger={() => checkAvailabilityAction(slug, "recheck-non-deleted")} + cancelAction={cancelJobAction} + buttonLabel="Recheck non-deleted" + runningLabel="Rechecking…" + label="Recheck non-deleted availability" + /> + <StreamActionLog + trigger={() => checkAvailabilityAction(slug, "recheck-all")} + cancelAction={cancelJobAction} + buttonLabel="Recheck all" + runningLabel="Rechecking…" + label="Recheck all availability" + /> + </div> + ); +} + +const AVAILABILITY_LABELS: Record<Availability, string> = { + public: "Public", + unlisted: "Unlisted", + private: "Private", + members_only: "Members-only", + needs_auth: "Needs auth", + deleted: "Deleted", + error: "Error", +}; + +// Statuses whose IDs we surface as clickable lists; the rest are shown as +// counts only to keep the page from drowning in healthy videos. +const AVAILABILITY_LIST_STATUSES: Availability[] = [ + "deleted", + "private", + "members_only", + "needs_auth", + "error", +]; + +function AvailabilitySummary({ + slug, + availability, +}: { + slug: string; + availability: AvailabilitySnapshot; +}) { + const total = + AVAILABILITY_VALUES.reduce( + (acc, v) => acc + availability.byStatus[v].length, + 0, + ) + availability.unchecked.length; + if (total === 0) { + return ( + <p + aria-label="availability summary empty" + className="text-sm text-zinc-600 dark:text-zinc-400" + > + No availability data yet. Run a check above. + </p> + ); + } + const counts = AVAILABILITY_VALUES.map((v) => ({ + v, + n: availability.byStatus[v].length, + })).filter((x) => x.n > 0); + const listed = AVAILABILITY_LIST_STATUSES.filter( + (v) => availability.byStatus[v].length > 0, + ); + return ( + <div + className="flex flex-col gap-3" + aria-label="availability summary" + > + <div className="flex flex-wrap gap-x-3 gap-y-1 text-sm text-zinc-600 dark:text-zinc-400"> + {counts.map(({ v, n }) => ( + <span key={v} aria-label={`availability ${v} count`}> + <span className="font-medium text-zinc-800 dark:text-zinc-200"> + {AVAILABILITY_LABELS[v]} + </span>{" "} + {n} + </span> + ))} + {availability.unchecked.length > 0 && ( + <span aria-label="availability unchecked count"> + <span className="font-medium text-zinc-800 dark:text-zinc-200"> + Unchecked + </span>{" "} + {availability.unchecked.length} + </span> + )} + </div> + {listed.length > 0 && ( + <div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> + {listed.map((v) => ( + <div + key={v} + className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3" + > + <div> + <h4 className="text-sm font-semibold"> + {AVAILABILITY_LABELS[v]} ({availability.byStatus[v].length}) + </h4> + </div> + <VideoIdList + slug={slug} + ids={availability.byStatus[v]} + ariaLabel={`availability ${v} list`} + emptyAriaLabel={`availability ${v} empty`} + emptyMessage="None" + itemAriaLabel={(id) => `availability ${v} ${id}`} + /> + </div> + ))} + </div> + )} + </div> + ); +} diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -59,6 +59,7 @@ const JOB_KIND_TO_STAGE: Record<string, StageId> = { "whisper-video": "transcribe", "clean-audio-transcribed": "cleanup", "clean-extra-audio-formats": "cleanup", + "check-availability": "diagnostics", }; function pluralize(n: number, singular: string, plural?: string): string { @@ -278,19 +279,21 @@ export function computeStageStatuses( }), }; + const diagnosticsRunning = runningByStage.has("diagnostics"); const diagnostics: StageStatus = { id: "diagnostics", title: "Diagnostics", pending: diagnosticsPending, failed: 0, - running: false, + running: diagnosticsRunning, defaultOpen: true, - summary: - diagnosticsPending > 0 + summary: diagnosticsRunning + ? "Running…" + : diagnosticsPending > 0 ? pluralize(diagnosticsPending, "anomaly", "anomalies") : "All clear.", tone: pickTone({ - running: false, + running: diagnosticsRunning, pending: diagnosticsPending, failed: 0, fallback: "ok", diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { generateChannelSnapshot, + normalizeAvailability, readChannelSnapshot, } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; @@ -77,6 +78,7 @@ export default async function ChannelDetailPage({ const buckets = normalizeBuckets(snapshot.buckets); const undownloadedIds = snapshot.undownloadedIds ?? []; + const availability = normalizeAvailability(snapshot.availability); const stages = computeStageStatuses({ snapshot, failedVideoIds, @@ -215,6 +217,7 @@ export default async function ChannelDetailPage({ defaultOpen={stages.diagnostics.defaultOpen} tone={stages.diagnostics.tone} pending={stages.diagnostics.pending} + running={stages.diagnostics.running} > <DiagnosticsStage slug={slug} @@ -223,6 +226,7 @@ export default async function ChannelDetailPage({ missingFromArchiveIds={buckets.missingFromArchive} duplicateDirIds={buckets.duplicateDirs} totals={snapshot.totals} + availability={availability} /> </PipelineStageCard> diff --git a/editor/e2e/availability.spec.ts b/editor/e2e/availability.spec.ts @@ -0,0 +1,176 @@ +import { stat } from "node:fs/promises"; +import { test, expect } from "@playwright/test"; +import { pathExists, readJson, resetData, resolvePath } from "./helpers"; + +const CHANNEL = "availability-test"; +const FIXTURE = "availability-baseline"; +const VIDEOS = [ + { id: "vidpublic1", expected: "public" }, + { id: "vidpublic2", expected: "public" }, + { id: "viddeleted1", expected: "deleted" }, + { id: "vidprivate1", expected: "private" }, + { id: "vidmembers1", expected: "members_only" }, + { id: "vidneedsauth1", expected: "needs_auth" }, +]; + +type AvailabilityFile = { + checkedAt: string; + availability: string; + webpageUrl?: string; + error?: string; +}; + +function availabilityPath(id: string): string { + return `test-transcripts/channels/${CHANNEL}/data/${id}/availability.json`; +} + +async function readAvailability(id: string): Promise<AvailabilityFile> { + return readJson<AvailabilityFile>(availabilityPath(id)); +} + +test("resume checks classify each fixture video into its expected bucket", async ({ + page, +}) => { + await resetData(FIXTURE); + await page.goto(`/channels/${CHANNEL}`); + await page.getByRole("button", { name: "Resume checks" }).click(); + const log = page.getByLabel("Resume availability checks output"); + await expect(log).toContainText("attempted=6", { timeout: 20_000 }); + await expect(log).toContainText("Availability check done"); + + for (const v of VIDEOS) { + expect(await pathExists(availabilityPath(v.id))).toBe(true); + const record = await readAvailability(v.id); + expect(record.availability).toBe(v.expected); + expect(typeof record.checkedAt).toBe("string"); + } +}); + +test("resume is idempotent: a second run skips all videos", async ({ page }) => { + await resetData(FIXTURE); + await page.goto(`/channels/${CHANNEL}`); + await page.getByRole("button", { name: "Resume checks" }).click(); + const log = page.getByLabel("Resume availability checks output"); + await expect(log).toContainText("attempted=6", { timeout: 20_000 }); + + await page.getByRole("button", { name: "Resume checks" }).click(); + await expect(log).toContainText("attempted=0 checked=0 skipped=6", { + timeout: 20_000, + }); +}); + +test("recheck non-deleted skips deleted but advances other checkedAt", async ({ + page, +}) => { + await resetData(FIXTURE); + await page.goto(`/channels/${CHANNEL}`); + await page.getByRole("button", { name: "Resume checks" }).click(); + await expect( + page.getByLabel("Resume availability checks output"), + ).toContainText("attempted=6", { timeout: 20_000 }); + + const deletedBefore = await readAvailability("viddeleted1"); + const publicBefore = await readAvailability("vidpublic1"); + + // Wait long enough for ISO timestamps to differ. + await page.waitForTimeout(20); + + await page.getByRole("button", { name: "Recheck non-deleted" }).click(); + await expect( + page.getByLabel("Recheck non-deleted availability output"), + ).toContainText("attempted=5 checked=5 skipped=1", { timeout: 20_000 }); + + const deletedAfter = await readAvailability("viddeleted1"); + const publicAfter = await readAvailability("vidpublic1"); + expect(deletedAfter.checkedAt).toBe(deletedBefore.checkedAt); + expect(deletedAfter.availability).toBe("deleted"); + expect(publicAfter.checkedAt).not.toBe(publicBefore.checkedAt); + expect(publicAfter.availability).toBe("public"); +}); + +test("recheck all re-probes every video including deleted", async ({ page }) => { + await resetData(FIXTURE); + await page.goto(`/channels/${CHANNEL}`); + await page.getByRole("button", { name: "Resume checks" }).click(); + await expect( + page.getByLabel("Resume availability checks output"), + ).toContainText("attempted=6", { timeout: 20_000 }); + + const before = new Map<string, string>(); + for (const v of VIDEOS) { + const r = await readAvailability(v.id); + before.set(v.id, r.checkedAt); + } + + await page.waitForTimeout(20); + + await page.getByRole("button", { name: "Recheck all" }).click(); + await expect( + page.getByLabel("Recheck all availability output"), + ).toContainText("attempted=6 checked=6 skipped=0", { timeout: 20_000 }); + + for (const v of VIDEOS) { + const after = await readAvailability(v.id); + expect(after.checkedAt).not.toBe(before.get(v.id)); + expect(after.availability).toBe(v.expected); + } +}); + +test("build index marks deleted videos in the export summaries", async ({ + page, +}) => { + await resetData(FIXTURE); + await page.goto(`/channels/${CHANNEL}`); + await page.getByRole("button", { name: "Resume checks" }).click(); + await expect( + page.getByLabel("Resume availability checks output"), + ).toContainText("attempted=6", { timeout: 20_000 }); + + // Add a transcript for the public video so it appears in the index. + // (buildIndex requires a transcript.en.vtt or transcript.json.) + const { writeFile } = await import("node:fs/promises"); + await writeFile( + resolvePath( + `test-transcripts/channels/${CHANNEL}/data/vidpublic1/transcript.en.vtt`, + ), + "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nhello\n", + ); + await writeFile( + resolvePath( + `test-transcripts/channels/${CHANNEL}/data/viddeleted1/transcript.en.vtt`, + ), + "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nhello deleted\n", + ); + + await page.goto("/build"); + await page.getByRole("button", { name: "Build index" }).click(); + await expect(page.getByLabel("Build index output")).toContainText("Done in", { + timeout: 30_000, + }); + + const pageJson = await readJson< + Array<{ id: string; isDeleted: boolean }> + >("test-transcripts/.export-public/summaries/page-0000.json"); + const byId = new Map(pageJson.map((s) => [s.id, s])); + expect(byId.get("vidpublic1")?.isDeleted).toBe(false); + expect(byId.get("viddeleted1")?.isDeleted).toBe(true); +}); + +// Ensure the on-disk file matches the in-memory test data shape so future +// schema changes break this test rather than silently breaking the search UI. +test("availability.json shape matches the type contract", async ({ page }) => { + await resetData(FIXTURE); + await page.goto(`/channels/${CHANNEL}`); + await page.getByRole("button", { name: "Resume checks" }).click(); + await expect( + page.getByLabel("Resume availability checks output"), + ).toContainText("attempted=6", { timeout: 20_000 }); + + const record = await readAvailability("vidpublic1"); + expect(record.availability).toBe("public"); + expect(record.webpageUrl).toBe("https://www.youtube.com/watch?v=vidpublic1"); + expect(record.error).toBeUndefined(); + // The sidecar should be tiny. + const stats = await stat(resolvePath(availabilityPath("vidpublic1"))); + expect(stats.size).toBeLessThan(500); +}); diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs @@ -170,6 +170,50 @@ async function main() { return; } + // Availability probe: --dump-json --skip-download -- <url> + // Sentinels in the URL select the response so a fixture can deterministically + // produce each availability bucket. + if (has("--dump-json") && has("--skip-download")) { + const url = lastNonFlag() ?? ""; + const lower = url.toLowerCase(); + await appendFile("fake-ytdlp.invocations", `dump-json:${url}\n`); + if (lower.includes("deleted")) { + process.stderr.write(`ERROR: [youtube] ${url}: Video unavailable\n`); + process.exit(1); + } + if (lower.includes("private")) { + process.stderr.write( + `ERROR: [youtube] ${url}: This video is private\n`, + ); + process.exit(1); + } + if (lower.includes("members")) { + process.stderr.write( + `ERROR: [youtube] ${url}: Members-only content\n`, + ); + process.exit(1); + } + if (lower.includes("needsauth") || lower.includes("agerestricted")) { + process.stderr.write( + `ERROR: [youtube] ${url}: Sign in to confirm your age\n`, + ); + process.exit(1); + } + if (lower.includes("brokenerror")) { + process.stderr.write(`ERROR: [youtube] ${url}: Some other failure\n`); + process.exit(1); + } + const id = urlIdYouTube(url) ?? "unknown"; + const payload = { + id, + title: `Synthetic ${id}`, + webpage_url: url, + availability: "public", + }; + process.stdout.write(`${JSON.stringify(payload)}\n`); + return; + } + const audioFmt = has("-x") ? arg("--audio-format") : undefined; const playlistFile = arg("-a"); diff --git a/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/config.json b/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/config.json @@ -0,0 +1,5 @@ +{ + "handling": "youtube", + "name": "Availability Test Channel", + "url": "https://www.youtube.com/@availability/videos" +} diff --git a/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/viddeleted1/metadata.info.json b/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/viddeleted1/metadata.info.json @@ -0,0 +1,15 @@ +{ + "id": "viddeleted1", + "title": "Deleted video 1", + "channel": "Availability Test Channel", + "uploader": "Availability Test Channel", + "upload_date": "20240103", + "duration": 80, + "description": "Fixture video that should classify as deleted.", + "is_live": false, + "was_live": false, + "live_status": "not_live", + "age_limit": 0, + "extractor_key": "Youtube", + "webpage_url": "https://www.youtube.com/watch?v=viddeleted1" +} diff --git a/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidmembers1/metadata.info.json b/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidmembers1/metadata.info.json @@ -0,0 +1,15 @@ +{ + "id": "vidmembers1", + "title": "Members-only video 1", + "channel": "Availability Test Channel", + "uploader": "Availability Test Channel", + "upload_date": "20240105", + "duration": 100, + "description": "Fixture video that should classify as members_only.", + "is_live": false, + "was_live": false, + "live_status": "not_live", + "age_limit": 0, + "extractor_key": "Youtube", + "webpage_url": "https://www.youtube.com/watch?v=vidmembers1" +} diff --git a/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidneedsauth1/metadata.info.json b/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidneedsauth1/metadata.info.json @@ -0,0 +1,15 @@ +{ + "id": "vidneedsauth1", + "title": "Age-restricted video 1", + "channel": "Availability Test Channel", + "uploader": "Availability Test Channel", + "upload_date": "20240106", + "duration": 110, + "description": "Fixture video that should classify as needs_auth.", + "is_live": false, + "was_live": false, + "live_status": "not_live", + "age_limit": 0, + "extractor_key": "Youtube", + "webpage_url": "https://www.youtube.com/watch?v=vidneedsauth1" +} diff --git a/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidprivate1/metadata.info.json b/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidprivate1/metadata.info.json @@ -0,0 +1,15 @@ +{ + "id": "vidprivate1", + "title": "Private video 1", + "channel": "Availability Test Channel", + "uploader": "Availability Test Channel", + "upload_date": "20240104", + "duration": 90, + "description": "Fixture video that should classify as private.", + "is_live": false, + "was_live": false, + "live_status": "not_live", + "age_limit": 0, + "extractor_key": "Youtube", + "webpage_url": "https://www.youtube.com/watch?v=vidprivate1" +} diff --git a/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidpublic1/metadata.info.json b/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidpublic1/metadata.info.json @@ -0,0 +1,15 @@ +{ + "id": "vidpublic1", + "title": "Public video 1", + "channel": "Availability Test Channel", + "uploader": "Availability Test Channel", + "upload_date": "20240101", + "duration": 60, + "description": "Public fixture video.", + "is_live": false, + "was_live": false, + "live_status": "not_live", + "age_limit": 0, + "extractor_key": "Youtube", + "webpage_url": "https://www.youtube.com/watch?v=vidpublic1" +} diff --git a/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidpublic2/metadata.info.json b/editor/e2e/fixtures/test-transcripts/availability-baseline/channels/availability-test/data/vidpublic2/metadata.info.json @@ -0,0 +1,15 @@ +{ + "id": "vidpublic2", + "title": "Public video 2", + "channel": "Availability Test Channel", + "uploader": "Availability Test Channel", + "upload_date": "20240102", + "duration": 70, + "description": "Another public fixture video.", + "is_live": false, + "was_live": false, + "live_status": "not_live", + "age_limit": 0, + "extractor_key": "Youtube", + "webpage_url": "https://www.youtube.com/watch?v=vidpublic2" +}