Archilyzer · Source

archilyzer

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

commit a495e1a2fcd423ae1bdbeb8fc1940031a442f1c1
parent 7f7ac6a868e50250ebaa90271b4ed8613a652289
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 19 Sep 2026 23:57:56 -0400

download filter: a channel can decline a video by title, and the decline settles

`ChannelConfig.downloadFilter` is two case-insensitive regexes over
`title + "\n" + description` — the only text a download filter ever sees,
since it runs on the per-video metadata prefetch. It is the first entry in
the FILTERS registry, ahead of skipLive, so an unwanted video is settled
rather than classified as a live-stream retry.

A non-match is settled by a TERMINAL OUTCOME keyed on the filter's signature,
not by an archive line. An archive line means "downloaded" to
verifyTranscripts and the missingFromArchive bucket, and undownloadedIds is
artifact-based, so the id would come straight back. `"v1\0"+include+"\0"+exclude`
re-evaluates itself instead: edit either pattern and every video the old one
decided stops being settled, with no migration and no sweep.

isSettledByFilter is the ONE definition of settled; isVideoSettledByFilter is
its per-video-dir form, shaped like isDeferredAuthExcluded so the exclusion
passes can treat the two the same way. A configured filter with no metadata
fails CLOSED as a non-permanent skip — the existing retryable bucket — and an
unparseable regex makes the filter inert plus one log line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/channelConfig.ts | 35+++++++++++++++++++++++++++++++++++
Acommon/lib/downloadFilters.test.ts | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/downloadFilters.ts | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/downloadOutcome-server.ts | 18++++++++++++++++++
Mcommon/lib/downloadOutcome.ts | 17++++++++++++++++-
Mcommon/ytdlp/downloadOneManaged.ts | 16++++++++++++++--
6 files changed, 519 insertions(+), 5 deletions(-)

diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts @@ -56,6 +56,14 @@ export type AudioCheckConfig = { resumeDuringProbe?: boolean; }; +// Per-channel download filter patterns. Regex SOURCES, always compiled with +// the "i" flag; an unparseable pattern makes the filter inert rather than +// failing a download (the editor form refuses to save one). +export type DownloadFilterConfig = { + include?: string; + exclude?: string; +}; + export type ChannelConfig = { handling: ChannelHandling; // Omitted = "video" (every channel that predates the posts corpus). @@ -159,6 +167,19 @@ export type ChannelConfig = { // omitted, the global SiteSettings value is used. Set false to allow this // channel to download currently-live/upcoming videos. skipLiveDownloads?: boolean; + // Per-channel title/description download filter. Both patterns are + // case-insensitive regex SOURCES (no delimiters, no flags) matched against + // `title + "\n" + description` from the per-video metadata prefetch — the + // only text a download filter ever gets to see. `exclude` wins over + // `include`. A video the filter declines is SETTLED by a terminal + // download-outcome keyed on the filter's signature (see + // downloadFilterSignature), NOT by an archive line: an archive line would + // mean "downloaded" to verifyTranscripts and the missingFromArchive bucket, + // and would still leave the id in the artifact-based undownloadedIds. The + // signature is what makes the settlement self-expiring — change either + // pattern and every settled video is re-evaluated on the next run. + // Omitted/empty = no filter (the registry entry is inert). + downloadFilter?: DownloadFilterConfig; // Per-channel override of the global cookies-from-browser browser spec // (SiteSettings.cookiesFromBrowser). Omitted/empty = inherit the global // value. See common/lib/cookiePolicy.ts. @@ -331,6 +352,20 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null { if (typeof r.skipLiveDownloads === "boolean") { config.skipLiveDownloads = r.skipLiveDownloads; } + // Download filter. Blank/whitespace patterns are dropped rather than stored, + // so an all-blank object leaves the key absent and the filter inert — the + // same "empty means inherit-off" rule the cookie overrides use. + if (r.downloadFilter && typeof r.downloadFilter === "object") { + const df = r.downloadFilter as Record<string, unknown>; + const include = typeof df.include === "string" ? df.include.trim() : ""; + const exclude = typeof df.exclude === "string" ? df.exclude.trim() : ""; + if (include || exclude) { + config.downloadFilter = { + ...(include ? { include } : {}), + ...(exclude ? { exclude } : {}), + }; + } + } if (typeof r.cookiesFromBrowser === "string" && r.cookiesFromBrowser.trim()) { config.cookiesFromBrowser = r.cookiesFromBrowser.trim(); } diff --git a/common/lib/downloadFilters.test.ts b/common/lib/downloadFilters.test.ts @@ -0,0 +1,278 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { ChannelConfig } from "./channelConfig"; +import type { DownloadOutcomeRecord } from "./downloadOutcome"; +import { + compileDownloadFilter, + downloadFilterSignature, + evaluateDownloadFilters, + isSettledByFilter, + type DownloadFilterContext, +} from "./downloadFilters"; +import type { RawMetadata } from "./transcripts-server"; + +// Run with: node_modules/.bin/tsx --test common/lib/downloadFilters.test.ts + +const BASE_CONFIG: ChannelConfig = { handling: "youtube", name: "Test" }; + +function meta(over: Partial<RawMetadata> = {}): RawMetadata { + return { + id: "vid", + title: "Synthetic vid", + description: "Synthetic video vid", + is_live: false, + was_live: false, + live_status: "not_live", + ...over, + }; +} + +function ctx( + over: Partial<DownloadFilterContext> = {}, +): DownloadFilterContext { + return { + metadata: meta(), + channelConfig: BASE_CONFIG, + // Global skip-live default is ON, matching downloadOneManaged's fallback. + settings: { skipLiveDownloads: true }, + ...over, + }; +} + +function withFilter( + include?: string, + exclude?: string, +): ChannelConfig { + return { + ...BASE_CONFIG, + downloadFilter: { + ...(include ? { include } : {}), + ...(exclude ? { exclude } : {}), + }, + }; +} + +test("no filter configured -> no decision, and no signature", () => { + assert.equal(downloadFilterSignature(undefined), null); + assert.equal(downloadFilterSignature({}), null); + assert.equal(downloadFilterSignature({ include: " " }), null); + assert.equal(compileDownloadFilter(undefined), null); + assert.equal(evaluateDownloadFilters(ctx()), null); +}); + +test("include matches the title -> downloads", () => { + const d = evaluateDownloadFilters( + ctx({ + channelConfig: withFilter("guest"), + metadata: meta({ title: "Synthetic guestvid0001", description: "x" }), + }), + ); + assert.equal(d, null); +}); + +test("include misses -> permanent skip carrying the signature", () => { + const config = withFilter("guest"); + const d = evaluateDownloadFilters( + ctx({ + channelConfig: config, + metadata: meta({ title: "Synthetic plainvid0001", description: "x" }), + }), + ); + assert.ok(d); + assert.equal(d.skip, true); + assert.equal(d.filter, "titleFilter"); + assert.equal(d.permanent, true); + assert.equal(d.signature, downloadFilterSignature(config.downloadFilter)); + assert.match(d.reason, /does not match include/); +}); + +test("the description alone can satisfy include", () => { + const d = evaluateDownloadFilters( + ctx({ + channelConfig: withFilter("special guest"), + metadata: meta({ + title: "Episode 12", + description: "With a special guest this week", + }), + }), + ); + assert.equal(d, null); +}); + +test("exclude wins over include", () => { + const d = evaluateDownloadFilters( + ctx({ + channelConfig: withFilter("guest", "rerun"), + metadata: meta({ title: "guest episode (rerun)", description: "" }), + }), + ); + assert.ok(d); + assert.equal(d.permanent, true); + assert.match(d.reason, /matches exclude/); +}); + +test("matching is case-insensitive in both directions", () => { + assert.equal( + evaluateDownloadFilters( + ctx({ + channelConfig: withFilter("GUEST"), + metadata: meta({ title: "a guest appears", description: "" }), + }), + ), + null, + ); + const d = evaluateDownloadFilters( + ctx({ + channelConfig: withFilter(undefined, "rerun"), + metadata: meta({ title: "A RERUN", description: "" }), + }), + ); + assert.ok(d); + assert.equal(d.permanent, true); +}); + +test("invalid regex -> inert filter, one log line, nothing skipped", () => { + const lines: string[] = []; + const d = evaluateDownloadFilters( + ctx({ + channelConfig: withFilter("elf("), + metadata: meta({ title: "anything at all", description: "" }), + onLog: (l) => lines.push(l), + }), + ); + assert.equal(d, null); + assert.equal(lines.length, 1); + assert.match(lines[0], /INERT/); + assert.equal(compileDownloadFilter({ include: "elf(" }), null); + // An inert filter still HAS a signature — the config is what it is; only the + // compile fails. Nothing gets settled because nothing is skipped. + assert.notEqual(downloadFilterSignature({ include: "elf(" }), null); +}); + +test("null metadata with a filter configured -> NON-permanent skip", () => { + const d = evaluateDownloadFilters( + ctx({ channelConfig: withFilter("guest"), metadata: null }), + ); + assert.ok(d); + assert.equal(d.skip, true); + assert.equal(d.filter, "titleFilter"); + assert.equal(d.permanent, false); + assert.match(d.reason, /failing closed/); +}); + +test("null metadata with NO filter configured -> no skip (fail-open)", () => { + assert.equal(evaluateDownloadFilters(ctx({ metadata: null })), null); +}); + +function settledOutcome(signature: string): DownloadOutcomeRecord { + return { + videoId: "plainvid0001", + status: "skipped-filtered", + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:00:01.000Z", + attempts: [], + filter: { + name: "titleFilter", + reason: "does not match include", + permanent: true, + signature, + }, + }; +} + +test("a signature change flips isSettledByFilter back to false", () => { + const config = withFilter("guest"); + const sig = downloadFilterSignature(config.downloadFilter); + assert.ok(sig); + const outcome = settledOutcome(sig); + + assert.equal(isSettledByFilter(outcome, config), true); + // Same include, different exclude -> different signature -> unsettled. + assert.equal(isSettledByFilter(outcome, withFilter("guest", "rerun")), false); + // A different include -> unsettled. + assert.equal(isSettledByFilter(outcome, withFilter("plain")), false); + // Filter removed entirely -> unsettled. + assert.equal(isSettledByFilter(outcome, BASE_CONFIG), false); + assert.equal(isSettledByFilter(outcome, null), false); +}); + +test("only a permanent, signed filter skip is settled", () => { + const config = withFilter("guest"); + const sig = downloadFilterSignature(config.downloadFilter)!; + assert.equal(isSettledByFilter(null, config), false); + assert.equal( + isSettledByFilter( + { status: "ok", filter: { name: "titleFilter", reason: "", permanent: true, signature: sig } }, + config, + ), + false, + ); + // skip-live's skip carries neither flag: never settled, always retried. + assert.equal( + isSettledByFilter( + { status: "skipped-filtered", filter: { name: "skipLive", reason: "live" } }, + config, + ), + false, + ); + // Permanent but unsigned can never match a signature. + assert.equal( + isSettledByFilter( + { + status: "skipped-filtered", + filter: { name: "titleFilter", reason: "", permanent: true }, + }, + config, + ), + false, + ); +}); + +test("skipLive still fires, and titleFilter runs first", () => { + // A live video with no title filter: skip-live decides, as before. + const live = evaluateDownloadFilters( + ctx({ metadata: meta({ is_live: true, live_status: "is_live" }) }), + ); + assert.ok(live); + assert.equal(live.filter, "skipLive"); + assert.equal(live.permanent, undefined); + + // A live video that ALSO misses the include: the title filter answers, + // because it is first in the registry and its verdict is the terminal one. + const both = evaluateDownloadFilters( + ctx({ + channelConfig: withFilter("guest"), + metadata: meta({ + title: "Synthetic plainvid0001", + description: "", + is_live: true, + live_status: "is_live", + }), + }), + ); + assert.ok(both); + assert.equal(both.filter, "titleFilter"); + + // A live video that PASSES the include still falls through to skip-live. + const passThrough = evaluateDownloadFilters( + ctx({ + channelConfig: withFilter("guest"), + metadata: meta({ + title: "Synthetic guestvid0001", + description: "", + is_live: true, + live_status: "is_live", + }), + }), + ); + assert.ok(passThrough); + assert.equal(passThrough.filter, "skipLive"); + + // Finished VOD is untouched by either (regression guard). + assert.equal( + evaluateDownloadFilters( + ctx({ metadata: meta({ was_live: true, live_status: "was_live" }) }), + ), + null, + ); +}); diff --git a/common/lib/downloadFilters.ts b/common/lib/downloadFilters.ts @@ -10,7 +10,8 @@ // Adding a filter = append one entry to FILTERS. Each filter sees the same // context (metadata + channel config + resolved settings). -import type { ChannelConfig } from "./channelConfig"; +import type { ChannelConfig, DownloadFilterConfig } from "./channelConfig"; +import type { DownloadOutcomeRecord } from "./downloadOutcome"; import type { RawMetadata } from "./transcripts-server"; export type DownloadFilterSettings = { @@ -22,12 +23,26 @@ export type DownloadFilterContext = { metadata: RawMetadata | null; channelConfig: ChannelConfig; settings: DownloadFilterSettings; + // Optional job log. A filter uses it only to report that it made itself + // INERT (an unparseable regex on disk) — a silent no-op filter is the one + // failure mode nothing downstream can explain. + onLog?: (line: string) => void; }; export type DownloadFilterDecision = { skip: boolean; filter: string; reason: string; + // TERMINAL, not "tried and failed". A permanent skip settles the video: the + // snapshot keeps it out of undownloadedIds and out of totals.videos, and sync + // counts it as a hit so a filtered channel's paged walk still stops on its + // first settled page instead of re-walking every non-match every day. + // Undefined/false = the historical retryable skip (skip-live). + permanent?: boolean; + // The filter identity a permanent skip is settled AGAINST. Re-evaluated on + // every read: a video is settled only while this equals the channel's current + // signature, so editing a pattern un-settles everything it decided. + signature?: string; }; type DownloadFilter = { @@ -68,7 +83,125 @@ const skipLive: DownloadFilter = { }, }; -const FILTERS: ReadonlyArray<DownloadFilter> = [skipLive]; +// --------------------------------------------------------------------------- +// The per-channel title/description filter. +// --------------------------------------------------------------------------- + +// The filter's identity, and the whole settlement mechanism in one string. +// Returns null when the channel has no filter configured (both patterns blank), +// which is also the "nothing is settled here" answer — an outcome recorded under +// a signature can never match null. +// +// Versioned ("v1") so a future change to what the patterns are matched against +// invalidates every stored settlement rather than silently keeping decisions +// that were made against different text. NUL-separated because a regex may +// contain any other character. +export function downloadFilterSignature( + filter: DownloadFilterConfig | undefined | null, +): string | null { + const include = filter?.include?.trim() ?? ""; + const exclude = filter?.exclude?.trim() ?? ""; + if (!include && !exclude) return null; + return `v1\0${include}\0${exclude}`; +} + +export type CompiledDownloadFilter = { + include: RegExp | null; + exclude: RegExp | null; + signature: string; +}; + +// Compile a channel's patterns. Returns null when there is no filter AND when a +// pattern doesn't parse — an invalid regex makes the filter inert rather than +// failing every download on the channel. The editor form refuses to save one, so +// this only fires for a hand-edited config.json. +export function compileDownloadFilter( + filter: DownloadFilterConfig | undefined | null, +): CompiledDownloadFilter | null { + const signature = downloadFilterSignature(filter); + if (!signature) return null; + const include = filter?.include?.trim() ?? ""; + const exclude = filter?.exclude?.trim() ?? ""; + try { + return { + include: include ? new RegExp(include, "i") : null, + exclude: exclude ? new RegExp(exclude, "i") : null, + signature, + }; + } catch { + return null; + } +} + +// The only text a download filter ever sees. Kept here so the preview job and +// the filter itself can never match against different strings. +export function downloadFilterText( + meta: Pick<RawMetadata, "title" | "description"> | null | undefined, +): string { + return `${meta?.title ?? ""}\n${meta?.description ?? ""}`; +} + +// Per-channel include/exclude over title + description. Runs BEFORE skipLive so +// a video the operator doesn't want is settled without also being classified as +// a live-stream retry. +// +// FAILS CLOSED on missing metadata: a prefetch that produced nothing cannot be +// matched, and downloading it anyway would defeat the filter on exactly the +// videos a flaky extractor hides. The skip is recorded NON-permanent, so it +// lands in the existing retryable skippedByFilter bucket and the next run tries +// again — the opposite of a settled video. +const titleFilter: DownloadFilter = { + name: "titleFilter", + evaluate(ctx) { + const raw = ctx.channelConfig.downloadFilter; + const signature = downloadFilterSignature(raw); + if (!signature) return null; + const compiled = compileDownloadFilter(raw); + if (!compiled) { + ctx.onLog?.( + `Download filter is INERT: include=${JSON.stringify( + raw?.include ?? "", + )} exclude=${JSON.stringify( + raw?.exclude ?? "", + )} is not a valid regex — nothing will be filtered.\n`, + ); + return null; + } + const m = ctx.metadata; + if (!m) { + return { + skip: true, + filter: "titleFilter", + reason: + "no metadata to match the download filter against (failing closed)", + permanent: false, + signature, + }; + } + const text = downloadFilterText(m); + if (compiled.exclude && compiled.exclude.test(text)) { + return { + skip: true, + filter: "titleFilter", + reason: `title/description matches exclude /${compiled.exclude.source}/i`, + permanent: true, + signature, + }; + } + if (compiled.include && !compiled.include.test(text)) { + return { + skip: true, + filter: "titleFilter", + reason: `title/description does not match include /${compiled.include.source}/i`, + permanent: true, + signature, + }; + } + return null; + }, +}; + +const FILTERS: ReadonlyArray<DownloadFilter> = [titleFilter, skipLive]; // Evaluate every filter; the first one that says "skip" wins. Returns null when // the video passes all filters. @@ -81,3 +214,26 @@ export function evaluateDownloadFilters( } return null; } + +// THE ONE DEFINITION OF "SETTLED". Every reader — the snapshot's bucket, the +// sync page walk, the batch exclusion pass, the video page's badge — asks this +// and nothing else, because "is this video done with?" answered two ways is how +// a filtered channel ends up both settled and re-queued. +// +// Three things must hold: the outcome is a filter skip, it was recorded as +// permanent, and its signature is still the channel's. The third is what makes +// an edited pattern un-settle every video it decided, with no migration and no +// sweep. +export function isSettledByFilter( + outcome: + | Pick<DownloadOutcomeRecord, "status" | "filter"> + | null + | undefined, + config: Pick<ChannelConfig, "downloadFilter"> | null | undefined, +): boolean { + if (!outcome || outcome.status !== "skipped-filtered") return false; + const recorded = outcome.filter; + if (!recorded?.permanent || !recorded.signature) return false; + const current = downloadFilterSignature(config?.downloadFilter); + return current !== null && current === recorded.signature; +} diff --git a/common/lib/downloadOutcome-server.ts b/common/lib/downloadOutcome-server.ts @@ -1,5 +1,10 @@ import path from "node:path"; import { readFile, rename, writeFile } from "node:fs/promises"; +import type { ChannelConfig } from "./channelConfig"; +import { + downloadFilterSignature, + isSettledByFilter, +} from "./downloadFilters"; import { DOWNLOAD_OUTCOME_FILENAME, DOWNLOAD_OUTCOME_STATUS_VALUES, @@ -44,5 +49,18 @@ export async function writeDownloadOutcome( await rename(tmp, file); } +// Per-video-dir predicate for the download filter's settlement, shaped exactly +// like isDeferredAuthExcluded (runYtdlp.ts) so the batch/sync exclusion passes +// treat the two the same way. The signature check runs FIRST: a channel with no +// filter configured pays no file read at all, which matters because this is +// called once per URL on every page of every sync. +export async function isVideoSettledByFilter( + videoDir: string, + config: Pick<ChannelConfig, "downloadFilter"> | null | undefined, +): Promise<boolean> { + if (downloadFilterSignature(config?.downloadFilter) === null) return false; + return isSettledByFilter(await loadDownloadOutcome(videoDir), config); +} + // Narrow re-export so callers don't have to import from both modules. export type { DownloadOutcomeStatus }; diff --git a/common/lib/downloadOutcome.ts b/common/lib/downloadOutcome.ts @@ -99,7 +99,22 @@ export type DownloadOutcomeRecord = { }; // Set when status is "skipped-filtered": which app-level filter declined the // download and why. Recorded so the UI/log can explain the skip. - filter?: { name: string; reason: string }; + // + // `permanent` + `signature` are what make a skip SETTLE. skip-live's skip is + // transient (the stream ends and the video becomes downloadable), so it + // carries neither and every sync retries it. The per-channel download filter's + // skip is terminal FOR AS LONG AS THE FILTER SAYS SO: `signature` is the + // filter's identity (see downloadFilterSignature) and a settled video is one + // whose recorded signature still equals the channel's current one. Change + // either pattern and the signature changes, the video stops being settled, and + // it is re-evaluated on the next run — which is the whole reason this is a + // signature rather than an archive line. + filter?: { + name: string; + reason: string; + permanent?: boolean; + signature?: string; + }; }; export const DOWNLOAD_OUTCOME_FILENAME = "download-outcome.json"; diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts @@ -604,10 +604,13 @@ async function runManagedDownload( metadata, channelConfig: opts.channelConfig, settings: { skipLiveDownloads: opts.globalSkipLiveDownloads ?? true }, + onLog: opts.onLog, }); if (decision?.skip) { opts.onLog( - `Skipping ${canonicalId}: ${decision.reason} [filter=${decision.filter}]\n`, + `Skipping ${canonicalId}: ${decision.reason} [filter=${decision.filter}${ + decision.permanent ? ", settled" : "" + }]\n`, ); const finishedAt = new Date().toISOString(); const record: DownloadOutcomeRecord = { @@ -617,7 +620,16 @@ async function runManagedDownload( startedAt, finishedAt, attempts, - filter: { name: decision.filter, reason: decision.reason }, + // permanent/signature ride along verbatim: they are the filter's own + // verdict, and isSettledByFilter re-checks the signature on every read. + filter: { + name: decision.filter, + reason: decision.reason, + ...(decision.permanent === undefined + ? {} + : { permanent: decision.permanent }), + ...(decision.signature ? { signature: decision.signature } : {}), + }, }; try { await mkdir(videoDir, { recursive: true });