Archilyzer · Source

archilyzer

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

commit 6248309257a83e732b4e018f25bc582e54c1339b
parent 3e8087a013c473333befe1c5f6ca00a39836f5d2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 12:20:59 -0400

backfill: a channel paused for transcription keeps its audio

THE DATA-LOSS FIX (the S0/S1 review, finding 1). `decideKeep` asked
`policyDrawsBucket` on the STORED transcription root. Once the priority model
says anything that root is COMPILED from it, and a channel paused for
transcription has no leaf in the compiled tree at all — so the honest reading
of the dispatched tree answers "no-leaf" for exactly the channels an operator
has just put on hold, and every re-acquired audio file they produce is
unlinked. A pause becoming data loss, silently, one file at a time.

Channel priority enters this decision twice, and the order is the fix:

  paused for transcription -> KEEP, before the leaf question is asked at all.
  A hold is never a stop; audio kept for a held lane is audio waiting for the
  hold to end. It still ranks BELOW "nothing landed" and "not this bucket's
  shape" — a pause keeps audio the lane would otherwise draw, not audio no
  leaf of any tier would ever touch.

  otherwise -> the leaf question is asked of the COMPILED root, because while
  a model exists that is what the runner dispatches from. Compiled for this
  one slug: every non-paused channel gets one bare leaf plus the trailing
  catch-all, and which tier group holds it changes the order, never whether a
  leaf draws the bucket — so a cleanup does not list 68 configs to find out.

`isDefaultChannelPriority` moves to lib/channelPriority.ts, where the model is.
Five callers in two packages ask it and they must all ask it the same way, and
a controller cannot import the runner without opening an import cycle. S1 kept
a copy in autoRunner and S4 a second in the editor; both are gone.

common 984/984.

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

Diffstat:
Mcommon/controller/autoRunner.test.ts | 2+-
Mcommon/controller/autoRunner.ts | 15+--------------
Mcommon/controller/backfillReacquire.test.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/backfillReacquire.ts | 49++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/channelPriority.ts | 16++++++++++++++++
Meditor/app/operations/channelPriorityView.ts | 2+-
6 files changed, 172 insertions(+), 17 deletions(-)

