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:
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";