commit 64549a205378f530760b86df53ef451ed2c193a1
parent eabbce526a21fa2b58c696c4f2f7e69bf24ea352
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 19:14:59 -0400
views: the widget's two route-resident builders become pure folds
`/api/widget/actionable` and `/api/widget/cleanable` were the last two payload
builders living inside a route file: the filter/sort that decides which channels
"need work", and the sum behind the cleanable badge. Both are now
`common/views/{widgetActionable,cleanable}.ts` — zero imports, unit-tested — and
the routes are the census read and the snapshot read that feed them.
The wire types moved with them, so nothing imports a payload type out of a route
any more: the seven client/page importers (`app/page.tsx`, `DashboardCockpit`,
`PipelineBand`, `NeedsWorkPanel`, `LaneDeck`, `WidgetControls`, `MonitorWidget`)
name `yt-dlp-transcript-common/views/*` instead. Same type names, same fields,
same order on the wire.
The four `actionable*Count` helpers stay in `lib/actionable/loadActionable.ts`:
they know the availability exclusions, which is corpus knowledge, not shape.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
13 files changed, 215 insertions(+), 55 deletions(-)
diff --git a/common/views/cleanable.test.ts b/common/views/cleanable.test.ts
@@ -0,0 +1,32 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { buildCleanablePayload } from "./cleanable";
+
+// The whole builder is a sum and a wrap, which is why it is here rather than in
+// a route: the number in the widget's badge and the numbers in its list are now
+// provably the same numbers.
+
+test("bytes is the sum of the rows, and the rows pass through in order", () => {
+ const payload = buildCleanablePayload([
+ { slug: "big", count: 31, bytes: 3_000 },
+ { slug: "small", count: 2, bytes: 500 },
+ ]);
+ assert.equal(payload.bytes, 3_500);
+ assert.deepEqual(
+ payload.channels.map((c) => c.slug),
+ ["big", "small"],
+ );
+ assert.equal(payload.channels[0].count, 31);
+});
+
+test("no rows is zero bytes and an empty list, not a missing field", () => {
+ assert.deepEqual(buildCleanablePayload([]), { bytes: 0, channels: [] });
+});
+
+test("the input array is not mutated or aliased", () => {
+ const rows = [{ slug: "a", count: 1, bytes: 10 }];
+ const payload = buildCleanablePayload(rows);
+ assert.notEqual(payload.channels, rows);
+ payload.channels.push({ slug: "b", count: 1, bytes: 1 });
+ assert.equal(rows.length, 1);
+});
diff --git a/common/views/cleanable.ts b/common/views/cleanable.ts
@@ -0,0 +1,42 @@
+// The monitor widget's cleanable-data indicator AND its "Needs cleaning"
+// channel list, as a pure fold.
+//
+// The per-channel rows come from one snapshot read (the editor's
+// `cleanup/lib/loadCleanup.ts`, which already honours the per-channel
+// `excludeFromCleanup` flag and drops empty rows), so serving both the total
+// and the list costs no extra I/O and no second poll. `bytes` is their sum —
+// and the sum lives HERE, beside the type it sums into, rather than in the
+// route that used to hold both.
+//
+// ZERO IMPORTS, deliberately: the input row is re-declared structurally below
+// instead of imported, because it is an editor type and a view may not reach
+// into the app. The editor asserts the two stay assignable at compile time
+// (`editor/app/api/view/views.test.ts`).
+
+// One channel's reclaimable-audio row: the structural twin of
+// `CleanableChannelRow` in the editor's cleanup loader, and the wire type the
+// widget's strip renders.
+export type CleanableChannel = {
+ slug: string;
+ // Videos in the transcribed-with-audio bucket (the sweep's targets).
+ count: number;
+ // Reclaim estimate for the primary "clean audio" sweep, net of protection.
+ bytes: number;
+};
+
+export type CleanablePayload = {
+ bytes: number;
+ channels: CleanableChannel[];
+};
+
+// The rows arrive already filtered and sorted by reclaim, descending — that
+// ordering is the loader's, and this fold preserves it rather than re-deciding
+// it, so the widget's list and the sidebar badge can never disagree about which
+// channel is the biggest.
+export function buildCleanablePayload(
+ rows: readonly CleanableChannel[],
+): CleanablePayload {
+ const channels = [...rows];
+ const bytes = channels.reduce((sum, c) => sum + c.bytes, 0);
+ return { bytes, channels };
+}
diff --git a/common/views/widgetActionable.test.ts b/common/views/widgetActionable.test.ts
@@ -0,0 +1,67 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { buildWidgetActionablePayload } from "./widgetActionable";
+
+const row = (
+ slug: string,
+ undownloaded: number,
+ untranscribed: number,
+ digestReachable = 0,
+) => ({ slug, undownloaded, untranscribed, digestReachable });
+
+test("a channel with neither backlog is dropped", () => {
+ const payload = buildWidgetActionablePayload([
+ row("idle", 0, 0, 9_000),
+ row("busy", 1, 0),
+ ]);
+ assert.deepEqual(
+ payload.channels.map((c) => c.slug),
+ ["busy"],
+ );
+});
+
+test("digestReachable is carried but never makes a channel actionable", () => {
+ const payload = buildWidgetActionablePayload([row("digest-only", 0, 0, 77_000)]);
+ assert.deepEqual(payload.channels, []);
+});
+
+test("the larger combined backlog sorts first", () => {
+ const payload = buildWidgetActionablePayload([
+ row("third", 1, 1),
+ row("first", 10, 30),
+ row("second", 20, 5),
+ ]);
+ assert.deepEqual(
+ payload.channels.map((c) => c.slug),
+ ["first", "second", "third"],
+ );
+});
+
+test("either bucket alone is enough to be listed", () => {
+ const payload = buildWidgetActionablePayload([
+ row("downloads-only", 3, 0),
+ row("transcripts-only", 0, 4),
+ ]);
+ assert.deepEqual(
+ payload.channels.map((c) => c.slug),
+ ["transcripts-only", "downloads-only"],
+ );
+});
+
+test("a tie keeps the order the census handed over", () => {
+ const payload = buildWidgetActionablePayload([
+ row("alpha", 2, 2),
+ row("beta", 3, 1),
+ ]);
+ assert.deepEqual(
+ payload.channels.map((c) => c.slug),
+ ["alpha", "beta"],
+ );
+});
+
+test("every field survives the fold", () => {
+ const payload = buildWidgetActionablePayload([row("a", 2, 3, 400)]);
+ assert.deepEqual(payload.channels, [
+ { slug: "a", undownloaded: 2, untranscribed: 3, digestReachable: 400 },
+ ]);
+});
diff --git a/common/views/widgetActionable.ts b/common/views/widgetActionable.ts
@@ -0,0 +1,47 @@
+// The monitor widget's optional "Needs work" strip, as a pure fold.
+//
+// The same actionable census the operation pages and the dashboard draw,
+// reduced to the two buckets a widget acts on — videos to download and videos
+// to transcribe — for each channel that has any, sorted by the larger backlog
+// first. The counting itself stays in the editor
+// (`lib/actionable/loadActionable.ts`, whose four count helpers know the
+// availability exclusions); what moved here is the shape and the two decisions
+// about it: which channels appear, and in what order.
+//
+// THE PATH KEEPS ITS NAME. /actionable the page is gone; /api/widget/actionable
+// is a wire contract a pinned widget in someone's browser is polling right now,
+// and renaming it would break that for no gain — which is why slice 2 serves it
+// as a REWRITE onto /api/view/widgetActionable rather than a redirect.
+//
+// ZERO IMPORTS, deliberately.
+
+export type WidgetActionableChannel = {
+ slug: string;
+ undownloaded: number;
+ untranscribed: number;
+ // What the digest lane could act on today — the digest band's `reachable`,
+ // per channel. Reported but STILL NOT used to decide whether a channel
+ // "needs work", and the reasoning is unchanged: during the backfill this is
+ // ~99.87% of the corpus, so
+ // counting it would put every channel in the list forever and drown the two
+ // buckets a human can actually act on today. The registry's classification
+ // removes untranscribed and cues-stale videos from the number, which makes it
+ // smaller and more honest — nowhere near small enough to filter on.
+ digestReachable: number;
+};
+
+export type WidgetActionablePayload = {
+ channels: WidgetActionableChannel[];
+};
+
+export function buildWidgetActionablePayload(
+ rows: readonly WidgetActionableChannel[],
+): WidgetActionablePayload {
+ const channels = rows
+ .filter((c) => c.undownloaded > 0 || c.untranscribed > 0)
+ .sort(
+ (a, b) =>
+ b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed),
+ );
+ return { channels };
+}
diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import {
actionableDigestReachableCount,
actionableUndownloadedCount,
@@ -9,46 +10,24 @@ import {
export const dynamic = "force-dynamic";
-export type WidgetActionableChannel = {
- slug: string;
- undownloaded: number;
- untranscribed: number;
- // What the digest lane could act on today — the digest band's `reachable`,
- // per channel. Reported but STILL NOT used to decide whether a channel
- // "needs work", and the reasoning is unchanged: during the backfill this is
- // ~99.87% of the corpus, so
- // counting it would put every channel in the list forever and drown the two
- // buckets a human can actually act on today. The registry's classification
- // removes untranscribed and cues-stale videos from the number, which makes it
- // smaller and more honest — nowhere near small enough to filter on.
- digestReachable: number;
-};
-
-export type WidgetActionablePayload = {
- channels: WidgetActionableChannel[];
-};
-
-// Backs the monitor widget's optional "Needs work" strip. Reuses the same
-// actionable census the operation pages and the dashboard draw, reduced to the
-// two buckets a widget acts on — videos to download and videos to transcribe —
-// for each channel that has any, sorted by the larger backlog first.
+// Backs the monitor widget's optional "Needs work" strip. The payload is
+// `common/views/widgetActionable.ts`; this file is the census read and the map
+// through the count helpers, which know the availability exclusions and stay
+// beside the loader.
//
// THE PATH KEEPS ITS NAME. /actionable the page is gone; /api/widget/actionable
// is a wire contract a pinned widget in someone's browser is polling right now,
// and renaming it would break that for no gain.
export async function GET() {
const summary = await loadActionableSummary(getPaths());
- const channels = summary.rows
- .map((row) => ({
- slug: row.channel.slug,
- undownloaded: actionableUndownloadedCount(row),
- untranscribed: actionableUntranscribedCount(row),
- digestReachable: actionableDigestReachableCount(row),
- }))
- .filter((c) => c.undownloaded > 0 || c.untranscribed > 0)
- .sort(
- (a, b) =>
- b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed),
- );
- return NextResponse.json({ channels } satisfies WidgetActionablePayload);
+ return NextResponse.json(
+ buildWidgetActionablePayload(
+ summary.rows.map((row) => ({
+ slug: row.channel.slug,
+ undownloaded: actionableUndownloadedCount(row),
+ untranscribed: actionableUntranscribedCount(row),
+ digestReachable: actionableDigestReachableCount(row),
+ })),
+ ),
+ );
}
diff --git a/editor/app/api/widget/cleanable/route.ts b/editor/app/api/widget/cleanable/route.ts
@@ -1,23 +1,16 @@
import { NextResponse } from "next/server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { buildCleanablePayload } from "yt-dlp-transcript-common/views/cleanable";
import { cleanableChannels } from "../../../cleanup/lib/loadCleanup";
export const dynamic = "force-dynamic";
-export type CleanableChannel = { slug: string; count: number; bytes: number };
-
-export type CleanablePayload = {
- bytes: number;
- channels: CleanableChannel[];
-};
-
// Backs the monitor widget's cleanable-data indicator AND its "Needs cleaning"
// channel list — the per-channel rows come from the same snapshot read, so
// serving both costs no extra I/O and no second poll. Covers the primary "clean
// audio" reclaim across channels not excluded from the cleanup total (honors the
-// per-channel excludeFromCleanup flag); `bytes` is their sum.
+// per-channel excludeFromCleanup flag); the payload's `bytes` is their sum, and
+// it is `common/views/cleanable.ts` that sums them.
export async function GET() {
- const channels = await cleanableChannels(getPaths());
- const bytes = channels.reduce((sum, c) => sum + c.bytes, 0);
- return NextResponse.json({ bytes, channels } satisfies CleanablePayload);
+ return NextResponse.json(buildCleanablePayload(await cleanableChannels(getPaths())));
}
diff --git a/editor/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx
@@ -2,7 +2,7 @@
import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs";
import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers";
-import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
import { usePolledPayload, useNow } from "../../widget/lib/usePolledPayload";
import { PipelineBand } from "./PipelineBand";
diff --git a/editor/app/components/dashboard/NeedsWorkPanel.tsx b/editor/app/components/dashboard/NeedsWorkPanel.tsx
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
-import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import { InlineActionButton } from "../actions/InlineActionButton";
const LIMIT = 10;
diff --git a/editor/app/components/dashboard/PipelineBand.tsx b/editor/app/components/dashboard/PipelineBand.tsx
@@ -5,7 +5,7 @@ import { useState } from "react";
import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs";
import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers";
import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
-import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import { JobsTable } from "../../jobs/components/JobsTable";
import { LaneDeck } from "../lanes/LaneDeck";
import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions";
diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
-import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs";
import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers";
import { formatBytes } from "yt-dlp-transcript-common/lib/format";
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -22,7 +22,7 @@ import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSyn
import { widgetSyncInputs } from "./widget/lib/syncInputs";
import { DashboardCockpit } from "./components/dashboard/DashboardCockpit";
import type { DashboardChannel } from "./components/dashboard/types";
-import type { WidgetActionablePayload } from "./api/widget/actionable/route";
+import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type { ActionableRow } from "./lib/actionable/loadActionable";
export const dynamic = "force-dynamic";
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -16,11 +16,11 @@ import type {
import { jobKindLabel } from "../../jobs/jobKindLabels";
import type { WorkersPayload, WorkerView } from "yt-dlp-transcript-common/views/workers";
import { InlineActionButton } from "../../components/actions/InlineActionButton";
-import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type {
CleanablePayload,
CleanableChannel,
-} from "../../api/widget/cleanable/route";
+} from "yt-dlp-transcript-common/views/cleanable";
import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
import {
buildWidgetQuery,
diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
-import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs";
import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers";
import { LaneDeck } from "../../components/lanes/LaneDeck";