diff --git a/common/controller/autoRunner.test.ts b/common/controller/autoRunner.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { focusHoldLine, - isDefaultChannelPriority, laneDispatchRoot, makeFocusHoldReporter, priorityContextFor, @@ -13,6 +12,7 @@ import { } from "./autoRunner"; import { defaultChannelPriority, + isDefaultChannelPriority, sanitizeChannelPriority, type ChannelPriority, type FocusSummary, diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -84,6 +84,7 @@ import { compileLaneRoot, focusSummary, isChannelPaused, + isDefaultChannelPriority, resolveFocusSlugs, } from "../lib/channelPriority"; import { siteChannelIndex } from "../lib/site"; @@ -384,20 +385,6 @@ type PriorityContext = PriorityDispatchContext & { let priorityContext: PriorityContext | null = null; -// No focus and no per-channel entry: the document says nothing, so the compiler -// must not run. Local to the runner rather than in lib/channelPriority.ts -// because it is a dispatch-side question ("is there anything to compile"), not -// part of the model's contract — and EXPORTED because it is the gate three -// other callers have to ask with exactly this spelling: the status payload -// (channelPriorityView.ts), the one writer's legacy seed and the two actions -// that may no longer write a lane's root while a model exists. S4 shipped a -// copy of it; S5 deleted that copy. One definition. -export function isDefaultChannelPriority(model: ChannelPriority): boolean { - return ( - model.focus.kind === "none" && Object.keys(model.channels).length === 0 - ); -} - // `settings` is a PARAMETER, not a read: both call sites already hold the // settings object for this tick (the runner's `next()` reads it to see // `enabled` and `snoozeUntil`; `computeLeafPending` reads it for the policy), diff --git a/common/controller/backfillReacquire.test.ts b/common/controller/backfillReacquire.test.ts @@ -16,6 +16,11 @@ import { VTT_FILENAME, } from "../lib/videoStatus"; import type { ChannelConfig } from "../lib/channelConfig"; +import { + compileLaneRoot, + defaultChannelPriority, + sanitizeChannelPriority, +} from "../lib/channelPriority"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test common/controller/backfillReacquire.test.ts @@ -175,6 +180,9 @@ function keepInput(over: Partial<KeepInput> = {}): KeepInput { snoozeUntil: null, root: ALL_LEAF, }, + // No channel priority set: the stored tree stands, byte for byte, which is + // every case below except the two that name a tier. + priority: defaultChannelPriority(), channel: { slug: "the-channel", platform: "youtube" }, disk: { freeBytes: 100 * GB, minFreeDiskGB: 20, resumeMarginGB: 5 }, ...over, @@ -357,3 +365,100 @@ test("decideKeep never mutates its input", () => { decideKeep(input); assert.equal(JSON.stringify(input), before); }); + +// --- A HOLD IS NEVER A STOP, and this is where it would have become one ----- +// +// The bug this closes (the S0/S1 review, finding 1): once the priority model +// says anything, `autoQueue.transcription.root` is COMPILED from it and a +// channel paused for transcription has no leaf in that tree. Decided on the +// compiled tree with no pause branch, every such channel answers "no-leaf" and +// every re-acquired audio file it produces is UNLINKED — a pause turning into +// data loss. Decided on the STORED tree it is just as wrong the other way: the +// stored tree is not what the runner dispatches from. + +test("decideKeep: a channel paused for transcription KEEPS its audio", () => { + const priority = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { "the-channel": { tier: "paused" } }, + }); + assert.deepEqual(decideKeep(keepInput({ priority })), { + keep: true, + reason: "paused", + }); + // The compiled tree really does omit it — this is the answer the naive + // reading would have produced, and it is why the pause is asked first. + assert.equal( + JSON.stringify( + compileLaneRoot("transcription", priority, ["the-channel"], []), + ).includes("the-channel"), + false, + ); +}); + +test("decideKeep: a PER-OPERATION pause is per operation", () => { + // What all 15 live `excludeFromSync` channels migrate to: stop syncing, keep + // everything else. The transcription lane is untouched, so this is an + // ordinary hand-off and not a keep-for-a-hold. + const syncPaused = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { "the-channel": { tier: "normal", overrides: { sync: "paused" } } }, + }); + assert.deepEqual(decideKeep(keepInput({ priority: syncPaused })), { + keep: true, + reason: "hand-off", + }); + // And the inverse: paused for DOWNLOAD only leaves transcription running too. + const downloadPaused = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { + "the-channel": { tier: "normal", overrides: { download: "paused" } }, + }, + }); + assert.deepEqual(decideKeep(keepInput({ priority: downloadPaused })), { + keep: true, + reason: "hand-off", + }); +}); + +test("decideKeep: a non-paused channel is decided on the COMPILED tree", () => { + // A model that says something, and a stored root that says nothing at all. + // The stored tree would answer "no leaf covers this channel"; the compiled + // one gives every non-paused channel a bare leaf, and with replaceAutoSubs + // on that leaf draws the opt-in bucket. The hand-off is the right answer + // because the compiled tree is the one the runner dispatches from. + const priority = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { "someone-else": { tier: "paused" } }, + }); + const d = decideKeep( + keepInput({ + priority, + policy: { + enabled: true, + replaceAutoSubs: true, + snoozeUntil: null, + root: { id: "root", mode: "strict", children: [] }, + }, + }), + ); + assert.deepEqual(d, { keep: true, reason: "hand-off" }); +}); + +test("decideKeep: the pause does not outrank a run in progress or a missing file", () => { + const priority = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { "the-channel": { tier: "paused" } }, + }); + // Nothing landed: there is no audio to hold for anyone. + assert.deepEqual(decideKeep(keepInput({ priority, hasAudio: false })), { + keep: false, + reason: "no-audio", + }); + // And a paused channel whose captions are not ASR-only is not this bucket's + // shape either — the pause keeps audio the lane would otherwise draw, not + // audio no leaf of any tier would ever touch. + assert.deepEqual(decideKeep(keepInput({ priority, autoSubsOnly: false })), { + keep: false, + reason: "not-auto-subs-only", + }); +}); diff --git a/common/controller/backfillReacquire.ts b/common/controller/backfillReacquire.ts @@ -72,6 +72,12 @@ import type { ChannelConfig } from "../lib/channelConfig"; import { detectPlatform, type Platform } from "../lib/platform"; import { isAutoSubsOnly } from "../lib/subtitleProvenance"; import { + compileLaneRoot, + isChannelPaused, + isDefaultChannelPriority, + type ChannelPriority, +} from "../lib/channelPriority"; +import { policyDrawsBucket, type AutoQueuePolicy, } from "../jobs/autoQueuePolicy"; @@ -93,6 +99,9 @@ export type KeepReason = | "do-not-clean" // A transcription task is running on this video right now. | "in-flight" + // The channel is PAUSED for transcription. A hold is never a stop: the audio + // is kept for the pause to end, not deleted because it started. + | "paused" // autoQueue.transcription would draw this video from downloadedAutoSubsOnly. | "hand-off"; @@ -332,6 +341,9 @@ export type KeepInput = { AutoQueuePolicy, "enabled" | "replaceAutoSubs" | "snoozeUntil" | "root" >; + // The channel-priority document. Two questions are asked of it, and both are + // about the tree the runner would ACTUALLY dispatch from — see decideKeep. + priority: ChannelPriority; channel: { slug: string; platform: Platform | null }; disk: { freeBytes: number; minFreeDiskGB: number; resumeMarginGB: number }; }; @@ -348,9 +360,28 @@ export type KeepInput = { // !policy.enabled the runner is off; audio kept for it would sit forever // snoozeUntil idem, temporarily (a lapsed snooze is already normalized // to null by sanitizePolicy, so non-null means still on) +// paused the CHANNEL is held for transcription — KEEP (below) // !policyDrawsBucket no leaf covering this channel draws downloadedAutoSubsOnly // disk the runner itself would refuse to fetch this much // +// CHANNEL PRIORITY ENTERS TWICE, AND THE FIRST ONE IS WHY. Once the priority +// model says anything, `autoQueue.transcription.root` is COMPILED from it +// (lib/channelPriority.ts) and a channel paused for transcription has NO LEAF +// in that tree at all. Asked naively, `policyDrawsBucket` would then answer +// "no leaf" for exactly the channels the operator just put on hold, and this +// function would UNLINK their audio — a pause turning into data loss, silently, +// one re-acquired file at a time. So the pause is asked FIRST and it KEEPS: +// a hold is never a stop (controller/operationBatch.ts:22-25), and audio kept +// for a paused lane is audio waiting for the pause to end. +// +// The second is the tree itself: while a model exists the runner does not +// dispatch from the STORED root, so neither may this decision. The compiled +// root for this one channel is the same answer the real compiled tree gives — +// every non-paused channel gets one bare leaf plus the trailing catch-all, and +// WHICH tier group holds it changes the order, never whether a leaf draws the +// bucket — so the compile is done with this slug alone rather than by listing +// 68 configs inside a cleanup. +// // The disk bar is the RESUME mark (floor + margin), not the floor: a hand-off is // a download the backfill was about to give back, and the download runner resumes // only at resumeBytes — so the backfill must never keep audio the runner would @@ -367,10 +398,21 @@ export function decideKeep(input: KeepInput): KeepDecision { if (input.policy.snoozeUntil != null) { return { keep: false, reason: "policy-snoozed" }; } + if (isChannelPaused(input.priority, input.channel.slug, "transcription")) { + return { keep: true, reason: "paused" }; + } + const root = isDefaultChannelPriority(input.priority) + ? input.policy.root + : compileLaneRoot( + "transcription", + input.priority, + [input.channel.slug], + [], + ); if ( !policyDrawsBucket( "transcription", - input.policy, + { ...input.policy, root }, input.channel, "downloadedAutoSubsOnly", ) @@ -427,6 +469,7 @@ function buildCleanup( hasAudio: files.audioFiles.length > 0, autoSubsOnly: await isAutoSubsOnly(videoDir, files), policy: settings.autoQueue.transcription, + priority: settings.channelPriority, channel: { slug: ctx.channelSlug, platform: ctx.platform }, disk: { freeBytes: await getFreeBytes(ctx.paths.transcriptsDir), @@ -444,6 +487,10 @@ function buildCleanup( log( `Keeping re-acquired media for ${videoId}: a transcription is running on it (${list}).`, ); + } else if (decision.reason === "paused") { + log( + `Keeping re-acquired media for ${videoId}: ${ctx.channelSlug} is paused for transcription, and a hold is not a stop (${list}).`, + ); } else { log( `Keeping re-acquired media for ${videoId}: handed to auto-transcribe, which will replace the auto-captions (${list}).`, diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts @@ -335,6 +335,22 @@ export function channelsForOperation( // Tier order as a sortable number: focus 0, normal 1, low 2, paused 3. The sync // scheduler sorts by this, then `rank`, then most-overdue-first — so a focus // channel due by a minute outranks a low channel due by a day. +// THE GATE ON THE WHOLE COMPILER, and the plan's "absent document = today's +// behaviour, byte for byte": no focus and no per-channel entry means the +// document says nothing, the compiler never runs, and the stored trees stand. +// +// It lives HERE, in the model, because five callers in two packages ask it and +// they must all ask it the same way: the runner (`laneDispatchRoot`), the +// status payload, the one writer (both for its legacy seed and for the rule +// that a default document never overwrites a stored tree), the backfill's +// keep decision, and the two actions that may no longer write a lane's root. +// S1 kept a copy in controller/autoRunner.ts and S4 a second in the editor; +// S5 deleted both. A controller may not import the runner without opening an +// import cycle, which is the other half of why the model owns it. +export function isDefaultChannelPriority(model: ChannelPriority): boolean { + return model.focus.kind === "none" && Object.keys(model.channels).length === 0; +} + export function tierOrder(tier: ChannelTier): number { const i = CHANNEL_TIERS.indexOf(tier); return i < 0 ? CHANNEL_TIERS.length : i; diff --git a/editor/app/operations/channelPriorityView.ts b/editor/app/operations/channelPriorityView.ts @@ -2,13 +2,13 @@ import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { listSites, siteChannelIndex } from "yt-dlp-transcript-common/lib/site"; import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels"; -import { isDefaultChannelPriority } from "yt-dlp-transcript-common/controller/autoRunner"; import { type ChannelPriority, type FocusSummary, type PendingByLeaf, type SiteChannelIndex, focusSummary, + isDefaultChannelPriority, resolveFocusSlugs, } from "yt-dlp-transcript-common/lib/channelPriority";