commit 1413de3744febdae605ef2ccd9ea73474e3a5d64
parent a7a21831d144eb1f033d38ef9f3fdee8b12ceef0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 02:12:50 -0400
review: say when the machine paused a channel
The storage watch forces a channel to `paused` when the drive its media is on
stops answering, and restores it when the drive comes back. Both halves are
right; what was missing was anywhere that SAID it had happened. The record
surfaced as one chip beside one channel's tier on /channels — so the way an
operator found out a platter had gone away was by noticing that a channel had
quietly stopped doing anything.
`common/views/review.ts` (`autoPausedRows`) is the pure builder, and the one
sentence is `autoPauseReasonOf`'s — the same wording the rack and the channel
page say, because three surfaces wording one machine decision three ways is how
an operator ends up unsure whether they are three problems. A channel a PERSON
paused is deliberately not a row: offering to "resume" a deliberate fence is how
someone undoes their own decision by accident.
It is on /review rather than /storage because the decision is about a CHANNEL
and the response is a judgement — resume it and accept a lane will keep skipping
it, or go plug the drive in. /storage is where the second one happens, so the
section links there instead of duplicating its controls.
Resume is NOT a second writer. `settings.channelPriority` has exactly one
(`saveChannelPriorityAction`), and the button is the named gesture
`setChannelTierAction` reached from another page. Setting a tier by hand is also
what clears the auto-pause record, so restoring the tier and dropping the
machine's note are one edit with nothing to remember. It posts the PREVIOUS
tier, not "normal": that tier is the whole content of the record, and resuming a
`high` channel as `normal` would silently demote it.
The section renders only when there is something to say. An empty state here
would be a permanent all-clear about a condition that is normally absent —
noise on every render of a page whose whole job is to be worth reading.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 380 insertions(+), 2 deletions(-)
diff --git a/common/views/review.test.ts b/common/views/review.test.ts
@@ -0,0 +1,60 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { autoPausedRows } from "./review";
+import type { ChannelPriority } from "../lib/channelPriority";
+
+function model(
+ channels: ChannelPriority["channels"],
+): ChannelPriority {
+ return { focus: { kind: "none" }, channels };
+}
+
+test("only auto-paused channels are rows, and they carry where Resume puts them back", () => {
+ const rows = autoPausedRows(
+ model({
+ zeta: {
+ tier: "paused",
+ autoPaused: {
+ reason: "storage",
+ since: "2026-09-19T04:05:06.000Z",
+ previousTier: "high",
+ },
+ },
+ // Paused BY A PERSON. It must not appear: /review is for decisions the
+ // machine took, and offering to "resume" a deliberate pause is how an
+ // operator undoes their own fence by accident.
+ alpha: { tier: "paused" },
+ beta: { tier: "normal" },
+ }),
+ );
+ assert.deepEqual(
+ rows.map((r) => r.slug),
+ ["zeta"],
+ );
+ assert.equal(rows[0].previousTier, "high");
+ assert.equal(rows[0].reason, "storage");
+ assert.match(rows[0].detail, /drive that is not there/);
+ assert.match(rows[0].detail, /2026-09-19/);
+});
+
+test("the order is the slug order, so an auto-refresh cannot reshuffle the list", () => {
+ const entry = (previousTier: "normal" | "high") => ({
+ tier: "paused" as const,
+ autoPaused: {
+ reason: "storage" as const,
+ since: "2026-09-19T00:00:00.000Z",
+ previousTier,
+ },
+ });
+ const rows = autoPausedRows(
+ model({ zeta: entry("high"), alpha: entry("normal"), mid: entry("normal") }),
+ );
+ assert.deepEqual(
+ rows.map((r) => r.slug),
+ ["alpha", "mid", "zeta"],
+ );
+});
+
+test("an empty document is no rows, not a section full of nothing", () => {
+ assert.deepEqual(autoPausedRows(model({})), []);
+});
diff --git a/common/views/review.ts b/common/views/review.ts
@@ -0,0 +1,58 @@
+import {
+ autoPauseReasonOf,
+ autoPausedSlugs,
+ type ChannelPriority,
+ type StoredChannelTier,
+} from "../lib/channelPriority";
+
+// THE /review PAYLOAD'S THIRD SECTION: what the MACHINE decided on its own.
+//
+// /review is "findings a human decides, not work a lane runs", and an
+// auto-pause is exactly that shape from the other direction: the storage watch
+// took a decision while nobody was looking, and it stands until either the
+// drive comes back or a person overrides it. Until now the only place that
+// decision surfaced was a chip beside one channel's tier on /channels — so an
+// operator whose platter went away found out by noticing that a channel had
+// quietly stopped doing anything.
+//
+// PURE, like everything in this directory: the model arrives as an argument.
+//
+// ONE WORDING. The sentence is `autoPauseReasonOf`'s, the same one the rack and
+// the channel page say, because three surfaces wording the same machine
+// decision three ways is how an operator ends up unsure whether they are three
+// different problems.
+
+export type AutoPausedRow = {
+ slug: string;
+ // Which machine decided. One value today; a union so a second reason has
+ // somewhere to go and a row can say which rather than "automatic".
+ reason: "storage";
+ // ISO, as stored. The renderer decides how much of it to show.
+ since: string;
+ // WHERE RESUME PUTS IT BACK. Carried because it is not derivable from
+ // anything once the tier was overwritten with `paused` — that is the whole
+ // content of the record — and because the Resume control has to post it.
+ previousTier: StoredChannelTier;
+ // The one sentence, already assembled.
+ detail: string;
+};
+
+export function autoPausedRows(model: ChannelPriority): AutoPausedRow[] {
+ const rows: AutoPausedRow[] = [];
+ // `autoPausedSlugs` sorts, so the list is stable across renders — a section
+ // that reorders itself under an auto-refresh is one an operator cannot click.
+ for (const slug of autoPausedSlugs(model)) {
+ const auto = model.channels[slug]?.autoPaused;
+ if (!auto) continue;
+ rows.push({
+ slug,
+ reason: auto.reason,
+ since: auto.since,
+ previousTier: auto.previousTier,
+ // Never null here — the entry carries a record, which is exactly the
+ // condition `autoPauseReasonOf` returns a sentence for.
+ detail: autoPauseReasonOf(model, slug) ?? "",
+ });
+ }
+ return rows;
+}
diff --git a/editor/app/review/components/ResumeAutoPausedButton.tsx b/editor/app/review/components/ResumeAutoPausedButton.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+// RESUME A CHANNEL THE MACHINE PAUSED.
+//
+// NOT A SECOND WRITER. `settings.channelPriority` has exactly one
+// (`saveChannelPriorityAction`, S3), and this is the named gesture
+// `setChannelTierAction` reached from a different page — the same writer, one
+// more control. Setting the tier by hand is also what CLEARS the auto-pause
+// record (`clearAutoPause` in the writer), so there is nothing extra to do
+// here: restoring the tier and dropping the machine's note are one edit.
+//
+// IT POSTS THE PREVIOUS TIER, not "normal". The whole content of the record is
+// the tier the channel HAD, and resuming a `high` channel to `normal` would
+// silently demote it — which is the kind of thing an operator discovers three
+// weeks later by wondering why a lane never gets to it.
+//
+// The drive may still be away, and that is fine: the next watch pass will
+// auto-pause it again (and the record it writes then is a fresh one). What the
+// operator's word buys is that it is THEIR decision until then.
+//
+// A FAILED WRITE MUST SAY SO — the writer returns `{error}` before it
+// revalidates, so a swallowed result would read as "the click did nothing".
+
+import { useState, useTransition } from "react";
+import type { StoredChannelTier } from "yt-dlp-transcript-common/lib/channelPriority";
+import { setChannelTierAction } from "../../channels/actions";
+
+export function ResumeAutoPausedButton({
+ slug,
+ previousTier,
+}: {
+ slug: string;
+ previousTier: StoredChannelTier;
+}): React.ReactNode {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ return (
+ <>
+ <button
+ type="button"
+ aria-label={`resume ${slug}`}
+ disabled={pending}
+ onClick={() =>
+ startTransition(async () => {
+ try {
+ const result = await setChannelTierAction([slug], previousTier);
+ setError(result?.error ?? null);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ })
+ }
+ className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ {pending ? "Resuming…" : `Resume as ${previousTier}`}
+ </button>
+ {error && (
+ <span
+ role="alert"
+ aria-label={`resume error for ${slug}`}
+ className="text-xs text-destructive"
+ >
+ {error}
+ </span>
+ )}
+ </>
+ );
+}
diff --git a/editor/app/review/lib/loadReview.ts b/editor/app/review/lib/loadReview.ts
@@ -12,6 +12,11 @@ import {
readMediaScanOverrides,
readMediaScanReport,
} from "yt-dlp-transcript-common/controller/scanCorruptMedia";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ autoPausedRows,
+ type AutoPausedRow,
+} from "yt-dlp-transcript-common/views/review";
import { getDuplicateReport } from "../../lib/requestCache";
// The half of the old actionable summary that is a HUMAN judgement rather than
@@ -33,6 +38,21 @@ export type ReviewSummary = {
// all-clear it has no evidence for.
mediaScan: MediaScanReport | null;
mediaScanOverrides: MediaScanOverrides;
+ // WHAT THE MACHINE DECIDED ON ITS OWN. The storage watch forces a channel to
+ // `paused` when the drive its media is on stops being there; until this, the
+ // only surface saying so was a chip beside that one channel's tier on
+ // /channels, so an operator whose platter went away found out by noticing a
+ // channel had quietly stopped doing anything.
+ //
+ // It belongs here and not on /storage because the decision is about a
+ // CHANNEL, and because the thing to do about it — resume, or leave it — is a
+ // human judgement, which is what this page is. /storage is where the drive
+ // gets fixed, and the section links there.
+ //
+ // FROM SETTINGS, NOT FROM DISK: `settings.channelPriority` is the record, and
+ // reading it costs nothing (getSettings is memoized and every other page on
+ // this render already called it).
+ autoPaused: AutoPausedRow[];
};
export async function loadReviewSummary(paths: Paths): Promise<ReviewSummary> {
@@ -43,5 +63,11 @@ export async function loadReviewSummary(paths: Paths): Promise<ReviewSummary> {
readMediaScanReport(paths),
readMediaScanOverrides(paths),
]);
- return { duplicates, duplicateOverrides, mediaScan, mediaScanOverrides };
+ return {
+ duplicates,
+ duplicateOverrides,
+ mediaScan,
+ mediaScanOverrides,
+ autoPaused: autoPausedRows(getSettings().channelPriority),
+ };
}
diff --git a/editor/app/review/page.tsx b/editor/app/review/page.tsx
@@ -13,7 +13,9 @@ import type {
DuplicateOverrides,
DuplicateReport,
} from "yt-dlp-transcript-common/lib/duplicates";
+import type { AutoPausedRow } from "yt-dlp-transcript-common/views/review";
import { loadReviewSummary } from "./lib/loadReview";
+import { ResumeAutoPausedButton } from "./components/ResumeAutoPausedButton";
import { RunDuplicateDetectionButton } from "./components/RunDuplicateDetectionButton";
import { RunMediaScanButton } from "./components/RunMediaScanButton";
import { MediaScanFindingRow } from "./components/MediaScanFindingRow";
@@ -35,6 +37,7 @@ export default async function ReviewPage() {
<p className="text-sm text-muted-foreground">
Corpus review — findings a human decides, not work a lane runs.
</p>
+ <AutoPausedSection rows={review.autoPaused} />
<DuplicatesSection
report={review.duplicates}
overrides={review.duplicateOverrides}
@@ -47,6 +50,86 @@ export default async function ReviewPage() {
);
}
+// CHANNELS THE MACHINE PAUSED WHILE NOBODY WAS LOOKING.
+//
+// The storage watch forces a channel to `paused` when the drive its media is on
+// stops being there, and restores it when the drive comes back. Both halves are
+// right; what was missing was anywhere that said it had happened. Until this,
+// the record surfaced as one chip beside one channel's tier on /channels — so
+// the way you found out a platter had gone away was by noticing that a channel
+// had quietly stopped doing anything.
+//
+// IT IS A REVIEW FINDING, not a storage row: the decision is about a CHANNEL,
+// and what to do about it is a judgement — resume it and accept that a lane
+// will keep skipping it, or go plug the drive in. /storage is where the second
+// one happens, so the section links there rather than duplicating its controls.
+//
+// RENDERED ONLY WHEN THERE IS SOMETHING TO SAY. Unlike the two sections below
+// it, an empty state here would be a permanent all-clear about a condition that
+// is normally absent — noise on every render of a page whose whole job is to be
+// worth reading.
+function AutoPausedSection({ rows }: { rows: AutoPausedRow[] }) {
+ if (rows.length === 0) return null;
+ return (
+ <section aria-label="auto-paused channels" className="flex flex-col gap-2">
+ <div className="flex items-center justify-between flex-wrap gap-2">
+ <h2 className="text-lg font-semibold">
+ Auto-paused channels ({rows.length})
+ </h2>
+ <Link
+ href="/storage"
+ className="text-sm underline hover:text-foreground"
+ >
+ Storage
+ </Link>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ The storage watch paused these because the drive holding their media
+ stopped answering. Each returns to its previous tier on its own when the
+ drive is back — resume one only to override that, which is a decision
+ the watch will then respect until the next time the drive goes away.
+ </p>
+ <ul className="flex flex-col gap-2">
+ {rows.map((row) => (
+ <li
+ key={row.slug}
+ aria-label={`auto-paused ${row.slug}`}
+ className="border border-border rounded-md p-3 flex flex-col gap-2"
+ >
+ <div className="flex items-center gap-2 flex-wrap text-sm">
+ <Link
+ href={`/channels/${row.slug}`}
+ className="underline hover:text-foreground"
+ >
+ {row.slug}
+ </Link>
+ <Badge>{row.reason}</Badge>
+ {row.since && (
+ <span className="text-xs text-muted-foreground">
+ since{" "}
+ <time dateTime={row.since}>{row.since.slice(0, 10)}</time>
+ </span>
+ )}
+ <span className="ml-auto flex items-center gap-2">
+ <ResumeAutoPausedButton
+ slug={row.slug}
+ previousTier={row.previousTier}
+ />
+ </span>
+ </div>
+ <p
+ aria-label={`auto-paused reason for ${row.slug}`}
+ className="text-xs text-muted-foreground"
+ >
+ {row.detail}
+ </p>
+ </li>
+ ))}
+ </ul>
+ </section>
+ );
+}
+
// Corrupt media already on disk. Flag-only, exactly like the duplicates section
// above it — the probes have existed since the audio-checked download landed;
// what was missing was ever running them against files that are already here.
diff --git a/editor/e2e/review.spec.ts b/editor/e2e/review.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
-import { resetData } from "./helpers";
+import { readJson, resetData, writeSettings } from "./helpers";
// /review is the page for the findings a PERSON decides about: duplicate
// clusters and media-integrity findings. Both are flag-only and both carry a
@@ -34,3 +34,86 @@ test("Review is in the Corpus group of the sidebar", async ({ page }) => {
page.locator("aside").getByRole("link", { name: /^Review$/ }),
).toHaveAttribute("href", "/review");
});
+
+// AN AUTO-PAUSE IS A REVIEW FINDING. The storage watch forces a channel to
+// `paused` when the drive its media is on stops answering, and restores it when
+// the drive comes back. Both halves were right; what was missing was anywhere
+// that said it had happened — the record surfaced only as a chip beside that
+// one channel's tier on /channels, so the way an operator found out a platter
+// had gone away was by noticing a channel had quietly stopped doing anything.
+test("an auto-paused channel is listed with its reason", async ({ page }) => {
+ await resetData("one-youtube-channel-with-data");
+ await writeSettings({
+ adminTitle: "Test Admin",
+ channelPriority: {
+ focus: { kind: "none" },
+ channels: {
+ "test-youtube": {
+ tier: "paused",
+ autoPaused: {
+ reason: "storage",
+ since: "2026-09-19T04:05:06.000Z",
+ previousTier: "high",
+ },
+ },
+ },
+ },
+ });
+
+ await page.goto("/review");
+ const section = page.getByLabel("auto-paused channels");
+ await expect(section).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Auto-paused channels (1)" }),
+ ).toBeVisible();
+ const row = page.getByLabel("auto-paused test-youtube");
+ await expect(row).toBeVisible();
+ await expect(row).toContainText("2026-09-19");
+ // ONE WORDING, shared with the rack and the channel page (autoPauseReasonOf).
+ await expect(
+ page.getByLabel("auto-paused reason for test-youtube"),
+ ).toContainText("drive that is not there");
+ // It offers to put the channel back where it WAS, not at "normal" — the
+ // previous tier is the whole content of the record, and resuming a `high`
+ // channel as `normal` would silently demote it.
+ const resume = page.getByLabel("resume test-youtube");
+ await expect(resume).toHaveText("Resume as high");
+
+ // And the fix for the actual problem is one link away.
+ await expect(section.getByRole("link", { name: "Storage" })).toHaveAttribute(
+ "href",
+ "/storage",
+ );
+
+ // --- the operator's word wins ------------------------------------------
+ // The writer is the ONE writer (saveChannelPriorityAction), and setting a
+ // tier by hand is what clears the machine's record — so this is not a second
+ // path that has to remember to clean up after itself.
+ await resume.click();
+ await expect
+ .poll(
+ async () => {
+ const s = await readJson<{
+ channelPriority?: {
+ channels: Record<string, { tier?: string; autoPaused?: unknown }>;
+ };
+ }>("test-settings.json");
+ return s.channelPriority?.channels["test-youtube"];
+ },
+ { timeout: 20_000 },
+ )
+ .toMatchObject({ tier: "high" });
+ const after = await readJson<{
+ channelPriority?: {
+ channels: Record<string, { autoPaused?: unknown }>;
+ };
+ }>("test-settings.json");
+ expect(
+ after.channelPriority?.channels["test-youtube"]?.autoPaused,
+ ).toBe(undefined);
+
+ // The section goes away entirely rather than rendering a permanent all-clear
+ // about a condition that is normally absent.
+ await page.goto("/review");
+ await expect(page.getByLabel("auto-paused channels")).toHaveCount(0);
+});