Archilyzer · Source

archilyzer

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

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:
Acommon/views/cleanable.test.ts | 32++++++++++++++++++++++++++++++++
Acommon/views/cleanable.ts | 42++++++++++++++++++++++++++++++++++++++++++
Acommon/views/widgetActionable.test.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/widgetActionable.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/widget/actionable/route.ts | 51+++++++++++++++------------------------------------
Meditor/app/api/widget/cleanable/route.ts | 15++++-----------
Meditor/app/components/dashboard/DashboardCockpit.tsx | 2+-
Meditor/app/components/dashboard/NeedsWorkPanel.tsx | 2+-
Meditor/app/components/dashboard/PipelineBand.tsx | 2+-
Meditor/app/components/lanes/LaneDeck.tsx | 2+-
Meditor/app/page.tsx | 2+-
Meditor/app/widget/components/MonitorWidget.tsx | 4++--
Meditor/app/widget/components/WidgetControls.tsx | 2+-
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";