Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/backfillAvailability.ts | 25++++++++++++++-----------
Mcommon/controller/channelSnapshot.ts | 40+++++++++++++++++++++++++++++++++-------
Mcommon/controller/checkAvailability.ts | 45++++++++++++++++++++++++++++++++++++---------
Acommon/controller/quickAvailabilityCheck.ts | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/availability-server.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/availability.ts | 9+++++++++
Mcommon/ytdlp/downloadOneManaged.ts | 24++++++++++++++++++++++++
Mcommon/ytdlp/runYtdlp.ts | 19+++++++++++++++++++
Meditor/CHANGELOG.md | 3+++
Meditor/app/channels/[slug]/availabilityActions.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/page.tsx | 8+++++++-
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 4++++
Meditor/e2e/availability.spec.ts | 7+++++++
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 23++++++++++++++++++++---
Aeditor/e2e/maybe-missing.spec.ts | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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&apos;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", + }); +});