commit 17b7d14391a6c10a018b9f71fb97059aef4f6153
parent 348996a430126178141007a3ea851630b5e24a68
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 18 Jun 2026 23:25:02 -0400
Add quick availability check, targeted full check, and availability history
Quick availability check fetches the channel's current flat playlist (one
cheap --flat-playlist call) and diffs it against on-disk videos, flagging
those absent from the listing as "maybe missing" (persisted to a
maybe-missing.json sidecar, surfaced through the channel snapshot).
A "Full-check unexpected" button runs the per-video --dump-json availability
check on only the maybe-missing videos, skipping ones already known
deleted/private/members_only and re-checking unlisted/needs_auth — efficiently
resolving deleted vs. unlisted.
availability.json now carries an append-on-change history[] of
{availability, observedAt, source}, written via a shared recordAvailability
helper wired into the explicit check, metadata backfill, and download path
(download-observed flips append without disturbing the explicit-check fields).
Rendered as a timeline on the video page; quick-check UI lives in the
Diagnostics stage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
17 files changed, 816 insertions(+), 32 deletions(-)
diff --git a/common/controller/backfillAvailability.ts b/common/controller/backfillAvailability.ts
@@ -1,10 +1,10 @@
import path from "node:path";
import { readdir, readFile } from "node:fs/promises";
+import { availabilityFromJsonField } from "../lib/availability";
import {
- availabilityFromJsonField,
- type AvailabilityRecord,
-} from "../lib/availability";
-import { loadAvailability, writeAvailability } from "../lib/availability-server";
+ loadAvailability,
+ recordAvailability,
+} from "../lib/availability-server";
import type { Paths } from "../lib/paths";
export type BackfillAvailabilityOpts = {
@@ -57,14 +57,17 @@ export async function backfillAvailabilityFromMetadata({
skippedNoMeta++;
continue;
}
- const record: AvailabilityRecord = {
- checkedAt: new Date().toISOString(),
+ await recordAvailability(videoDir, {
availability: availabilityFromJsonField(parsed.availability),
- };
- if (typeof parsed.webpage_url === "string") {
- record.webpageUrl = parsed.webpage_url;
- }
- await writeAvailability(videoDir, record);
+ observedAt: new Date().toISOString(),
+ source: "backfill",
+ webpageUrl:
+ typeof parsed.webpage_url === "string" ? parsed.webpage_url : undefined,
+ }).catch((err) => {
+ log(
+ `Warn ${id}: failed to record availability history: ${(err as Error)?.message ?? String(err)}`,
+ );
+ });
wrote++;
}
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts
@@ -26,6 +26,7 @@ import { extractVideoId } from "../ytdlp/runYtdlp";
import { reconcileVideoDirs } from "./reconcileVideoDirs";
import { loadFailedTranscriptions } from "./failedTranscriptions";
import { readChannelConfig } from "./channels";
+import { loadMaybeMissing } from "./quickAvailabilityCheck";
export type AvailabilitySnapshot = {
byStatus: Record<Availability, string[]>;
@@ -77,6 +78,12 @@ export type ChannelSnapshot = {
undownloadedIds: string[];
excludedFromDownload?: ExcludedFromDownload;
availability?: AvailabilitySnapshot;
+ // Known videos absent from the channel's most recent fresh flat-playlist
+ // fetch (the quick availability check). The maybe-missing.json sidecar is the
+ // source of truth; this re-surfaces it so it survives snapshot regens. ids are
+ // intersected with on-disk dirs at generation time. Optional: older snapshots
+ // lack it; readers must default via normalizeMaybeMissing.
+ maybeMissing?: { ids: string[]; checkedAt: string };
// Estimated bytes each cleanup operation would reclaim, mirroring the bucket
// counts: transcribedWithAudio sums every audio.* file in those dirs (all are
// removed); multipleAudioFormats sums the non-target audio.* files (the target
@@ -134,6 +141,12 @@ export function normalizeAvailability(
return out;
}
+export function normalizeMaybeMissing(
+ raw: { ids?: string[]; checkedAt?: string } | undefined,
+): { ids: string[]; checkedAt: string } {
+ return { ids: raw?.ids ?? [], checkedAt: raw?.checkedAt ?? "" };
+}
+
export const SNAPSHOT_FILENAME = "snapshot.json";
const SNAPSHOT_VIDEO_CONCURRENCY = 16;
@@ -184,13 +197,15 @@ export async function generateChannelSnapshot(
/* ignore — the migration CLI can repair stragglers */
}
- const [dirEntries, urls, archive, failedListed, config] = await Promise.all([
- readdir(dataDir, { withFileTypes: true }).catch(() => [] as Dirent[]),
- readPlaylistUrls(playlistPath),
- readArchive(archivePath),
- loadFailedTranscriptions(paths, slug),
- readChannelConfig(paths, slug),
- ]);
+ const [dirEntries, urls, archive, failedListed, config, maybeMissingRecord] =
+ await Promise.all([
+ readdir(dataDir, { withFileTypes: true }).catch(() => [] as Dirent[]),
+ readPlaylistUrls(playlistPath),
+ readArchive(archivePath),
+ loadFailedTranscriptions(paths, slug),
+ readChannelConfig(paths, slug),
+ loadMaybeMissing(paths, slug),
+ ]);
const targetAudioFile = config?.audioFormat
? `audio.${config.audioFormat}`
: null;
@@ -422,6 +437,16 @@ export async function generateChannelSnapshot(
undownloadedIds.push(dirId);
}
+ // Re-surface the maybe-missing sidecar, intersecting with on-disk dirs so an
+ // id whose dir was deleted drops out.
+ const dirNameSet = new Set(videoDirNames);
+ const maybeMissing = maybeMissingRecord
+ ? {
+ ids: maybeMissingRecord.ids.filter((id) => dirNameSet.has(id)),
+ checkedAt: maybeMissingRecord.checkedAt,
+ }
+ : undefined;
+
const snapshot: ChannelSnapshot = {
generatedAt: new Date().toISOString(),
totals: {
@@ -447,6 +472,7 @@ export async function generateChannelSnapshot(
undownloadedIds,
excludedFromDownload,
availability,
+ ...(maybeMissing ? { maybeMissing } : {}),
cleanupBytes: {
transcribedWithAudio: transcribedWithAudioBytes,
multipleAudioFormats: multipleAudioFormatsBytes,
diff --git a/common/controller/checkAvailability.ts b/common/controller/checkAvailability.ts
@@ -4,12 +4,16 @@ import { execa } from "execa";
import pLimit from "p-limit";
import {
AVAILABILITY_VALUES,
+ EXCLUDED_FROM_DOWNLOAD,
availabilityFromJsonField,
parseUnavailableFromStderr,
type Availability,
- type AvailabilityRecord,
} from "../lib/availability";
-import { loadAvailability, writeAvailability } from "../lib/availability-server";
+import {
+ loadAvailability,
+ recordAvailability,
+ resolveEffectiveAvailability,
+} from "../lib/availability-server";
import { readChannelConfig } from "./channels";
import { resolveShardItems } from "./shard";
import type { Paths } from "../lib/paths";
@@ -35,6 +39,12 @@ export type CheckAvailabilityOpts = {
// When set, resolve + persist the shard slice and return immediately without
// checking anything. Backs the "Save shard" button.
saveShardOnly?: boolean;
+ // Restrict the scan to these data-dir ids (applied before shard slicing).
+ // Backs the "Full-check unexpected" / maybe-missing flow.
+ onlyIds?: ReadonlyArray<string>;
+ // Skip ids already known permanently gone (deleted / private / members_only,
+ // i.e. EXCLUDED_FROM_DOWNLOAD) — they will not change, so don't re-probe.
+ skipExpectedAbsent?: boolean;
onLog?: (msg: string) => void;
signal?: AbortSignal;
};
@@ -95,6 +105,8 @@ export async function runAvailabilityCheck({
shardTotal,
shardIndex,
saveShardOnly = false,
+ onlyIds,
+ skipExpectedAbsent = false,
onLog,
signal,
}: CheckAvailabilityOpts): Promise<CheckAvailabilityResult> {
@@ -106,7 +118,11 @@ export async function runAvailabilityCheck({
const limit = pLimit(concurrency && concurrency > 0 ? Math.floor(concurrency) : 1);
- const allDirs = await readdir(dataDir).catch(() => [] as string[]);
+ let allDirs = await readdir(dataDir).catch(() => [] as string[]);
+ if (onlyIds) {
+ const onlyIdSet = new Set(onlyIds);
+ allDirs = allDirs.filter((id) => onlyIdSet.has(id));
+ }
const shardResult = await resolveShardItems({
paths,
slug: channelSlug,
@@ -164,6 +180,14 @@ export async function runAvailabilityCheck({
skipped++;
return;
}
+ if (skipExpectedAbsent) {
+ const effective = await resolveEffectiveAvailability(videoDir);
+ if (effective && EXCLUDED_FROM_DOWNLOAD.includes(effective)) {
+ skipped++;
+ log(`Skip ${id}: already ${effective}`);
+ return;
+ }
+ }
const url = await readWebpageUrl(videoDir);
if (!url) {
@@ -223,14 +247,17 @@ export async function runAvailabilityCheck({
}
}
- const record: AvailabilityRecord = {
- checkedAt: new Date().toISOString(),
+ await recordAvailability(videoDir, {
availability,
+ observedAt: new Date().toISOString(),
+ source: "check",
+ error,
webpageUrl: url,
- };
- if (error) record.error = error;
-
- await writeAvailability(videoDir, record);
+ }).catch((err) => {
+ log(
+ `Warn ${id}: failed to record availability history: ${(err as Error)?.message ?? String(err)}`,
+ );
+ });
checked++;
byStatus[availability]++;
log(`Check ${id}: ${availability}`);
diff --git a/common/controller/quickAvailabilityCheck.ts b/common/controller/quickAvailabilityCheck.ts
@@ -0,0 +1,130 @@
+import path from "node:path";
+import { readdir, readFile, rename, writeFile } from "node:fs/promises";
+import { readChannelConfig } from "./channels";
+import { extractVideoId, fetchFlatPlaylistUrls } from "../ytdlp/runYtdlp";
+import type { Paths } from "../lib/paths";
+
+export const MAYBE_MISSING_FILENAME = "maybe-missing.json";
+
+export type MaybeMissingRecord = {
+ checkedAt: string;
+ freshPlaylistCount: number;
+ ids: string[];
+};
+
+function maybeMissingPath(paths: Paths, slug: string): string {
+ return path.join(paths.channelsDir, slug, MAYBE_MISSING_FILENAME);
+}
+
+export async function loadMaybeMissing(
+ paths: Paths,
+ slug: string,
+): Promise<MaybeMissingRecord | null> {
+ try {
+ const raw = await readFile(maybeMissingPath(paths, slug), "utf8");
+ const parsed = JSON.parse(raw) as Partial<MaybeMissingRecord>;
+ if (
+ typeof parsed?.checkedAt === "string" &&
+ Array.isArray(parsed.ids) &&
+ typeof parsed.freshPlaylistCount === "number"
+ ) {
+ return {
+ checkedAt: parsed.checkedAt,
+ freshPlaylistCount: parsed.freshPlaylistCount,
+ ids: parsed.ids.filter((id): id is string => typeof id === "string"),
+ };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export async function writeMaybeMissing(
+ paths: Paths,
+ slug: string,
+ record: MaybeMissingRecord,
+): Promise<void> {
+ const file = maybeMissingPath(paths, slug);
+ const tmp = `${file}.tmp-${process.pid}`;
+ await writeFile(tmp, JSON.stringify(record, null, 2) + "\n");
+ await rename(tmp, file);
+}
+
+export type QuickAvailabilityCheckResult = {
+ knownCount: number;
+ freshCount: number;
+ maybeMissingCount: number;
+ ids: string[];
+};
+
+// Cheap first pass: fetch the channel's current flat playlist (one yt-dlp
+// spawn, no per-video metadata) and diff it against the videos we already have
+// on disk. Any known video absent from the fresh listing is "maybe missing"
+// (deleted, made private, or unlisted — unlisted videos legitimately drop out
+// of channel listings). The result is persisted to maybe-missing.json so the
+// targeted full check can resolve which is which.
+export async function runQuickAvailabilityCheck({
+ channelSlug,
+ paths,
+ onLog,
+ signal,
+}: {
+ channelSlug: string;
+ paths: Paths;
+ onLog?: (msg: string) => void;
+ signal: AbortSignal;
+}): Promise<QuickAvailabilityCheckResult> {
+ const log = onLog ?? ((m: string) => console.log(m));
+ const config = await readChannelConfig(paths, channelSlug);
+ if (!config?.url) {
+ throw new Error(
+ `Channel "${channelSlug}" has no URL configured; cannot fetch a fresh playlist.`,
+ );
+ }
+
+ const freshUrls = await fetchFlatPlaylistUrls({
+ channelConfig: config,
+ paths,
+ channelSlug,
+ onLog: log,
+ signal,
+ });
+ // Canonical, URL-derived ids — these match data-dir names like-for-like on
+ // every platform. Drop nulls (a null would never match and would spuriously
+ // flag every known video).
+ const freshIds = new Set(
+ freshUrls
+ .map((u) => extractVideoId(u))
+ .filter((id): id is string => Boolean(id)),
+ );
+
+ // Known set = data-dir names. A data-dir name *is* the canonical id; the
+ // archive stores yt-dlp native ids which diverge on non-YouTube platforms.
+ const dataDir = path.join(paths.channelsDir, channelSlug, "data");
+ const entries = await readdir(dataDir, { withFileTypes: true }).catch(
+ () => [] as Awaited<ReturnType<typeof readdir>> & { length: 0 },
+ );
+ const knownIds = (entries as Array<{ isDirectory(): boolean; name: string }>)
+ .filter((e) => e.isDirectory())
+ .map((e) => e.name);
+
+ const maybeMissing = knownIds.filter((id) => !freshIds.has(id)).sort();
+
+ await writeMaybeMissing(paths, channelSlug, {
+ checkedAt: new Date().toISOString(),
+ freshPlaylistCount: freshIds.size,
+ ids: maybeMissing,
+ });
+
+ log(
+ `Quick availability: ${knownIds.length} known, ${freshIds.size} in fresh playlist, ${maybeMissing.length} maybe-missing.`,
+ );
+
+ return {
+ knownCount: knownIds.length,
+ freshCount: freshIds.size,
+ maybeMissingCount: maybeMissing.length,
+ ids: maybeMissing,
+ };
+}
diff --git a/common/lib/availability-server.ts b/common/lib/availability-server.ts
@@ -4,6 +4,8 @@ import {
AVAILABILITY_FILENAME,
AVAILABILITY_VALUES,
type Availability,
+ type AvailabilityHistoryEntry,
+ type AvailabilityHistorySource,
type AvailabilityRecord,
} from "./availability";
import {
@@ -44,6 +46,66 @@ export async function writeAvailability(
await rename(tmp, file);
}
+// Append-on-change writer that all availability persistence funnels through.
+// Records an observation in the video's history[] only when the availability
+// differs from the most recent known value, then writes the record atomically.
+//
+// `updateTopLevel` (default true): the explicit check/backfill sources own the
+// top-level checkedAt/availability/error/webpageUrl fields. The download source
+// passes `updateTopLevel: false` so it appends a history entry without
+// disturbing those fields (keeping resolveEffectiveAvailability's recency
+// comparison against download-outcome.json intact).
+export async function recordAvailability(
+ videoDir: string,
+ obs: {
+ availability: Availability;
+ observedAt: string;
+ source: AvailabilityHistorySource;
+ error?: string;
+ webpageUrl?: string;
+ },
+ opts?: { updateTopLevel?: boolean },
+): Promise<void> {
+ const updateTopLevel = opts?.updateTopLevel !== false;
+ const existing = await loadAvailability(videoDir);
+
+ const lastAvail =
+ existing?.history?.at(-1)?.availability ?? existing?.availability ?? null;
+ const changed = lastAvail !== obs.availability;
+
+ const history: AvailabilityHistoryEntry[] = [...(existing?.history ?? [])];
+ if (changed) {
+ history.push({
+ availability: obs.availability,
+ observedAt: obs.observedAt,
+ source: obs.source,
+ });
+ }
+
+ let record: AvailabilityRecord;
+ if (updateTopLevel) {
+ record = {
+ checkedAt: obs.observedAt,
+ availability: obs.availability,
+ history,
+ };
+ if (obs.error !== undefined) record.error = obs.error;
+ if (obs.webpageUrl !== undefined) record.webpageUrl = obs.webpageUrl;
+ } else if (existing) {
+ record = { ...existing, history };
+ } else {
+ record = {
+ checkedAt: obs.observedAt,
+ availability: obs.availability,
+ history,
+ };
+ if (obs.error !== undefined) record.error = obs.error;
+ if (obs.webpageUrl !== undefined) record.webpageUrl = obs.webpageUrl;
+ }
+
+ await writeAvailability(videoDir, record);
+}
+
// Resolve a video's availability class by reconciling two sources:
// - availability.json (written by the explicit availability-check pass)
// - download-outcome.json's last attempt's availabilityClass (recorded
diff --git a/common/lib/availability.ts b/common/lib/availability.ts
@@ -31,11 +31,20 @@ export const EXCLUDED_FROM_DOWNLOAD: ReadonlyArray<Availability> = [
"private",
];
+export type AvailabilityHistorySource = "check" | "backfill" | "download";
+
+export type AvailabilityHistoryEntry = {
+ availability: Availability;
+ observedAt: string; // ISO
+ source: AvailabilityHistorySource;
+};
+
export type AvailabilityRecord = {
checkedAt: string;
availability: Availability;
error?: string;
webpageUrl?: string;
+ history?: AvailabilityHistoryEntry[]; // append-on-change; optional → backward compatible
};
export const AVAILABILITY_FILENAME = "availability.json";
diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts
@@ -17,6 +17,7 @@ import {
type DownloadOutcomeStatus,
} from "../lib/downloadOutcome";
import { writeDownloadOutcome } from "../lib/downloadOutcome-server";
+import { recordAvailability } from "../lib/availability-server";
import { loadRawMetadata } from "../lib/transcripts-server";
import { evaluateDownloadFilters } from "../lib/downloadFilters";
import type { Paths } from "../lib/paths";
@@ -674,5 +675,28 @@ async function runManagedDownload(
);
}
+ // Capture an availability history entry when the last attempt observed a
+ // class (e.g. a public video the uploader just deleted). Never touch the
+ // top-level fields (updateTopLevel: false) so resolveEffectiveAvailability's
+ // recency comparison against download-outcome.json is unchanged.
+ const lastAttempt = attempts.at(-1);
+ if (lastAttempt?.availabilityClass) {
+ try {
+ await recordAvailability(
+ videoDir,
+ {
+ availability: lastAttempt.availabilityClass,
+ observedAt: finishedAt,
+ source: "download",
+ },
+ { updateTopLevel: false },
+ );
+ } catch (err) {
+ opts.onLog(
+ `Failed to record availability history: ${(err as Error).message}\n`,
+ );
+ }
+ }
+
return record;
}
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -234,6 +234,25 @@ async function enumeratePlaylistUrls(
.filter(Boolean);
}
+// Thin exported wrapper over the module-private enumeratePlaylistUrls for
+// callers (e.g. the quick availability check) that only need a one-shot fresh
+// flat-playlist fetch and shouldn't have to fabricate a full RunYtdlpOpts.
+// enumeratePlaylistUrls only reads channelConfig, paths.ytdlpBin, onLog and
+// signal — never mode/tracker — so the cast is safe.
+export async function fetchFlatPlaylistUrls(opts: {
+ channelConfig: ChannelConfig;
+ paths: Paths;
+ channelSlug: string;
+ onLog: (s: string) => void;
+ signal: AbortSignal;
+}): Promise<string[]> {
+ const root = path.join(opts.paths.channelsDir, opts.channelSlug);
+ return enumeratePlaylistUrls(
+ { ...opts, mode: "store-playlist" } as RunYtdlpOpts,
+ root,
+ );
+}
+
async function storePlaylist(opts: RunYtdlpOpts): Promise<void> {
const root = channelRoot(opts);
await mkdir(root, { recursive: true });
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
+- **Quick availability check: spot videos that vanished from a channel with one cheap yt-dlp call.** A channel's **Diagnostics → Availability checks** stage gained a **Quick check (flat playlist)** button that fetches the channel's *current* flat playlist (one `--flat-playlist --print url` pass, no per-video metadata) and diffs it against the videos already on disk. Any known video absent from the fresh listing is flagged **maybe-missing** — it was deleted, made private, or **unlisted** (unlisted videos legitimately drop out of channel listings, so a flag is a candidate, not a verdict). The set is persisted to `channels/<slug>/maybe-missing.json` and re-surfaced through the channel snapshot (intersected with on-disk dirs), so it survives report regens. This is the fast first pass that avoids running the full per-video `--dump-json` check across a whole channel just to notice the handful that disappeared.
+- **"Full-check unexpected": resolve maybe-missing videos without re-probing the whole channel.** Alongside the maybe-missing list, a **Full-check unexpected** button (with its own concurrency control) runs the full availability check on *only* the maybe-missing ids, skipping any already known permanently gone (`deleted` / `private` / `members_only`) and re-probing the rest (`unlisted` / `needs_auth` / `public` / `error` / never-checked, since their status may have changed). This efficiently resolves e.g. deleted-vs-unlisted for just the suspect videos.
+- **Per-video availability history.** Each video's `availability.json` now keeps a `history[]` timeline that appends an entry only when the observed availability *changes* — so a flip like `public → deleted` is preserved rather than overwritten. Entries are tagged by source (`check`, `backfill`, or `download` — the last captures a public video the uploader deleted, observed at download time, without disturbing the explicit-check fields). The video detail page renders this as an **Availability history** card (reverse-chronological, "No availability changes recorded" when empty).
- **The Jobs screen has persistent filters, and archived jobs keep their details.** A filter bar atop `/jobs` lets you hide jobs by **kind** and **status** and **search** by id / channel / video; the choices persist in `localStorage` and, by default, hide the noisy `refresh-report` report-regen job so the list shows the work you actually triggered (a **Reset filters** button restores the defaults, and a `Showing N of M · K hidden` summary makes the active filtering obvious). Separately, each job now writes a small `<id>.meta.json` sidecar next to its log capturing its kind, channel, video, status, and timings — so once the in-memory registry evicts it (it keeps only the 100 most-recent finished jobs) or the server restarts, an **archived** job still shows that metadata instead of a bare id. The sidecar writes are best-effort and never delay or break a job, and **Clear archived** removes the sidecars along with the logs. See `common/jobs/jobMeta.ts`.
- **The Workers page can save the current arrangement as a launch default, and "Stop & keep progress" now takes the worker out of rotation.** A **Set as default** button (top of `/workers`) snapshots which workers are enabled right now; on the next server launch the pool starts exactly those workers enabled and **every other worker disabled** — including workers added later that aren't in the saved set. This persists the otherwise-transient runtime on/off state across restarts without touching `settings.json` (it's a small `transcripts/.workers/defaults.json` the pool reads on first use). Once a default is saved the button reads **Update default** and each included worker shows a small **default** badge; the default governs the launch-time seed only, so editing a worker's Enabled flag in Settings still takes effect as before. Separately, **Stop & keep progress** now drains the worker after finishing the current window (it ends *disabled*) instead of leaving it enabled to immediately grab the next video — matching **Drain**, which already ended disabled. See `common/jobs/workerDefaults.ts`.
- **Downloads stop and stay blocked when free disk space runs low.** A new **Settings → Minimum free disk space (GB)** floor (default **5 GB**; set **0** to disable) guards every download against filling the disk. When free space on the transcripts data directory is at or below the floor, a download job is **prevented from starting** — the channel/import action returns a clear "Low disk space: X free, Y required" error instead of queuing — and a **running batch stops between videos**: the in-flight download finishes, no new ones start, and the batch ends cleanly (status *done*, partial progress preserved) rather than crashing into an out-of-space error mid-file. Free space is measured natively (`statfs`, no new dependency) and the check **fails open** — if it can't read the filesystem, downloads proceed rather than being wrongly blocked. The monitor widget (and `/api/jobs/active`) gained a compact disk indicator showing free space, which turns red and reads "downloads paused" when below the floor (and stays visible even when idle, so it explains why nothing is downloading). `store-playlist` (which writes no media) is not gated. See `common/lib/diskSpace.ts`.
diff --git a/editor/app/channels/[slug]/availabilityActions.ts b/editor/app/channels/[slug]/availabilityActions.ts
@@ -14,6 +14,10 @@ import {
type CheckAvailabilityMode,
} from "yt-dlp-transcript-common/controller/checkAvailability";
import {
+ loadMaybeMissing,
+ runQuickAvailabilityCheck,
+} from "yt-dlp-transcript-common/controller/quickAvailabilityCheck";
+import {
runManagedFunction,
type StreamActionResult,
} from "yt-dlp-transcript-common/jobs/streamCommand";
@@ -63,3 +67,75 @@ export async function checkAvailabilityAction(
},
});
}
+
+// Cheap first pass: one yt-dlp spawn fetches the channel's fresh flat playlist
+// and diffs it against the on-disk videos, persisting the maybe-missing set.
+export async function quickAvailabilityCheckAction(
+ slug: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const config = await readChannelConfig(paths, slug);
+ const defaultQueue = config?.platform
+ ? platformQueueKey(config.platform)
+ : queueKeyForUrl(config?.url);
+ return runManagedFunction({
+ kind: "quick-availability-check",
+ queueKey: resolveQueueKey(defaultQueue, queueKey),
+ paths,
+ channelSlug: slug,
+ fn: async (onLog, signal) => {
+ await runQuickAvailabilityCheck({
+ channelSlug: slug,
+ paths,
+ onLog,
+ signal,
+ });
+ revalidatePath(`/channels/${slug}`);
+ },
+ });
+}
+
+// Targeted full check: run the per-video --dump-json pass on only the
+// maybe-missing ids, skipping any already known permanently gone. Resolves
+// deleted vs. unlisted without re-probing the whole channel.
+export async function checkMaybeMissingAction(
+ slug: string,
+ queueKey?: string,
+ concurrency?: number,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const config = await readChannelConfig(paths, slug);
+ const defaultQueue = config?.platform
+ ? platformQueueKey(config.platform)
+ : queueKeyForUrl(config?.url);
+ const limit = sanitizeConcurrency(concurrency);
+ return runManagedFunction({
+ kind: "check-maybe-missing",
+ queueKey: resolveQueueKey(defaultQueue, queueKey),
+ paths,
+ channelSlug: slug,
+ fn: async (onLog, signal) => {
+ const record = await loadMaybeMissing(paths, slug);
+ const ids = record?.ids ?? [];
+ if (ids.length === 0) {
+ onLog("No maybe-missing videos. Run a quick check first.\n");
+ return;
+ }
+ // recheck-all (not resume) so videos with a stale `public`
+ // availability.json are re-probed; skipExpectedAbsent avoids wasted
+ // probes of videos already known deleted/private/members-only.
+ await runAvailabilityCheck({
+ channelSlug: slug,
+ paths,
+ mode: "recheck-all",
+ onlyIds: ids,
+ skipExpectedAbsent: true,
+ 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
@@ -8,7 +8,11 @@ import {
} 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 {
+ checkAvailabilityAction,
+ checkMaybeMissingAction,
+ quickAvailabilityCheckAction,
+} from "../../availabilityActions";
import { cancelJobAction } from "../../../../jobs/actions";
import { gotoVideoAction } from "../../../actions";
import { ConcurrencyControl } from "../../../../components/ConcurrencyControl";
@@ -47,6 +51,7 @@ type Props = {
skippedByFilterIds: string[];
totals: { videos: number; transcribed: number; downloaded: number };
availability: AvailabilitySnapshot;
+ maybeMissing: { ids: string[]; checkedAt: string };
availabilityShard: ShardConfigSummary | null;
existingQueues: string[];
downloadDefaultQueueKey: string;
@@ -62,6 +67,7 @@ export function DiagnosticsStage({
skippedByFilterIds,
totals,
availability,
+ maybeMissing,
availabilityShard,
existingQueues,
downloadDefaultQueueKey,
@@ -161,6 +167,7 @@ export function DiagnosticsStage({
existingQueues={existingQueues}
downloadDefaultQueueKey={downloadDefaultQueueKey}
/>
+ <QuickAvailabilityCheck slug={slug} maybeMissing={maybeMissing} />
</div>
<div className="flex flex-col gap-2" aria-label="channel health">
<div>
@@ -412,6 +419,88 @@ function AvailabilityButtons({
);
}
+function QuickAvailabilityCheck({
+ slug,
+ maybeMissing,
+}: {
+ slug: string;
+ maybeMissing: { ids: string[]; checkedAt: string };
+}) {
+ const [concurrency, setConcurrency] = useState("");
+ const limit = parseConcurrency(concurrency);
+ const neverRun = maybeMissing.ids.length === 0 && !maybeMissing.checkedAt;
+ return (
+ <div
+ className="mt-2 flex flex-col gap-3 rounded border border-zinc-200 dark:border-zinc-800 p-3"
+ aria-label="quick availability check"
+ >
+ <div>
+ <h4 className="text-sm font-semibold">Quick availability check</h4>
+ <p className="text-xs text-zinc-500">
+ Fetch the channel's current flat playlist (one cheap yt-dlp call,
+ no per-video metadata) and diff it against the videos on disk. Anything
+ missing is flagged below. Note: unlisted videos legitimately drop out
+ of channel listings, so a flag here is a candidate, not a verdict.
+ </p>
+ </div>
+ <StreamActionLog
+ trigger={() => quickAvailabilityCheckAction(slug)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Quick check (flat playlist)"
+ runningLabel="Fetching playlist…"
+ label="Quick availability check"
+ />
+ {neverRun ? (
+ <p
+ aria-label="maybe missing never run"
+ className="text-sm text-zinc-600 dark:text-zinc-400"
+ >
+ No quick check run yet.
+ </p>
+ ) : (
+ <div className="flex flex-col gap-2" aria-label="maybe missing summary">
+ <div>
+ <h5 className="text-sm font-semibold">
+ Maybe-missing ({maybeMissing.ids.length})
+ </h5>
+ {maybeMissing.checkedAt && (
+ <p className="text-xs text-zinc-500">
+ Last quick check:{" "}
+ {new Date(maybeMissing.checkedAt).toLocaleString()}
+ </p>
+ )}
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={maybeMissing.ids}
+ ariaLabel="maybe missing list"
+ emptyAriaLabel="maybe missing empty"
+ emptyMessage="None — every known video is still in the playlist."
+ itemAriaLabel={(id) => `maybe missing ${id}`}
+ />
+ {maybeMissing.ids.length > 0 && (
+ <StreamActionLog
+ trigger={() => checkMaybeMissingAction(slug, undefined, limit)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Full-check unexpected"
+ runningLabel="Checking…"
+ label="Full-check maybe-missing videos"
+ extraControls={
+ <ConcurrencyControl
+ value={concurrency}
+ onChange={setConcurrency}
+ defaultLimit={1}
+ actionLabel="full-check unexpected"
+ />
+ }
+ />
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
const AVAILABILITY_LABELS: Record<Availability, string> = {
public: "Public",
unlisted: "Unlisted",
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -10,6 +10,7 @@ import {
generateChannelSnapshot,
normalizeAvailability,
normalizeExcludedFromDownload,
+ normalizeMaybeMissing,
readChannelSnapshot,
} from "yt-dlp-transcript-common/controller/channelSnapshot";
import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
@@ -20,6 +21,7 @@ import {
type ShardOp,
} from "yt-dlp-transcript-common/controller/shard";
import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server";
+import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { resolvePrimaryVtt } from "yt-dlp-transcript-common/lib/videoStatus";
import {
@@ -183,6 +185,7 @@ export default async function ChannelDetailPage({
(id) => !excludedDownloadIds.has(id),
);
const availability = normalizeAvailability(snapshot.availability);
+ const maybeMissing = normalizeMaybeMissing(snapshot.maybeMissing);
const stages = computeStageStatuses({
snapshot,
failedVideoIds,
@@ -276,6 +279,7 @@ export default async function ChannelDetailPage({
skippedByFilterIds={buckets.skippedByFilter}
totals={snapshot.totals}
availability={availability}
+ maybeMissing={maybeMissing}
availabilityShard={availabilityShard}
existingQueues={existingQueues}
downloadDefaultQueueKey={platformDefaultQueueKey}
@@ -336,10 +340,11 @@ export default async function ChannelDetailPage({
let selectedPanel: ReactNode = null;
let selectedTitle: string | null = null;
if (selectedVideoId) {
- const [dirData, title, outcome] = await Promise.all([
+ const [dirData, title, outcome, availabilityRecord] = await Promise.all([
loadVideoDir(channelDataDir, selectedVideoId),
loadVideoTitle(channelDataDir, selectedVideoId),
loadDownloadOutcome(path.join(channelDataDir, selectedVideoId)),
+ loadAvailability(path.join(channelDataDir, selectedVideoId)),
]);
selectedTitle = title;
const prevRow =
@@ -358,6 +363,7 @@ export default async function ChannelDetailPage({
defaultQueueKey={platformDefaultQueueKey}
existingQueues={existingQueues}
downloadOutcome={outcome}
+ availabilityHistory={availabilityRecord?.history ?? []}
channelAudioFormat={config.audioFormat}
prevHref={prevRow ? buildVideoHref(prevRow.id) : undefined}
nextHref={nextRow ? buildVideoHref(nextRow.id) : undefined}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -9,6 +9,7 @@ import {
type ChannelHandling,
} from "yt-dlp-transcript-common/lib/channelConfig";
import type { DownloadOutcomeRecord } from "yt-dlp-transcript-common/lib/downloadOutcome";
+import type { AvailabilityHistoryEntry } from "yt-dlp-transcript-common/lib/availability";
import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import { QueueControl } from "../../../../../components/QueueControl";
import { cancelJobAction } from "../../../../../jobs/actions";
@@ -52,6 +53,7 @@ type Props = {
defaultQueueKey: string;
existingQueues: string[];
downloadOutcome: DownloadOutcomeRecord | null;
+ availabilityHistory: AvailabilityHistoryEntry[];
channelAudioFormat?: AudioFormat;
doNotClean?: boolean;
prevHref?: string;
@@ -127,6 +129,7 @@ export function VideoPanel({
defaultQueueKey,
existingQueues,
downloadOutcome,
+ availabilityHistory,
channelAudioFormat,
doNotClean = false,
prevHref,
@@ -181,6 +184,19 @@ export function VideoPanel({
downloadFailed={downloadFailed}
/>
{downloadOutcome && <DownloadOutcomeBadge outcome={downloadOutcome} />}
+ <PipelineStageCard
+ id="availability-history"
+ title="Availability history"
+ summary={
+ availabilityHistory.length === 0
+ ? "No availability changes recorded."
+ : `${availabilityHistory.length} recorded change${availabilityHistory.length === 1 ? "" : "s"}.`
+ }
+ defaultOpen={false}
+ tone="neutral"
+ >
+ <AvailabilityHistoryList entries={availabilityHistory} />
+ </PipelineStageCard>
{doNotClean && (
<div
role="status"
@@ -976,6 +992,47 @@ function DeleteVideoDirSection({
);
}
+function AvailabilityHistoryList({
+ entries,
+}: {
+ entries: AvailabilityHistoryEntry[];
+}) {
+ if (entries.length === 0) {
+ return (
+ <p
+ aria-label="availability history empty"
+ className="text-sm text-zinc-600 dark:text-zinc-400"
+ >
+ No availability changes recorded.
+ </p>
+ );
+ }
+ // Reverse-chronological (most recent first).
+ const ordered = [...entries].reverse();
+ return (
+ <ul
+ aria-label="availability history"
+ className="flex flex-col gap-1 text-sm"
+ >
+ {ordered.map((e, i) => (
+ <li
+ key={`${e.observedAt}-${i}`}
+ aria-label={`availability history ${e.availability}`}
+ className="flex flex-wrap items-baseline gap-x-2"
+ >
+ <span className="text-zinc-500 tabular-nums">
+ {new Date(e.observedAt).toLocaleString()}
+ </span>
+ <span className="font-medium text-zinc-800 dark:text-zinc-200">
+ {e.availability}
+ </span>
+ <span className="text-xs text-zinc-500">({e.source})</span>
+ </li>
+ ))}
+ </ul>
+ );
+}
+
function DownloadOutcomeBadge({
outcome,
}: {
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -6,6 +6,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
import type { Dirent } from "node:fs";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server";
+import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server";
import { isDoNotClean } from "yt-dlp-transcript-common/lib/doNotClean-server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { resolvePrimaryVtt } from "yt-dlp-transcript-common/lib/videoStatus";
@@ -97,6 +98,8 @@ export default async function VideoDetailPage({
const meta = await loadMeta(slug, id);
const videoDir = path.join(getPaths().channelsDir, slug, "data", id);
const downloadOutcome = await loadDownloadOutcome(videoDir);
+ const availabilityRecord = await loadAvailability(videoDir);
+ const availabilityHistory = availabilityRecord?.history ?? [];
const doNotClean = await isDoNotClean(videoDir);
const registry = getRegistry();
@@ -173,6 +176,7 @@ export default async function VideoDetailPage({
defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
downloadOutcome={downloadOutcome}
+ availabilityHistory={availabilityHistory}
channelAudioFormat={config.audioFormat}
doNotClean={doNotClean}
/>
diff --git a/editor/e2e/availability.spec.ts b/editor/e2e/availability.spec.ts
@@ -24,6 +24,7 @@ type AvailabilityFile = {
availability: string;
webpageUrl?: string;
error?: string;
+ history?: Array<{ availability: string; observedAt: string; source: string }>;
};
function availabilityPath(id: string): string {
@@ -49,6 +50,12 @@ test("resume checks classify each fixture video into its expected bucket", async
const record = await readAvailability(v.id);
expect(record.availability).toBe(v.expected);
expect(typeof record.checkedAt).toBe("string");
+ // Each first-time check appends exactly one history entry (source "check").
+ expect(record.history?.length).toBe(1);
+ expect(record.history?.[0]).toMatchObject({
+ availability: v.expected,
+ source: "check",
+ });
}
});
diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs
@@ -168,8 +168,22 @@ async function downloadOne(url, opts = {}) {
}
async function modeStorePlaylist() {
- for (let i = 1; i <= 5; i++) {
- process.stdout.write(`https://www.youtube.com/watch?v=fake0000000${i}\n`);
+ // The emitted id set is configurable via a cwd sidecar so the quick
+ // availability check (flat-playlist diff) can omit specific known videos to
+ // exercise the maybe-missing flow. Sidecar shape: { ids: ["..."] } — each id
+ // becomes a watch?v=<id> URL. Defaults to the historical 5 fake ids.
+ let ids = ["fake00000001", "fake00000002", "fake00000003", "fake00000004", "fake00000005"];
+ try {
+ const raw = await readFile(".fake-ytdlp-flat-playlist.json", "utf8");
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed?.ids)) {
+ ids = parsed.ids.filter((x) => typeof x === "string");
+ }
+ } catch {
+ // no sidecar — use the default set
+ }
+ for (const id of ids) {
+ process.stdout.write(`https://www.youtube.com/watch?v=${id}\n`);
}
}
@@ -461,11 +475,14 @@ async function main() {
process.exit(1);
}
const id = urlIdYouTube(url) ?? "unknown";
+ // An unlisted video resolves successfully via --dump-json (it dropped out
+ // of the channel listing but is still fetchable by URL).
+ const availability = lower.includes("unlisted") ? "unlisted" : "public";
const payload = {
id,
title: `Synthetic ${id}`,
webpage_url: url,
- availability: "public",
+ availability,
};
process.stdout.write(`${JSON.stringify(payload)}\n`);
return;
diff --git a/editor/e2e/maybe-missing.spec.ts b/editor/e2e/maybe-missing.spec.ts
@@ -0,0 +1,225 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { readJson, resetData, resolvePath } from "./helpers";
+
+const CHANNEL = "availability-test";
+const FIXTURE = "availability-baseline";
+
+// The baseline fixture's data dirs (canonical ids). vidunlisted1 is created at
+// test time below so we can exercise the unlisted-resolves-via-dump-json path.
+const BASELINE_IDS = [
+ "vidpublic1",
+ "vidpublic2",
+ "viddeleted1",
+ "vidprivate1",
+ "vidmembers1",
+ "vidneedsauth1",
+];
+
+const channelRoot = `test-transcripts/channels/${CHANNEL}`;
+
+function dataDir(id: string): string {
+ return `${channelRoot}/data/${id}`;
+}
+
+function availabilityPath(id: string): string {
+ return `${dataDir(id)}/availability.json`;
+}
+
+type AvailabilityFile = {
+ checkedAt: string;
+ availability: string;
+ webpageUrl?: string;
+ history?: Array<{
+ availability: string;
+ observedAt: string;
+ source: string;
+ }>;
+};
+
+async function readAvailability(id: string): Promise<AvailabilityFile> {
+ return readJson<AvailabilityFile>(availabilityPath(id));
+}
+
+// Seed an extra video dir with metadata so it counts as a known on-disk video.
+async function seedVideoDir(id: string): Promise<void> {
+ const dir = resolvePath(dataDir(id));
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ resolvePath(`${dataDir(id)}/metadata.info.json`),
+ JSON.stringify(
+ {
+ id,
+ title: `Synthetic ${id}`,
+ webpage_url: `https://www.youtube.com/watch?v=${id}`,
+ extractor_key: "Youtube",
+ },
+ null,
+ 2,
+ ),
+ );
+}
+
+// Write the sidecar that controls which ids the fake yt-dlp's flat-playlist
+// branch emits. It lives in the channel cwd (where availability spawns run).
+async function setFreshPlaylist(ids: string[]): Promise<void> {
+ await writeFile(
+ resolvePath(`${channelRoot}/.fake-ytdlp-flat-playlist.json`),
+ JSON.stringify({ ids }),
+ );
+}
+
+// The quick-check job arms a debounced (~1s) snapshot regen. Wait until
+// snapshot.json re-surfaces a non-empty maybeMissing set so the reloaded page
+// renders the list + "Full-check unexpected" button.
+async function waitForSnapshotMaybeMissing(): Promise<void> {
+ await expect
+ .poll(
+ async () => {
+ const snap = await readJson<{ maybeMissing?: { ids: string[] } }>(
+ `${channelRoot}/snapshot.json`,
+ ).catch(() => null);
+ return snap?.maybeMissing?.ids?.length ?? 0;
+ },
+ { timeout: 15_000 },
+ )
+ .toBeGreaterThan(0);
+}
+
+async function readInvocations(): Promise<string> {
+ try {
+ return await readFile(
+ resolvePath(`${channelRoot}/fake-ytdlp.invocations`),
+ "utf8",
+ );
+ } catch {
+ return "";
+ }
+}
+
+test("quick check flags videos missing from the fresh playlist", async ({
+ page,
+}) => {
+ await resetData(FIXTURE);
+ await seedVideoDir("vidunlisted1");
+ // Fresh playlist omits viddeleted1 and vidunlisted1.
+ await setFreshPlaylist(BASELINE_IDS.filter((id) => id !== "viddeleted1"));
+
+ await page.goto(`/channels/${CHANNEL}`);
+ await page
+ .getByRole("button", { name: "Quick check (flat playlist)" })
+ .click();
+ const log = page.getByLabel("Quick availability check output");
+ await expect(log).toContainText("maybe-missing", { timeout: 20_000 });
+
+ const record = await readJson<{ ids: string[]; freshPlaylistCount: number }>(
+ `${channelRoot}/maybe-missing.json`,
+ );
+ expect(record.ids.sort()).toEqual(["viddeleted1", "vidunlisted1"].sort());
+
+ // The job arms a debounced (~1s) snapshot regen that re-surfaces the
+ // maybe-missing set into snapshot.json. Wait for it before reloading.
+ await waitForSnapshotMaybeMissing();
+
+ // Reload — the maybe-missing list persists (re-surfaced via the snapshot).
+ await page.reload();
+ const list = page.getByLabel("maybe missing list");
+ await expect(list).toContainText("viddeleted1");
+ await expect(list).toContainText("vidunlisted1");
+});
+
+test("full-check skips hard-excluded but re-checks unlisted/needs_auth", async ({
+ page,
+}) => {
+ await resetData(FIXTURE);
+ await seedVideoDir("vidunlisted1");
+ // vidmembers1 already known members_only → must be skipped by the full check.
+ await writeFile(
+ resolvePath(availabilityPath("vidmembers1")),
+ JSON.stringify({
+ checkedAt: new Date().toISOString(),
+ availability: "members_only",
+ }),
+ );
+ // Fresh playlist omits the three we want flagged.
+ await setFreshPlaylist(["vidpublic1", "vidpublic2", "vidprivate1"]);
+
+ await page.goto(`/channels/${CHANNEL}`);
+ await page
+ .getByRole("button", { name: "Quick check (flat playlist)" })
+ .click();
+ await expect(
+ page.getByLabel("Quick availability check output"),
+ ).toContainText("maybe-missing", { timeout: 20_000 });
+
+ await waitForSnapshotMaybeMissing();
+ await page.reload();
+ await page.getByRole("button", { name: "Full-check unexpected" }).click();
+ await expect(
+ page.getByLabel("Full-check maybe-missing videos output"),
+ ).toContainText("Availability check done", { timeout: 20_000 });
+
+ const invocations = await readInvocations();
+ // The hard-excluded members_only video is skipped: no dump-json probe.
+ expect(invocations).not.toContain("dump-json:https://www.youtube.com/watch?v=vidmembers1");
+ // viddeleted1, vidunlisted1, vidneedsauth1 are re-checked.
+ expect(invocations).toContain("dump-json:https://www.youtube.com/watch?v=vidunlisted1");
+ expect(invocations).toContain("dump-json:https://www.youtube.com/watch?v=viddeleted1");
+ expect(invocations).toContain("dump-json:https://www.youtube.com/watch?v=vidneedsauth1");
+});
+
+test("full-check resolves deleted vs unlisted and records history", async ({
+ page,
+}) => {
+ await resetData(FIXTURE);
+ await seedVideoDir("vidunlisted1");
+ await setFreshPlaylist(["vidpublic1", "vidpublic2"]);
+
+ await page.goto(`/channels/${CHANNEL}`);
+ await page
+ .getByRole("button", { name: "Quick check (flat playlist)" })
+ .click();
+ await expect(
+ page.getByLabel("Quick availability check output"),
+ ).toContainText("maybe-missing", { timeout: 20_000 });
+
+ await waitForSnapshotMaybeMissing();
+ await page.reload();
+ await page.getByRole("button", { name: "Full-check unexpected" }).click();
+ await expect(
+ page.getByLabel("Full-check maybe-missing videos output"),
+ ).toContainText("Availability check done", { timeout: 20_000 });
+
+ const deleted = await readAvailability("viddeleted1");
+ const unlisted = await readAvailability("vidunlisted1");
+ expect(deleted.availability).toBe("deleted");
+ expect(unlisted.availability).toBe("unlisted");
+
+ // History append-on-change: each got exactly one entry recorded.
+ const delHist = deleted.history ?? [];
+ expect(delHist.length).toBe(1);
+ expect(delHist[0]).toMatchObject({
+ availability: "deleted",
+ source: "check",
+ });
+
+ // Re-run the full check with no change → no duplicate history entry.
+ await page.getByRole("button", { name: "Full-check unexpected" }).click();
+ // Wait for a second "done" line.
+ await expect(
+ page.getByLabel("Full-check maybe-missing videos output"),
+ ).toContainText("Availability check done", { timeout: 20_000 });
+ await expect
+ .poll(async () => (await readAvailability("viddeleted1")).history?.length, {
+ timeout: 10_000,
+ })
+ .toBe(1);
+
+ // vidunlisted1 also got exactly one history entry recorded.
+ const unlHist = unlisted.history ?? [];
+ expect(unlHist.length).toBe(1);
+ expect(unlHist[0]).toMatchObject({
+ availability: "unlisted",
+ source: "check",
+ });
+});