commit a9f5961e3182e4cb65d78f6fe5152e37970402db
parent 30e2834879bf54b90723b5c84c78e1c9d59a0e82
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 13:59:12 -0400
Merge one-core/phase-3-s3a — one drawing per noun: channel row + job in flight
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
35 files changed, 2964 insertions(+), 1927 deletions(-)
diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts
@@ -184,6 +184,9 @@ export type AutoRunnerInFlight = {
// note renders the note instead of a video link. Absent for every ordinary
// unit, which is how the surfaces stay unchanged.
note?: string;
+ // The registry job running this unit, when it is one (a download unit is a
+ // child job on its platform queue); absent for a task on the runner's job.
+ jobId?: string;
};
// Why the runner is up but dispatching nothing. Every one of these was already
@@ -1890,7 +1893,7 @@ async function runLoop(
targets: picked.scan.targets,
tracker,
onLog,
- onChildJob: (jid) => childJobIds.set(pick.videoId, jid),
+ onChildJob: (jid) => { childJobIds.set(pick.videoId, jid); const u = live.inFlight.get(pick.videoId); if (u) u.jobId = jid; },
});
return;
}
@@ -1905,7 +1908,7 @@ async function runLoop(
onLog,
signal: runSignal,
drainSignal: ctx.drainSignal,
- onChildJob: (jid) => childJobIds.set(pick.videoId, jid),
+ onChildJob: (jid) => { childJobIds.set(pick.videoId, jid); const u = live.inFlight.get(pick.videoId); if (u) u.jobId = jid; },
});
} catch (err) {
result = { outcome: "failed" };
diff --git a/common/views/actionableCounts.test.ts b/common/views/actionableCounts.test.ts
@@ -0,0 +1,116 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { ChannelSnapshot } from "../controller/channelSnapshot";
+import type { OperationSnapshotEntry } from "../lib/operations";
+import { normalizeBuckets } from "./pipeline/stageStatus";
+import {
+ cleanExtraFormatsBytesOf,
+ cleanExtraFormatsCountOf,
+ cleanTranscribedBytesOf,
+ cleanTranscribedCountOf,
+ digestReachableCountOf,
+ digestWarningsCountOf,
+ incompleteTranscriptCountOf,
+ metadataScanCountOf,
+ missingNeverFetchedCountOf,
+ shortAudioCountOf,
+ undownloadedCountOf,
+ untranscribedCountOf,
+} from "./actionableCounts";
+
+function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot {
+ return {
+ generatedAt: "2026-08-01T00:00:00.000Z",
+ totals: { videos: 10, transcribed: 4, downloaded: 6 },
+ buckets: normalizeBuckets(undefined),
+ undownloadedIds: [],
+ ...patch,
+ };
+}
+
+const ALL = [
+ undownloadedCountOf,
+ missingNeverFetchedCountOf,
+ metadataScanCountOf,
+ untranscribedCountOf,
+ incompleteTranscriptCountOf,
+ shortAudioCountOf,
+ cleanTranscribedCountOf,
+ cleanExtraFormatsCountOf,
+ digestReachableCountOf,
+ digestWarningsCountOf,
+ cleanTranscribedBytesOf,
+ cleanExtraFormatsBytesOf,
+];
+
+test("a channel with no report counts zero everywhere", () => {
+ for (const f of ALL) {
+ assert.equal(f(null), 0, f.name);
+ assert.equal(f(undefined), 0, f.name);
+ }
+});
+
+test("a snapshot written before the optional buckets existed counts zero", () => {
+ // The on-disk shape of an old report: no optional buckets, no cleanupBytes,
+ // no metadataScan, no missingNeverFetched, no backfill entries.
+ const old = {
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ totals: { videos: 3, transcribed: 0, downloaded: 0 },
+ buckets: { downloadedNoTranscript: [] },
+ undownloadedIds: [],
+ } as unknown as ChannelSnapshot;
+ for (const f of ALL) assert.equal(f(old), 0, f.name);
+});
+
+test("undownloaded and untranscribed drop the availability exclusions", () => {
+ const snap = snapshotOf({
+ undownloadedIds: ["a", "b", "c", "d"],
+ buckets: {
+ ...normalizeBuckets(undefined),
+ downloadedNoTranscript: ["t1", "t2", "b"],
+ },
+ excludedFromDownload: { membersOnly: ["a"], deleted: ["b"], private: [] },
+ });
+ assert.equal(undownloadedCountOf(snap), 2);
+ assert.equal(untranscribedCountOf(snap), 2);
+ // With nothing excluded the lengths are the counts.
+ const plain = snapshotOf({ undownloadedIds: ["a", "b"] });
+ assert.equal(undownloadedCountOf(plain), 2);
+});
+
+test("the bucket counts are the bucket lengths; the bytes are cleanupBytes", () => {
+ const snap = snapshotOf({
+ buckets: {
+ ...normalizeBuckets(undefined),
+ incompleteTranscript: ["i"],
+ shortAudio: ["s1", "s2"],
+ transcribedWithAudio: ["w1", "w2", "w3"],
+ multipleAudioFormats: ["m"],
+ digestWarnings: ["d1", "d2"],
+ },
+ cleanupBytes: { transcribedWithAudio: 300, multipleAudioFormats: 40 },
+ metadataScan: { unscanned: 7 } as ChannelSnapshot["metadataScan"],
+ missingNeverFetched: [{}, {}] as ChannelSnapshot["missingNeverFetched"],
+ });
+ assert.equal(incompleteTranscriptCountOf(snap), 1);
+ assert.equal(shortAudioCountOf(snap), 2);
+ assert.equal(cleanTranscribedCountOf(snap), 3);
+ assert.equal(cleanExtraFormatsCountOf(snap), 1);
+ assert.equal(digestWarningsCountOf(snap), 2);
+ assert.equal(cleanTranscribedBytesOf(snap), 300);
+ assert.equal(cleanExtraFormatsBytesOf(snap), 40);
+ assert.equal(metadataScanCountOf(snap), 7);
+ assert.equal(missingNeverFetchedCountOf(snap), 2);
+});
+
+test("digest reachable is the digest registry entry's reachable", () => {
+ const entry = {
+ ids: [],
+ missing: 5,
+ stale: 2,
+ missingInput: 0,
+ partial: 1,
+ } as unknown as OperationSnapshotEntry;
+ const snap = snapshotOf({ backfill: { digest: entry } });
+ assert.equal(digestReachableCountOf(snap), 8);
+});
diff --git a/common/views/actionableCounts.ts b/common/views/actionableCounts.ts
@@ -0,0 +1,108 @@
+import {
+ digestWorkOf,
+ excludedDownloadIdSet,
+ type ChannelSnapshot,
+} from "../controller/channelSnapshot";
+
+// THE PER-CHANNEL WORK COUNTS, as pure functions over one channel's snapshot.
+//
+// These were the `actionable*Count` helpers in the editor's
+// `lib/actionable/loadActionable.ts`, each taking an `ActionableRow` and reading
+// only `row.snapshot`. They move here so the channel-row builder
+// (`./channelRow.ts`) and the actionable census count the SAME way — the
+// dashboard's Videos / Digest to do cells and the widget's "Needs work" strip
+// were each a hand copy of the census before. `loadActionable.ts` keeps its
+// `actionable*` names as one-line wrappers over these, so no caller moved.
+//
+// Every function takes `null`/`undefined` (a channel that has never been
+// reported) and answers 0, and every optional bucket defaults to 0 for a
+// snapshot written before the bucket existed.
+
+type Snap = ChannelSnapshot | null | undefined;
+
+// Counts that drive the actionable lists exclude IDs that the availability
+// check has flagged as deleted / members-only / private — those videos
+// can't be acted on, so they shouldn't inflate "needs attention" totals.
+// `undownloadedIds` is already filtered at snapshot generation time, but we
+// apply the filter again so a stale snapshot can't surface excluded IDs.
+function countActionable(
+ snapshot: Snap,
+ ids: readonly string[] | undefined,
+): number {
+ if (!snapshot || !ids) return 0;
+ const excluded = excludedDownloadIdSet(snapshot);
+ if (excluded.size === 0) return ids.length;
+ let n = 0;
+ for (const id of ids) if (!excluded.has(id)) n++;
+ return n;
+}
+
+export function undownloadedCountOf(snapshot: Snap): number {
+ return countActionable(snapshot, snapshot?.undownloadedIds);
+}
+
+// Videos the roster says we were told about, never downloaded, and that have
+// since left the listing. Deliberately NOT run through countActionable: the
+// availability exclusions are keyed on videos we have on disk, and these have no
+// dir at all.
+export function missingNeverFetchedCountOf(snapshot: Snap): number {
+ return snapshot?.missingNeverFetched?.length ?? 0;
+}
+
+// Listed videos the metadata scan has neither read nor recently failed on —
+// the scan operation's backlog. Deliberately NOT run through countActionable:
+// these videos have no directory, so the availability exclusions (which are
+// keyed on what is on disk) cannot say anything about them.
+export function metadataScanCountOf(snapshot: Snap): number {
+ return snapshot?.metadataScan?.unscanned ?? 0;
+}
+
+export function untranscribedCountOf(snapshot: Snap): number {
+ return countActionable(snapshot, snapshot?.buckets.downloadedNoTranscript);
+}
+
+// Transcribed videos whose transcript is badly truncated (the audio download
+// stopped early).
+export function incompleteTranscriptCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.incompleteTranscript?.length ?? 0;
+}
+
+// Downloads the duration guard flagged as truncated at the source (short audio
+// kept on disk, not transcribed).
+export function shortAudioCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.shortAudio?.length ?? 0;
+}
+
+// Cleanup buckets are filtered by "do not clean" at snapshot-generation time,
+// so the length is the actionable count directly.
+export function cleanTranscribedCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.transcribedWithAudio?.length ?? 0;
+}
+
+export function cleanExtraFormatsCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.multipleAudioFormats?.length ?? 0;
+}
+
+// The digest band's `reachable`, per channel — read through digestWorkOf, the
+// registry's classification the runner uses (no transcript = blocked, stale
+// cues = deferred; neither is in it). buildBands.ts folds the same call.
+export function digestReachableCountOf(snapshot: Snap): number {
+ return digestWorkOf(snapshot).reachable;
+}
+
+// "The model produced something a human should look at", which includes the
+// total failures that write no section and so are invisible to any count of
+// files.
+export function digestWarningsCountOf(snapshot: Snap): number {
+ return snapshot?.buckets.digestWarnings?.length ?? 0;
+}
+
+// Estimated bytes each cleanup would reclaim (0 for snapshots written before
+// cleanupBytes existed).
+export function cleanTranscribedBytesOf(snapshot: Snap): number {
+ return snapshot?.cleanupBytes?.transcribedWithAudio ?? 0;
+}
+
+export function cleanExtraFormatsBytesOf(snapshot: Snap): number {
+ return snapshot?.cleanupBytes?.multipleAudioFormats ?? 0;
+}
diff --git a/common/views/channelGroupSections.ts b/common/views/channelGroupSections.ts
@@ -63,6 +63,14 @@ export type StationWork = {
laneOff?: boolean;
};
+// What the /channels client reads of a section: everything but the stats,
+// which it only counts and joins by slug. The page projects to this before the
+// sections cross to the browser, so no ChannelConfig (url, dataDir, yt-dlp
+// args, cookie settings) rides along with them.
+export type ChannelGroupSectionView = Omit<ChannelGroupSection, "channels"> & {
+ channels: Array<{ slug: string }>;
+};
+
export type ChannelGroupSection = {
group: ChannelGroup;
// Membership order asc (absent sorts last), then slug.
diff --git a/common/views/channelRow.test.ts b/common/views/channelRow.test.ts
@@ -0,0 +1,172 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { ChannelSnapshot } from "../controller/channelSnapshot";
+import type { ChannelConfig } from "../lib/channelConfig";
+import type { StorageLocation } from "../lib/storageLocations";
+import { normalizeBuckets } from "./pipeline/stageStatus";
+import {
+ buildChannelRowView,
+ channelVolumeOf,
+ neutralChannelPriority,
+ reportStateOf,
+ type ChannelRowInput,
+} from "./channelRow";
+import {
+ digestReachableCountOf,
+ undownloadedCountOf,
+ untranscribedCountOf,
+} from "./actionableCounts";
+
+function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot {
+ return {
+ generatedAt: "2026-08-01T00:00:00.000Z",
+ totals: { videos: 10, transcribed: 4, downloaded: 6 },
+ buckets: normalizeBuckets(undefined),
+ undownloadedIds: [],
+ ...patch,
+ };
+}
+
+function input(patch: Partial<ChannelRowInput> = {}): ChannelRowInput {
+ const config: ChannelConfig = {
+ handling: "transcribe",
+ url: "https://example.test/alpha",
+ name: "Alpha",
+ lastSyncedAt: "2026-07-01T00:00:00.000Z",
+ cookiesFile: "/secret/cookies.txt",
+ } as ChannelConfig;
+ return {
+ slug: "alpha",
+ config,
+ snapshot: snapshotOf(),
+ playlistCount: 12,
+ bands: [],
+ priority: neutralChannelPriority(),
+ media: null,
+ volume: { id: "internal", label: "Internal" },
+ ...patch,
+ };
+}
+
+test("a channel with no report: missing, no size, zero work", () => {
+ const row = buildChannelRowView(input({ snapshot: null }));
+ assert.deepEqual(row.report, { generatedAt: null, state: "missing" });
+ assert.equal(row.mediaBytes, null);
+ assert.deepEqual(row.work, {
+ videos: 0,
+ undownloaded: 0,
+ untranscribed: 0,
+ digestReachable: 0,
+ });
+ assert.equal(row.downloadCount, 0);
+ assert.equal(row.transcriptCount, 0);
+});
+
+test("stale when the last sync is newer than the report, else current", () => {
+ const stale = buildChannelRowView(
+ input({
+ config: { handling: "transcribe", lastSyncedAt: "2026-09-01T00:00:00Z" },
+ }),
+ );
+ assert.equal(stale.report.state, "stale");
+ const current = buildChannelRowView(input());
+ assert.equal(current.report.state, "current");
+ assert.equal(current.report.generatedAt, "2026-08-01T00:00:00.000Z");
+ // Never synced: nothing to be stale against.
+ assert.equal(
+ reportStateOf({ config: {}, snapshot: { generatedAt: "2026-01-01" } }),
+ "current",
+ );
+});
+
+test("work equals the census count helpers over the same snapshot", () => {
+ const snap = snapshotOf({
+ undownloadedIds: ["a", "b", "c"],
+ buckets: { ...normalizeBuckets(undefined), downloadedNoTranscript: ["x", "a"] },
+ excludedFromDownload: { membersOnly: ["a"], deleted: [], private: [] },
+ totalMediaBytes: 1234,
+ });
+ const row = buildChannelRowView(input({ snapshot: snap }));
+ assert.deepEqual(row.work, {
+ videos: 10,
+ undownloaded: undownloadedCountOf(snap),
+ untranscribed: untranscribedCountOf(snap),
+ digestReachable: digestReachableCountOf(snap),
+ });
+ assert.equal(row.work.undownloaded, 2);
+ assert.equal(row.mediaBytes, 1234);
+ assert.equal(row.downloadCount, 6);
+ assert.equal(row.transcriptCount, 4);
+});
+
+test("the view carries no config, only the fields a cell draws", () => {
+ const row = buildChannelRowView(input());
+ assert.equal("config" in row, false);
+ assert.equal(JSON.stringify(row).includes("cookies"), false);
+ assert.equal(row.name, "Alpha");
+ assert.equal(row.hasUrl, true);
+ assert.equal(row.excludeFromBuild, false);
+ assert.equal(row.lastSyncedAt, "2026-07-01T00:00:00.000Z");
+ assert.equal(row.playlistCount, 12);
+ const bare = buildChannelRowView(input({ config: { handling: "youtube" } }));
+ assert.equal(bare.name, "");
+ assert.equal(bare.hasUrl, false);
+ assert.equal(bare.lastSyncedAt, null);
+});
+
+test("media: in-place is dropped; the label is the volume's unless overridden", () => {
+ const inPlace = buildChannelRowView(
+ input({ media: { status: "in-place", target: "/x", detail: "" } }),
+ );
+ assert.equal(inPlace.media, null);
+ const moved = buildChannelRowView(
+ input({
+ media: { status: "ok", target: "/mnt/big/alpha/data", detail: "d" },
+ volume: { id: "big", label: "Big disk" },
+ }),
+ );
+ assert.equal(moved.media?.locationLabel, "Big disk");
+ assert.equal(moved.volumeId, "big");
+ const unnamed = buildChannelRowView(
+ input({
+ media: { status: "unreachable", target: "/mnt/x", detail: "d" },
+ volume: { id: "", label: "Elsewhere" },
+ }),
+ );
+ assert.equal(unnamed.media?.locationLabel, undefined);
+ const overridden = buildChannelRowView(
+ input({
+ media: { status: "ok", target: "/mnt/big/alpha/data", detail: "d" },
+ volume: { id: "big", label: "Big disk" },
+ mediaLocationLabel: undefined,
+ }),
+ );
+ assert.equal(overridden.media?.locationLabel, undefined);
+});
+
+test("channelVolumeOf: no dataDir is internal, a named root wins, else unnamed", () => {
+ const locations = [
+ { id: "big", label: "Big disk", root: "/mnt/big" },
+ { id: "bigger", label: "", root: "/mnt/big/inner" },
+ ] as StorageLocation[];
+ assert.deepEqual(channelVolumeOf(undefined, locations), {
+ id: "internal",
+ label: "Internal",
+ });
+ assert.deepEqual(channelVolumeOf(" ", locations), {
+ id: "internal",
+ label: "Internal",
+ });
+ assert.deepEqual(channelVolumeOf("/mnt/big/alpha/data", locations), {
+ id: "big",
+ label: "Big disk",
+ });
+ assert.deepEqual(channelVolumeOf("/mnt/big/inner/a/data", locations), {
+ id: "bigger",
+ label: "bigger",
+ });
+ assert.deepEqual(channelVolumeOf("/elsewhere/a/data", locations), {
+ id: "",
+ label: "Elsewhere",
+ });
+});
diff --git a/common/views/channelRow.ts b/common/views/channelRow.ts
@@ -0,0 +1,218 @@
+import type { ChannelConfig } from "../lib/channelConfig";
+import type { ChannelSnapshot } from "../controller/channelSnapshot";
+import type { ChannelMediaLocation } from "../lib/channelMedia";
+import type {
+ PriorityOperation,
+ StoredChannelTier,
+} from "../lib/channelPriority";
+import {
+ INTERNAL_LOCATION_ID,
+ locationOfDataDir,
+ type StorageLocation,
+} from "../lib/storageLocations";
+import type { OperationBand } from "./pipeline/band";
+import {
+ digestReachableCountOf,
+ undownloadedCountOf,
+ untranscribedCountOf,
+} from "./actionableCounts";
+
+// ONE CHANNEL ROW, whichever table draws it.
+//
+// Three tables used to build their row inline — the /channels rack
+// (`channels/page.tsx`), the dashboard's channels table (`app/page.tsx`) and the
+// operation pages' work tables (`ChannelWorkTable`) — each reading the config
+// and the snapshot its own way. This is the one projection: the server builds a
+// `ChannelRowView` per channel and the shared client table
+// (`editor/app/channels/components/ChannelsTable.tsx`) draws whichever columns
+// its caller names.
+//
+// CLIENT-SAFE BY CONSTRUCTION. The view never carries `config`: a channel
+// config holds cookie paths, yt-dlp args and the download filter, none of which
+// a table draws and all of which would otherwise be serialized into every
+// page's RSC payload. The builder reads the config; the view carries the six
+// fields of it that a cell renders.
+
+// One row's share of the channel priority document, resolved on the server.
+// The row never reads the model itself: `focused` and `heldReason` are facts
+// about the corpus-wide focus selector, which no single row can answer.
+export type ChannelRowPriority = {
+ tier: StoredChannelTier;
+ rank: number | null;
+ overrides: Partial<Record<PriorityOperation, StoredChannelTier>>;
+ focused: boolean;
+ heldReason: string | null;
+ // Why the machine paused it, or null. See autoPauseReasonOf.
+ autoPausedReason: string | null;
+};
+
+// The priority a table that draws no Tier column hands the builder: what a
+// channel with no entry in the priority document resolves to.
+export function neutralChannelPriority(): ChannelRowPriority {
+ return {
+ tier: "normal",
+ rank: null,
+ overrides: {},
+ focused: false,
+ heldReason: null,
+ autoPausedReason: null,
+ };
+}
+
+// Where this channel's media physically is, as the badge draws it. Narrower
+// than ChannelMediaLocation: a row only needs what it draws.
+export type ChannelRowMedia = Pick<
+ ChannelMediaLocation,
+ "status" | "target" | "detail"
+> & {
+ // The name of the storage location this channel's media is on, projected by
+ // the server that built the row. A fourth string is still cheaper than
+ // shipping the location list to every table.
+ locationLabel?: string;
+};
+
+export type ReportState = "current" | "stale" | "missing";
+
+export type ChannelRowView = {
+ slug: string;
+ // `config.name`, "" when unset.
+ name: string;
+ handling: ChannelConfig["handling"];
+ // Whether the channel has a URL — Sync and Download need one.
+ hasUrl: boolean;
+ excludeFromBuild: boolean;
+ lastSyncedAt: string | null;
+ // The playlist file's line count; null when there is no playlist file.
+ playlistCount: number | null;
+ downloadCount: number;
+ transcriptCount: number;
+ // The per-channel work figures, off the SAME helpers the actionable census
+ // counts with (./actionableCounts.ts), so a dashboard cell and the widget's
+ // "Needs work" strip cannot disagree about a channel.
+ work: {
+ videos: number;
+ undownloaded: number;
+ untranscribed: number;
+ digestReachable: number;
+ };
+ // The pipeline bands, in column order, projected from the same snapshot the
+ // counts come from. `[]` for a table that draws no pipeline column.
+ pipelines: OperationBand[];
+ priority: ChannelRowPriority;
+ // How old this channel's report is. Every count and every band on this row is
+ // projected from that report, so its age is the caveat on all of them.
+ report: { generatedAt: string | null; state: ReportState };
+ // Null for an in-place channel — the overwhelming majority — so the badge
+ // marks only the rows whose other numbers may not be trustworthy.
+ media: ChannelRowMedia | null;
+ // WHICH VOLUME, as an id a filter can name: a location id, "internal" for the
+ // corpus volume, or "" for a dataDir under a root nobody named.
+ volumeId: string;
+ volumeLabel: string;
+ // `snapshot.totalMediaBytes`. NULL, not 0, for a report written before the
+ // field existed: a 400 GB channel that has not been measured must not sort as
+ // the smallest thing on the disk.
+ mediaBytes: number | null;
+};
+
+export type ChannelRowInput = {
+ slug: string;
+ config: ChannelConfig;
+ snapshot: ChannelSnapshot | null;
+ playlistCount: number | null;
+ bands: OperationBand[];
+ priority: ChannelRowPriority;
+ // The server's inspectChannelMedia result, or null when it was not asked.
+ // An in-place result is dropped here, once, for every table.
+ media: Pick<ChannelMediaLocation, "status" | "target" | "detail"> | null;
+ volume: ChannelVolumeId;
+ // The badge's location name. Defaults to the volume's label (undefined for
+ // the unnamed-root volume, which renders exactly what the badge rendered
+ // before locations existed). The dashboard names it from the media target,
+ // as it always has, and passes that here.
+ mediaLocationLabel?: string;
+};
+
+// How old a channel's report is, in the three states /channels draws:
+// "missing" (never generated), "stale" (older than the last sync — so every
+// count read off it may be wrong) and "current".
+export function reportStateOf(brief: {
+ config: Pick<ChannelConfig, "lastSyncedAt">;
+ snapshot: Pick<ChannelSnapshot, "generatedAt"> | null;
+}): ReportState {
+ if (!brief.snapshot) return "missing";
+ const synced = brief.config.lastSyncedAt;
+ if (!synced) return "current";
+ return new Date(synced).getTime() >
+ new Date(brief.snapshot.generatedAt).getTime()
+ ? "stale"
+ : "current";
+}
+
+export type ChannelVolumeId = { id: string; label: string };
+
+// WHICH VOLUME A CHANNEL'S MEDIA IS ON, as a filterable id.
+//
+// Same derivation the badge uses (`config.dataDir` under a location's root,
+// longest match wins) with one addition: no `dataDir` at all means the corpus
+// volume, which is the row the operator is trying to empty and therefore the
+// one they most need to filter to. A `dataDir` under a root NOBODY named is
+// neither — it gets "" and falls out of every volume filter, which is the
+// honest answer and the nudge to name that root on /storage.
+export function channelVolumeOf(
+ dataDir: string | undefined,
+ locations: StorageLocation[],
+): ChannelVolumeId {
+ const trimmed = dataDir?.trim();
+ if (!trimmed) {
+ return { id: INTERNAL_LOCATION_ID, label: "Internal" };
+ }
+ const found = locationOfDataDir(trimmed, locations);
+ return found
+ ? { id: found.id, label: found.label || found.id }
+ : { id: "", label: "Elsewhere" };
+}
+
+export function buildChannelRowView(i: ChannelRowInput): ChannelRowView {
+ const snap = i.snapshot;
+ return {
+ slug: i.slug,
+ name: i.config.name ?? "",
+ handling: i.config.handling,
+ hasUrl: Boolean(i.config.url),
+ excludeFromBuild: i.config.excludeFromBuild === true,
+ lastSyncedAt: i.config.lastSyncedAt ?? null,
+ playlistCount: i.playlistCount,
+ downloadCount: snap?.totals.downloaded ?? 0,
+ transcriptCount: snap?.totals.transcribed ?? 0,
+ work: {
+ videos: snap?.totals.videos ?? 0,
+ undownloaded: undownloadedCountOf(snap),
+ untranscribed: untranscribedCountOf(snap),
+ digestReachable: digestReachableCountOf(snap),
+ },
+ pipelines: i.bands,
+ priority: i.priority,
+ report: {
+ generatedAt: snap?.generatedAt ?? null,
+ state: reportStateOf({ config: i.config, snapshot: snap }),
+ },
+ media:
+ i.media && i.media.status !== "in-place"
+ ? {
+ status: i.media.status,
+ target: i.media.target,
+ detail: i.media.detail,
+ locationLabel:
+ "mediaLocationLabel" in i
+ ? i.mediaLocationLabel
+ : i.volume.id === ""
+ ? undefined
+ : i.volume.label,
+ }
+ : null,
+ volumeId: i.volume.id,
+ volumeLabel: i.volume.label,
+ mediaBytes: snap?.totalMediaBytes ?? null,
+ };
+}
diff --git a/common/views/jobRowView.ts b/common/views/jobRowView.ts
@@ -94,6 +94,8 @@ export type JobRowView = {
// jobs/jobDetail.ts from the job's own replay spec; absent for every kind
// that has nothing to add, so no existing row changes.
detail?: string;
- // Which adapter built it. Never rendered; tests and the merge read it.
- source: "registry" | "archive" | "slot";
+ // Which adapter built it. Never rendered; tests and the merge read it —
+ // except "runner": an auto-queue lane's in-flight unit (fromInFlight), which
+ // links to a job page only when `inRegistry` says it has one.
+ source: "registry" | "archive" | "slot" | "runner";
};
diff --git a/common/views/jobRows.test.ts b/common/views/jobRows.test.ts
@@ -5,6 +5,7 @@ import type { JobListEntry } from "../jobs/listJobs";
import { createScheduler } from "../jobs/scheduler";
import {
fromEntry,
+ fromInFlight,
fromRecord,
fromSlot,
isLive,
@@ -340,3 +341,61 @@ test("fromSlot builds the phantom, and a stuck row sorts above a running one", (
]);
assert.equal(ordered[0].id, phantom.id);
});
+
+test("fromInFlight: a download unit carries its registry job id", () => {
+ const row = fromInFlight(
+ {
+ videoId: "v1",
+ leafId: "leaf-a",
+ channelSlug: "alpha",
+ startedAt: 1_000,
+ jobId: "01JOBDOWNLOAD",
+ },
+ { kind: "download", runnerJobId: "01RUNNER", detail: "rule 1 · alpha" },
+ );
+ assert.equal(row.id, "01JOBDOWNLOAD");
+ assert.equal(row.inRegistry, true);
+ assert.equal(row.source, "runner");
+ assert.equal(row.status, "running");
+ assert.equal(row.background, true);
+ assert.equal(row.kind, "download");
+ assert.equal(row.channelSlug, "alpha");
+ assert.equal(row.videoId, "v1");
+ assert.equal(row.startedAt, 1_000);
+ assert.equal(row.detail, "rule 1 · alpha");
+});
+
+test("fromInFlight: a task unit gets a synthetic id and no job page", () => {
+ const unit = {
+ videoId: "v2",
+ leafId: "leaf-b",
+ channelSlug: "beta",
+ startedAt: 2_000,
+ };
+ const withRunner = fromInFlight(unit, {
+ kind: "transcription",
+ runnerJobId: "01RUNNER",
+ });
+ assert.equal(withRunner.id, "01RUNNER:v2");
+ assert.equal(withRunner.inRegistry, false);
+ // No runner job known (a lane between runs): the lane kind keys it.
+ const bare = fromInFlight(unit, { kind: "transcription", runnerJobId: null });
+ assert.equal(bare.id, "transcription:v2");
+ assert.equal(bare.detail, undefined);
+});
+
+test("fromInFlight: a note replaces the detail and drops the synthetic video id", () => {
+ const row = fromInFlight(
+ {
+ videoId: "metadata-scan alpha",
+ leafId: "metadata-scan",
+ channelSlug: "alpha",
+ startedAt: 3_000,
+ note: "metadata scan · 12 titles",
+ },
+ { kind: "download", detail: "rule 1 · alpha" },
+ );
+ assert.equal(row.detail, "metadata scan · 12 titles");
+ assert.equal(row.videoId, undefined);
+ assert.equal(row.id, "download:metadata-scan alpha");
+});
diff --git a/common/views/jobRows.ts b/common/views/jobRows.ts
@@ -5,6 +5,7 @@ import type { JobListEntry } from "../jobs/listJobs";
import { jobSpecDetail } from "../jobs/jobDetail";
import type { ChannelStat } from "../controller/channels";
import type { QueueView } from "../jobs/scheduler";
+import type { AutoRunnerInFlight } from "../controller/autoRunner";
import type { JobRowView, StuckReason } from "./jobRowView";
// THE ADAPTERS, THE ORDER AND THE MERGE — pure, and directive-free by
@@ -332,3 +333,33 @@ export function isLive(r: JobRowView): boolean {
Boolean(r.stuck)
);
}
+
+// AN AUTO-QUEUE LANE'S IN-FLIGHT UNIT, as a job row — so the operations board
+// draws what the runner is doing with the same row every other surface draws
+// a job with. A download unit is a real registry job (its id rides on the unit
+// as `jobId`, set when the runner's child job starts); a transcription, digest
+// or backfill unit is a task on the runner's own job and has no page of its
+// own, so its id is synthetic and `inRegistry` is false.
+//
+// `detail` ("rule 3 · newest first from alpha") is the caller's: the leaf
+// sentence is an editor helper over the lane's policy, not a pure fact of the
+// unit. A unit with a `note` (the download lane's channel-scoped metadata
+// scan, whose `videoId` names no directory) says the note instead, and carries
+// no videoId, so nothing links to a video that is not there.
+export function fromInFlight(
+ unit: AutoRunnerInFlight,
+ ctx: { kind: string; runnerJobId?: string | null; detail?: string },
+): JobRowView {
+ return {
+ id: unit.jobId ?? `${ctx.runnerJobId ?? ctx.kind}:${unit.videoId}`,
+ kind: ctx.kind,
+ status: "running",
+ channelSlug: unit.channelSlug,
+ videoId: unit.note ? undefined : unit.videoId,
+ startedAt: unit.startedAt,
+ background: true,
+ inRegistry: Boolean(unit.jobId),
+ detail: unit.note ?? ctx.detail,
+ source: "runner",
+ };
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Every channel table and every job-in-flight line is now drawn one way.** The /channels rack, the dashboard's Channels table and the work tables on the operation pages and /cleanup are one table with a column set per page, over one channel row built on the server (which no longer ships a channel's config to the browser); the dashboard's "Needs work" seed is computed by the same code the widget endpoint serves. On the jobs side, /jobs rows, the "Active jobs" cards on channel/video/operation pages, the monitor widget's Active jobs strip and the operations board's "In flight" list are one job row in three sizes, with one rule for which buttons (Retry / Reorder / Drain / Cancel / Force-release) a job gets. **What you might notice:** a work table's report column reads "stale"/"missing" like the rack's instead of a date; the dashboard's Sync button is the rack's; a lane line on /jobs offers Force-release while its runner is running; widget job lines show who asked for the job; an in-flight download on the operations board links to its job page. Nothing a count says moved.
- **`settings.json` has one schema and one writer, and its key table is generated.** Every key, its default, its clamp and its documentation is now one zod schema (`common/lib/settingsSchema.ts`); `getSettings`/`writeSettings` both parse through it, and every settings form saves through one helper (`editor/app/settings/saveSettings.ts`) that merges only what the form changed. **`SETTINGS.md`** (new, repo root) lists every key with its default and what it does, and `settings.json.example` is now the full default object — both generated by `common/bin/settings-example.ts` and checked by a test, so neither can drift. Nothing an operator has configured reads differently. **Fixed:** adding or editing a storage location on `/storage` no longer erases the record of which location the saved-video store is on (`storage.savedVideosLocationId`).
- **Every live panel now polls one endpoint, `/api/view/<name>`, and the eight old addresses still answer.** The change token, the job head, workers, the operations board, the sync console and the widget's three strips were eight separate API routes that each did the same thing; they are one route serving eight named views (`pulse`, `activeJobs`, `workers`, `autoQueueStatus`, `schedulerStatus`, `widgetSync`, `widgetActionable`, `cleanable`), and the editor's own pages poll it there. `/api/pulse`, `/api/jobs/active`, `/api/workers`, `/api/auto-queue/status`, `/api/scheduler/status` and `/api/widget/{sync,actionable,cleanable}` are kept as **rewrites**, not redirects — same method, status, body and query string (`?rev=` included) — so a monitor widget pinned in a browser, or any script polling the old path, keeps working untouched. `/api/widget/presets` is unchanged. An unknown view name is a 404. **One behaviour change you might notice:** the operations board (every 3 s) and the sync console (every 5 s) now send their next poll only after the previous one answers, and abandon a poll that takes longer than 10–15 s — so a slow editor no longer piles requests up behind itself, and a hung request no longer stops the page updating.
- **A lane's pause is one key on the lane, and the four old pause fields are gone from `settings.json`.** Holding a lane has been `autoQueue.<lane>.held` since the runner work landed; until now the file also still carried the four flags that used to mean it — `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and the backwards `backfill.enabled` (where *enabled* meant *not held*) — which were read only when a lane had no `held` yet, to carry an older file's pause across. Every lane now carries its own key, so those four are **deleted**: nothing reads them, no form writes them, and the next settings save drops them from the file. A settings.json that still spells one of them holds nothing with it, so a hand-edited file (or a very old backup restored over a newer one) can no longer resurrect a pause you had lifted, or lift one you had set. "Run the backfill lane" on the diarization page and the Hold/Pause buttons write the one key, as they already did. **UPGRADING: boot once on the release that writes `held` before taking this one.** That release is the one that moved the gate onto the lane and carried the old fields across on read; a single boot of it (any settings save, or just starting the editor and pausing/resuming anything) puts `autoQueue.<lane>.held` in your settings.json, after which **nothing you can see changes here** — the same buttons, the same labels, the same pauses. An install that jumps straight from an older release to this one has no `held` keys at all and **loses its pauses**: transcription, downloads and digests come up running, and the backfill lane comes up held. Re-set them from the dashboard, or add the keys by hand before starting.
diff --git a/editor/app/channels/components/ChannelGroupHeaderRow.tsx b/editor/app/channels/components/ChannelGroupHeaderRow.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections";
+import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections";
import { ChannelGroupLine } from "./ChannelGroupLine";
// A group's section header: one full-colspan row above its channels' rows, so
@@ -19,7 +19,7 @@ export function ChannelGroupHeaderRow({
siteId,
colSpan,
}: {
- section: ChannelGroupSection;
+ section: ChannelGroupSectionView;
siteId: string;
// The table's full width, owned by ChannelsTable: it is the one that knows
// how many pipeline columns the settings switched on.
diff --git a/editor/app/channels/components/ChannelGroupLine.tsx b/editor/app/channels/components/ChannelGroupLine.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { Fragment, useEffect, useState } from "react";
import type {
- ChannelGroupSection,
+ ChannelGroupSectionView,
StationId,
StationWork,
} from "yt-dlp-transcript-common/views/channelGroupSections";
@@ -28,7 +28,7 @@ type Station = {
// Fixed for four stations; the speakers station reads its label off the
// section (`speakersLabel`), because what that lane is called depends on which
// operations are switched on and only the server can ask the registry.
- label: string | ((section: ChannelGroupSection) => string);
+ label: string | ((section: ChannelGroupSectionView) => string);
// What replaces the button once the group's work is provably finished. A dead
// button reading "Download 0" invites a click that would do nothing.
done: string;
@@ -117,7 +117,7 @@ export function ChannelGroupLine({
section,
siteId,
}: {
- section: ChannelGroupSection;
+ section: ChannelGroupSectionView;
siteId: string;
}) {
const [status, setStatus] = useState<Status>({ kind: "idle" });
diff --git a/editor/app/channels/components/ChannelsRack.tsx b/editor/app/channels/components/ChannelsRack.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow";
+import { BandLegend } from "../../components/pipelines/StateBand";
+import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections";
+import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar";
+import {
+ ChannelSelectionDeck,
+ type BulkDestination,
+} from "./ChannelSelectionDeck";
+import { ChannelVolumeBar, type ChannelVolume } from "./ChannelVolumeBar";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { selectToFreeBytes } from "yt-dlp-transcript-common/views/freeUpSelection";
+// A VALUE import from views/, and it is safe: every import in that module is a
+// type import, so what reaches the client bundle is two string constants. The
+// point is that "internal" and "location" have ONE spelling across /storage
+// (which writes the link) and /channels (which reads it).
+import { INTERNAL_ROW_ID } from "yt-dlp-transcript-common/views/storage";
+import { ChannelsTable } from "./ChannelsTable";
+import { RACK_COLUMNS, type PipelineColumn } from "./channelColumns";
+
+// THE /channels RACK: the page's chrome around the shared channel table — the
+// focus line, the volume bar, the instrument bar (grouping, band legend), the
+// one scroll region the table pins inside, and the selection deck below it.
+// The rows, cells and sort are ./ChannelsTable.tsx, which the dashboard and the
+// operation pages' work sections draw too; this component turns every feature
+// of it on.
+
+export function ChannelsRack({
+ channels,
+ columns,
+ sections = null,
+ siteId,
+ mediaDestinations = [],
+ defaultLocationId = "",
+ sites = [],
+ focusLabel = null,
+ volumes = [],
+ locationFilter = null,
+ initialSort = null,
+}: {
+ channels: ChannelRowView[];
+ // Which pipelines to draw, in group order, resolved on the server from the
+ // live settings. A switched-off feature is simply absent — which is the
+ // honest rendering: an empty column because nobody enabled attribution is not
+ // the same as an attribution lane with nothing left to do.
+ columns: PipelineColumn[];
+ // Null under "all sites" (or with no sites configured): groups only partition
+ // a SINGLE site's channels, so there is no one grouping to render across the
+ // pool. That path is today's flat table, unchanged.
+ sections?: ChannelGroupSectionView[] | null;
+ siteId?: string;
+ // The configured storage locations, resolved on the server. The selection
+ // deck's destination list; empty when none are configured, which leaves the
+ // deck's free-text root box as the whole control.
+ mediaDestinations?: BulkDestination[];
+ // `settings.storage.defaultLocationId` — which destination the deck opens on.
+ defaultLocationId?: string;
+ // Every configured site, for the "Focus site" control. Not the same list as
+ // the page's scope selector: a focus is corpus-wide, so it can name a site
+ // whose channels are not the ones on screen.
+ sites?: FocusSite[];
+ // What the focus selector currently names, resolved on the server, or null.
+ focusLabel?: string | null;
+ // One entry per storage volume the channels on screen live on, with its free
+ // space. Rendered ONCE above the rack, never per row — see ChannelVolumeBar.
+ volumes?: ChannelVolume[];
+ // `?location=`, already validated by the server against what is on screen.
+ locationFilter?: string | null;
+ // The sort the URL asked for (`?sort=size`, which is how /storage links to a
+ // "largest first" list). Only the initial value: sorting is client state
+ // from then on, because a router.replace races the global AutoRefresh.
+ initialSort?: "size" | null;
+}) {
+ // Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a
+ // re-render that reorders or drops a row cannot retarget the selection —
+ // the same reason SyncConsole keys its selection this way.
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
+ // Plain component state, deliberately NOT the URL: router.replace races the
+ // global AutoRefresh's router.refresh() and gets dropped.
+ const [grouped, setGrouped] = useState(true);
+
+ // Always the intersection with what is on screen: a slug can leave the table
+ // between renders (a scope change, a deletion), and a bulk edit must not act
+ // on a row nobody can see.
+ const selectedSlugs = channels
+ .map((c) => c.slug)
+ .filter((s) => selected.has(s));
+ // "FREE UP N GB" — the arithmetic the operator was doing by hand.
+ //
+ // The rule lives in common/views/freeUpSelection.ts (pure, unit-tested); this
+ // is the wire between it and the tick boxes. It SELECTS and does not act:
+ // what moves anything is the deck's existing Move button, with its existing
+ // destination and its existing per-channel skips.
+ //
+ // Always over `channels` — what is on screen — so a volume filter or a site
+ // scope narrows the proposal exactly as the operator expects.
+ const [freeUpNote, setFreeUpNote] = useState<string | null>(null);
+ function freeUp(targetGB: number) {
+ const result = selectToFreeBytes(
+ channels.map((c) => ({
+ slug: c.slug,
+ bytes: c.mediaBytes,
+ inPlace: c.volumeId === INTERNAL_ROW_ID,
+ // A MARKER IS A GUARANTEED SKIP. The bulk move refuses a channel whose
+ // media is in transition by name, so proposing one would put a row in
+ // the deck that cannot move and count its bytes toward a total the
+ // Move will never deliver.
+ inTransition: c.media?.status === "in-transition",
+ })),
+ targetGB * 1024 ** 3,
+ );
+ setSelected(new Set(result.slugs));
+ setFreeUpNote(`${formatBytes(result.bytes)} selected. ${result.note}`);
+ }
+
+ function toggleOne(slug: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (!next.delete(slug)) next.add(slug);
+ return next;
+ });
+ }
+
+ // A GROUP HEADER PINS UNDER THE COLUMN HEADER, not over it. The offset is the
+ // thead's real height rather than a guess, because the head grows a line when
+ // the pipeline block's eyebrow renders and shrinks again when settings switch
+ // every derived operation off. `top-9` is the fallback the class carries for
+ // the first paint and for a browser with no ResizeObserver.
+ const regionRef = useRef<HTMLDivElement>(null);
+ const theadRef = useRef<HTMLTableSectionElement>(null);
+ useEffect(() => {
+ const region = regionRef.current;
+ const thead = theadRef.current;
+ if (!region || !thead || typeof ResizeObserver === "undefined") return;
+ const measure = () =>
+ region.style.setProperty("--thead-h", `${thead.offsetHeight}px`);
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(thead);
+ return () => observer.disconnect();
+ }, []);
+
+ // HOW MANY ROWS THE FOCUS IS HOLDING, for the focus line's one-line summary.
+ // The reason itself stays per-row (each held chip carries the full sentence
+ // as its screen-reader text); the COUNT is the corpus-wide fact, and stating
+ // it once above the table is what lets the rows stop repeating the sentence.
+ const heldCount = channels.filter((c) => c.priority.heldReason).length;
+
+ return (
+ // On md+ this is a pane of the page's viewport-height flex column: the
+ // instrument bar and the deck are fixed chrome and only the region between
+ // them scrolls. Below md it has no height of its own and the document
+ // scrolls, exactly as it always has.
+ <div className="flex min-h-0 flex-1 flex-col">
+ <ChannelFocusBar
+ sites={sites}
+ focusLabel={focusLabel}
+ heldCount={heldCount}
+ />
+ <ChannelVolumeBar
+ volumes={volumes}
+ active={locationFilter}
+ onFreeUp={freeUp}
+ />
+ {/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left,
+ how to read it (the band legend, and the two-route explainer behind a
+ disclosure) on the right. Both used to live in an 11px stack UNDER 67
+ rows, where the legend for a chart is of no use to anyone. */}
+ <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-2 text-xs text-muted-foreground">
+ {sections && sections.length > 0 ? (
+ <label className="flex items-center gap-2">
+ <input
+ type="checkbox"
+ checked={grouped}
+ onChange={(e) => setGrouped(e.target.checked)}
+ className="accent-primary"
+ />
+ Group by section
+ </label>
+ ) : (
+ <span />
+ )}
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
+ <BandLegend />
+ {columns.some((c) => c.id.startsWith("attribution-")) && (
+ <details className="max-w-prose">
+ <summary className="cursor-pointer hover:text-foreground">
+ Names·A / Names·T
+ </summary>
+ <p className="pt-1">
+ Speaker names come from the audio (Names·A) or the
+ transcript (Names·T) — two routes to the same
+ field, counted separately because they cost different things.
+ </p>
+ </details>
+ )}
+ </div>
+ </div>
+ {/* THE RACK. One scroll box: the thead pins to its top, the checkbox and
+ Slug cells pin to its left, and the sixteen columns move underneath
+ them. The table itself must NOT clip (`overflow-hidden` would make it
+ the sticky ancestor and nothing would pin) — the rounded corners are
+ the region's. */}
+ <div
+ ref={regionRef}
+ className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border"
+ >
+ <ChannelsTable
+ rows={channels}
+ columns={RACK_COLUMNS}
+ pipelineColumns={columns}
+ sort={{ initialKey: initialSort }}
+ selection={{
+ selected,
+ onToggle: toggleOne,
+ onToggleAll: (checked) =>
+ setSelected(
+ checked ? new Set(channels.map((c) => c.slug)) : new Set(),
+ ),
+ }}
+ sections={grouped ? sections : null}
+ siteId={siteId}
+ sticky
+ theadRef={theadRef}
+ />
+ </div>
+ {/* OUTSIDE the overflow box, always: inside it, `sticky bottom-0` pins to
+ the table's scroll box rather than to the screen, which is what put
+ the old bar off the bottom of a phone. */}
+ <ChannelSelectionDeck
+ slugs={selectedSlugs}
+ onClear={() => {
+ setSelected(new Set());
+ setFreeUpNote(null);
+ }}
+ destinations={mediaDestinations}
+ defaultLocationId={defaultLocationId}
+ // THE NOTE, NOT THE CONTROL. The helper itself lives in the volume bar
+ // (it is about volumes and free space, and it has to be reachable with
+ // nothing ticked — the deck does not exist then). What belongs down
+ // here is what the proposal actually came to, beside the Move button
+ // that will act on it.
+ freeUpNote={freeUpNote}
+ />
+ </div>
+ );
+}
+
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -1,125 +1,40 @@
"use client";
-import Link from "next/link";
-import { useEffect, useMemo, useRef, useState } from "react";
-import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels";
+import { useMemo, useState, type ReactNode, type Ref } from "react";
+import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow";
import {
bandSentence,
type OperationBand,
} from "yt-dlp-transcript-common/views/pipeline/band";
-import { BandLegend, StateBand } from "../../components/pipelines/StateBand";
-import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections";
+import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections";
+import { tierOrder } from "yt-dlp-transcript-common/lib/channelPriority";
+import { StateBand } from "../../components/pipelines/StateBand";
import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow";
-import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton";
-import { ChannelBuildToggle } from "./ChannelBuildToggle";
-import { ChannelSyncButton } from "./ChannelSyncButton";
-import ChannelTierSelect from "./ChannelTierSelect";
-import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar";
-import { InlineActionButton } from "../../components/actions/InlineActionButton";
import {
- MediaLocationBadge,
- type MediaBadgeInput,
-} from "../../components/MediaLocationBadge";
-import {
- ChannelSelectionDeck,
- type BulkDestination,
-} from "./ChannelSelectionDeck";
-import { ChannelVolumeBar, type ChannelVolume } from "./ChannelVolumeBar";
-import { formatBytes } from "yt-dlp-transcript-common/lib/format";
-import { selectToFreeBytes } from "yt-dlp-transcript-common/views/freeUpSelection";
-// A VALUE import from views/, and it is safe: every import in that module is a
-// type import, so what reaches the client bundle is two string constants. The
-// point is that "internal" and "location" have ONE spelling across /storage
-// (which writes the link) and /channels (which reads it).
-import { INTERNAL_ROW_ID } from "yt-dlp-transcript-common/views/storage";
-import {
- tierOrder,
- type PriorityOperation,
- type StoredChannelTier,
-} from "yt-dlp-transcript-common/lib/channelPriority";
-
-// One row's share of the channel priority document, resolved on the server.
-// The row never reads the model itself: `focused` and `heldReason` are facts
-// about the corpus-wide focus selector, which no single row can answer.
-export type ChannelRowPriority = {
- tier: StoredChannelTier;
- rank: number | null;
- overrides: Partial<Record<PriorityOperation, StoredChannelTier>>;
- focused: boolean;
- heldReason: string | null;
- // Why the machine paused it, or null. See autoPauseReasonOf.
- autoPausedReason: string | null;
-};
+ CHANNEL_COLUMNS,
+ type ChannelCellCtx,
+ type ChannelColumnId,
+ type ChannelColumnLabels,
+ type ChannelRowExtras,
+ type ChannelSortKey,
+ type PipelineColumn,
+} from "./channelColumns";
-// A row is a stat plus its pipeline bands, in column order. The bands are
-// projected on the server from the same snapshot the counts come from, so a
-// figure in a band and the count beside it cannot disagree.
-export type ChannelRow = ChannelStat & {
- pipelines: OperationBand[];
- // Its tier, its pins, and whether the active focus is holding it. Replaces
- // the row's sync-inclusion flag: one ordered model instead of one boolean.
- priority: ChannelRowPriority;
- // How old this channel's report is. Every count and every band on this row is
- // projected from that report, so its age is the caveat on all of them — which
- // is why it belongs beside them rather than on a page of its own.
- report: {
- generatedAt: string | null;
- state: "current" | "stale" | "missing";
- };
- // Where this channel's media physically is, from inspectChannelMedia on the
- // server. Null for an in-place channel — the overwhelming majority — so the
- // badge column is empty for them and the two that matter stand out. See
- // components/MediaLocationBadge.tsx.
- media: MediaBadgeInput | null;
- // WHICH VOLUME, as an id the filter can name: a location id, "internal" for
- // the corpus volume, or "" for a dataDir under a root nobody named. Derived
- // on the server from `config.dataDir` — a pure prefix match, never a probe.
- volumeId: string;
- volumeLabel: string;
- // `snapshot.totalMediaBytes`. NULL, not 0, for a report written before the
- // field existed: the Size cell renders that as "—" and the sort puts it
- // first on ascending, because a 400 GB channel that has not been measured
- // must not sort as the smallest thing on the disk.
- mediaBytes: number | null;
-};
-
-// A column heading for one pipeline. Comes off the operation registry on the
-// server (shortLabel, costBasis) rather than being abbreviated here — see
-// operations.ts.
-export type PipelineColumn = {
- id: string;
- shortLabel: string;
- label: string;
- costBasis: string;
- // The two externally-dispatched pipelines keep their existing count column
- // semantics: the cell's text node is the bare number the suite asserts on,
- // under this accessible name. The slug is appended by the cell, so this is
- // the prefix — `"downloads count"` becomes `"downloads count for foo"`, which
- // is the name channels-counts.spec.ts has always addressed these cells by.
- // Null for the derived operations, which have never had a count column and
- // get the band's sentence as their text.
- countLabel: string | null;
-};
-
-// Sorting a pipeline column sorts by `reachable` — "which channel has the most
-// diarizable audio left right now", a question this page has never been able to
-// answer without opening 68 channel pages one at a time.
-type SortKey =
- | "slug"
- | "name"
- | "handling"
- | "build"
- | "tier"
- | "playlist"
- | "lastSync"
- | "report"
- | "location"
- | "size"
- | `op:${string}`;
+// ONE CHANNEL TABLE — the /channels rack, the dashboard's channels table and
+// every operation page's work section draw their rows here, off the one
+// `ChannelRowView` (common/views/channelRow.ts), with whichever columns the
+// caller names from the registry in ./channelColumns.tsx. The rack's chrome —
+// focus bar, volume bar, instrument bar, scroll region, selection deck — is
+// ./ChannelsRack.tsx; this file is the thead, the tbodies, the rows and the
+// sort.
+//
+// Rows are a PROP, never state: the global AutoRefresh re-renders the server
+// page and hands down fresh rows, and a table that copied them into state would
+// draw the first render's numbers forever.
type SortDir = "asc" | "desc";
-type SortState = { key: SortKey; dir: SortDir } | null;
+export type ChannelSortState = { key: ChannelSortKey; dir: SortDir } | null;
const DEFAULT_DIR: Record<string, SortDir> = {
slug: "asc",
@@ -143,11 +58,11 @@ const DEFAULT_DIR: Record<string, SortDir> = {
// A pipeline column defaults to `reachable` descending: the first click puts the
// channels with work at the top, which is the only reason to sort by a pipeline
// at all.
-function defaultDirFor(key: SortKey): SortDir {
+export function defaultDirFor(key: ChannelSortKey): SortDir {
return DEFAULT_DIR[key] ?? "desc";
}
-function bandOf(row: ChannelRow, id: string): OperationBand | undefined {
+function bandOf(row: ChannelRowView, id: string): OperationBand | undefined {
return row.pipelines.find((b) => b.id === id);
}
@@ -172,7 +87,10 @@ function compareNumbers(
return (a as number) - (b as number);
}
-function compareDates(a: string | undefined, b: string | undefined): number {
+function compareDates(
+ a: string | null | undefined,
+ b: string | null | undefined,
+): number {
// Missing dates sort as oldest (so stale channels surface first on asc).
const aHas = !!a;
const bHas = !!b;
@@ -196,7 +114,7 @@ function compareBools(a: boolean, b: boolean): number {
return (a ? 1 : 0) - (b ? 1 : 0);
}
-function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
+function cmp(a: ChannelRowView, b: ChannelRowView, key: ChannelSortKey): number {
if (key.startsWith("op:")) {
const id = key.slice(3);
return compareNumbers(bandOf(a, id)?.reachable, bandOf(b, id)?.reachable);
@@ -205,14 +123,11 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
case "slug":
return a.slug.localeCompare(b.slug);
case "name":
- return compareStrings(a.config.name, b.config.name);
+ return compareStrings(a.name, b.name);
case "handling":
- return compareStrings(a.config.handling, b.config.handling);
+ return compareStrings(a.handling, b.handling);
case "build":
- return compareBools(
- a.config.excludeFromBuild === true,
- b.config.excludeFromBuild === true,
- );
+ return compareBools(a.excludeFromBuild, b.excludeFromBuild);
case "tier":
// THE COMPILED ORDER, not the stored one: focus is a position the focus
// selector produces, so a focused channel sorts above every normal one
@@ -227,12 +142,9 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
case "playlist":
return compareNumbers(a.playlistCount, b.playlistCount);
case "lastSync":
- return compareDates(a.config.lastSyncedAt, b.config.lastSyncedAt);
+ return compareDates(a.lastSyncedAt, b.lastSyncedAt);
case "report":
- return compareDates(
- a.report.generatedAt ?? undefined,
- b.report.generatedAt ?? undefined,
- );
+ return compareDates(a.report.generatedAt, b.report.generatedAt);
case "location":
// By LABEL, then slug: the operator reads names, and a stable tiebreak
// keeps the two halves of a volume from shuffling between renders.
@@ -254,130 +166,98 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
// Pure, and module-level so the grouped render can apply the SAME sort state
// independently to each section — which is what "sorts within each group" means.
function applySort(
- list: ReadonlyArray<ChannelRow>,
- sort: SortState,
-): ReadonlyArray<ChannelRow> {
+ list: ReadonlyArray<ChannelRowView>,
+ sort: ChannelSortState,
+): ReadonlyArray<ChannelRowView> {
if (!sort) return list;
const sorted = [...list].sort((a, b) => cmp(a, b, sort.key));
return sort.dir === "desc" ? sorted.reverse() : sorted;
}
+export type ChannelSelection = {
+ selected: ReadonlySet<string>;
+ onToggle: (slug: string) => void;
+ onToggleAll: (checked: boolean) => void;
+};
+
export function ChannelsTable({
- channels,
+ rows: channels,
columns,
+ pipelineColumns = [],
+ rowLabelPrefix,
+ rowExtras,
+ labels = {},
+ sort: sortConfig,
+ selection,
sections = null,
siteId,
- mediaDestinations = [],
- defaultLocationId = "",
- sites = [],
- focusLabel = null,
- volumes = [],
- locationFilter = null,
- initialSort = null,
+ sticky = false,
+ theadRef,
+ now = null,
}: {
- channels: ChannelRow[];
- // Which pipelines to draw, in group order, resolved on the server from the
- // live settings. A switched-off feature is simply absent — which is the
- // honest rendering: an empty column because nobody enabled attribution is not
- // the same as an attribution lane with nothing left to do.
- columns: PipelineColumn[];
- // Null under "all sites" (or with no sites configured): groups only partition
- // a SINGLE site's channels, so there is no one grouping to render across the
- // pool. That path is today's flat table, unchanged.
- sections?: ChannelGroupSection[] | null;
+ rows: ChannelRowView[];
+ // Which columns, in order, by registry id (./channelColumns.tsx).
+ columns: readonly ChannelColumnId[];
+ // What the `pipelines` column expands into, in group order, resolved on the
+ // server from the live settings. A switched-off feature is simply absent —
+ // which is the honest rendering: an empty column because nobody enabled
+ // attribution is not the same as an attribution lane with nothing left.
+ pipelineColumns?: PipelineColumn[];
+ // `<tr aria-label="${prefix} row ${slug}">` — the dashboard's `channel row`,
+ // a work section's `<id> row`. The rack's rows carry none.
+ rowLabelPrefix?: string;
+ // Per-row data (and rendered elements) a server caller adds; see
+ // ChannelRowExtras.
+ rowExtras?: Record<string, ChannelRowExtras>;
+ labels?: ChannelColumnLabels;
+ // False: plain headers, rows in the order given. Otherwise the header
+ // buttons sort (client state — a router.replace races the AutoRefresh), from
+ // `initialKey` when the URL asked for one (`?sort=size`).
+ sort: false | { initialKey?: ChannelSortKey | null };
+ // False: no checkbox column. The rack owns the set (its deck acts on it).
+ selection: false | ChannelSelection;
+ // One tbody per group, each with its header row. Null (or no siteId) is the
+ // flat table: groups only partition a SINGLE site's channels.
+ sections?: ChannelGroupSectionView[] | null;
siteId?: string;
- // The configured storage locations, resolved on the server. The selection
- // deck's destination list; empty when none are configured, which leaves the
- // deck's free-text root box as the whole control.
- mediaDestinations?: BulkDestination[];
- // `settings.storage.defaultLocationId` — which destination the deck opens on.
- defaultLocationId?: string;
- // Every configured site, for the "Focus site" control. Not the same list as
- // the page's scope selector: a focus is corpus-wide, so it can name a site
- // whose channels are not the ones on screen.
- sites?: FocusSite[];
- // What the focus selector currently names, resolved on the server, or null.
- focusLabel?: string | null;
- // One entry per storage volume the channels on screen live on, with its free
- // space. Rendered ONCE above the rack, never per row — see ChannelVolumeBar.
- volumes?: ChannelVolume[];
- // `?location=`, already validated by the server against what is on screen.
- locationFilter?: string | null;
- // The sort the URL asked for (`?sort=size`, which is how /storage links to a
- // "largest first" list). Only the initial value: sorting is client state
- // from then on, because a router.replace races the global AutoRefresh.
- initialSort?: "size" | null;
+ // The rack: the thead pins to the scroll region's top and the checkbox and
+ // Slug cells pin to its left. The caller owns the scroll region.
+ sticky?: boolean;
+ theadRef?: Ref<HTMLTableSectionElement>;
+ // The live clock for relative dates; null until mounted.
+ now?: number | null;
}) {
- const [sort, setSort] = useState<SortState>(
- initialSort ? { key: initialSort, dir: defaultDirFor(initialSort) } : null,
+ const initialKey = sortConfig ? (sortConfig.initialKey ?? null) : null;
+ const [sort, setSort] = useState<ChannelSortState>(
+ initialKey ? { key: initialKey, dir: defaultDirFor(initialKey) } : null,
);
- // Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a
- // re-render that reorders or drops a row cannot retarget the selection —
- // the same reason SyncConsole keys its selection this way.
- const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
- // Plain component state, deliberately NOT the URL: router.replace races the
- // global AutoRefresh's router.refresh() and gets dropped.
- const [grouped, setGrouped] = useState(true);
+ const sortable = sortConfig !== false;
const rows = useMemo(() => applySort(channels, sort), [channels, sort]);
- // A section lists ChannelStats; the bands live on the ChannelRow the page
- // built for the same slug. Joined by slug, never by index — pairing one
- // channel's counts with another channel's bands is exactly the silent
- // wrongness the row type exists to remove.
+ // A section lists ChannelStats; the row the page built carries the same
+ // slug. Joined by slug, never by index — pairing one channel's counts with
+ // another channel's bands is exactly the silent wrongness the row type
+ // exists to remove.
const rowBySlug = useMemo(
() => new Map(channels.map((c) => [c.slug, c])),
[channels],
);
- const showSections = grouped && !!sections && sections.length > 0 && !!siteId;
- // The select column, ten fixed columns (Location and Size joined the eight),
- // one per pipeline, then Actions.
- const colSpan = 12 + columns.length;
- // Always the intersection with what is on screen: a slug can leave the table
- // between renders (a scope change, a deletion), and a bulk edit must not act
- // on a row nobody can see.
- const selectedSlugs = channels
- .map((c) => c.slug)
- .filter((s) => selected.has(s));
+ const showSections = !!sections && sections.length > 0 && !!siteId;
+ const visibleColumns = columns.filter(
+ (id) => id !== "select" || selection !== false,
+ );
+ // Computed from what is drawn, never a literal: every column counts one,
+ // except `pipelines`, which counts one per pipeline.
+ const colSpan = visibleColumns.reduce(
+ (n, id) => n + (id === "pipelines" ? pipelineColumns.length : 1),
+ 0,
+ );
const allSelected =
- channels.length > 0 && selectedSlugs.length === channels.length;
+ selection !== false &&
+ channels.length > 0 &&
+ channels.every((c) => selection.selected.has(c.slug));
- // "FREE UP N GB" — the arithmetic the operator was doing by hand.
- //
- // The rule lives in common/views/freeUpSelection.ts (pure, unit-tested); this
- // is the wire between it and the tick boxes. It SELECTS and does not act:
- // what moves anything is the deck's existing Move button, with its existing
- // destination and its existing per-channel skips.
- //
- // Always over `channels` — what is on screen — so a volume filter or a site
- // scope narrows the proposal exactly as the operator expects.
- const [freeUpNote, setFreeUpNote] = useState<string | null>(null);
- function freeUp(targetGB: number) {
- const result = selectToFreeBytes(
- channels.map((c) => ({
- slug: c.slug,
- bytes: c.mediaBytes,
- inPlace: c.volumeId === INTERNAL_ROW_ID,
- // A MARKER IS A GUARANTEED SKIP. The bulk move refuses a channel whose
- // media is in transition by name, so proposing one would put a row in
- // the deck that cannot move and count its bytes toward a total the
- // Move will never deliver.
- inTransition: c.media?.status === "in-transition",
- })),
- targetGB * 1024 ** 3,
- );
- setSelected(new Set(result.slugs));
- setFreeUpNote(`${formatBytes(result.bytes)} selected. ${result.note}`);
- }
-
- function toggleOne(slug: string) {
- setSelected((prev) => {
- const next = new Set(prev);
- if (!next.delete(slug)) next.add(slug);
- return next;
- });
- }
-
- function onHeaderClick(key: SortKey) {
+ function onHeaderClick(key: ChannelSortKey) {
setSort((prev) => {
if (prev && prev.key === key) {
return { key, dir: prev.dir === "asc" ? "desc" : "asc" };
@@ -386,268 +266,152 @@ export function ChannelsTable({
});
}
- // A GROUP HEADER PINS UNDER THE COLUMN HEADER, not over it. The offset is the
- // thead's real height rather than a guess, because the head grows a line when
- // the pipeline block's eyebrow renders and shrinks again when settings switch
- // every derived operation off. `top-9` is the fallback the class carries for
- // the first paint and for a browser with no ResizeObserver.
- const regionRef = useRef<HTMLDivElement>(null);
- const theadRef = useRef<HTMLTableSectionElement>(null);
- useEffect(() => {
- const region = regionRef.current;
- const thead = theadRef.current;
- if (!region || !thead || typeof ResizeObserver === "undefined") return;
- const measure = () =>
- region.style.setProperty("--thead-h", `${thead.offsetHeight}px`);
- measure();
- const observer = new ResizeObserver(measure);
- observer.observe(thead);
- return () => observer.disconnect();
- }, []);
+ const pad = sticky ? "px-2 py-1.5" : "px-3 py-2";
+ const head: ReactNode[] = [];
+ for (const id of visibleColumns) {
+ if (id === "select") {
+ head.push(
+ <th
+ key="select"
+ className={`${sticky ? "sticky left-0 z-10 bg-muted " : ""}w-9 px-2 py-1.5 align-bottom`}
+ >
+ <input
+ type="checkbox"
+ aria-label="select all channels"
+ checked={allSelected}
+ onChange={(e) =>
+ selection !== false && selection.onToggleAll(e.target.checked)
+ }
+ className="accent-primary"
+ />
+ </th>,
+ );
+ continue;
+ }
+ if (id === "pipelines") {
+ // THE METER BRIDGE. Six loose grey dashes become one block: the band
+ // columns share an eyebrow naming them, and the rules that open and
+ // close the block run the full height of the rack.
+ pipelineColumns.forEach((col, i) => {
+ const className =
+ "w-24 min-w-20" +
+ (i === 0 ? " border-l border-border" : "") +
+ (i === pipelineColumns.length - 1 ? " border-r border-border" : "");
+ head.push(
+ sortable ? (
+ <SortableTh
+ key={col.id}
+ label={col.shortLabel}
+ sortKey={`op:${col.id}`}
+ sort={sort}
+ onClick={onHeaderClick}
+ eyebrow={i === 0 ? "Pipeline" : undefined}
+ className={className}
+ title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`}
+ />
+ ) : (
+ <th
+ key={col.id}
+ className={`text-left ${pad} font-medium ${className}`}
+ title={`${col.label} — ${col.costBasis}`}
+ >
+ {col.shortLabel}
+ </th>
+ ),
+ );
+ });
+ continue;
+ }
+ const col = CHANNEL_COLUMNS[id];
+ const label = labels[id] ?? col.label;
+ const className = [
+ col.th?.className ?? "",
+ sticky ? (col.th?.stickyClassName ?? "") : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+ if (sortable && col.sortKey) {
+ head.push(
+ <SortableTh
+ key={id}
+ label={label}
+ sortKey={col.sortKey}
+ sort={sort}
+ onClick={onHeaderClick}
+ align={col.align}
+ className={className}
+ title={col.th?.title}
+ />,
+ );
+ } else {
+ head.push(
+ <th
+ key={id}
+ className={`${col.align === "right" ? "text-right" : "text-left"} ${pad}${sticky ? " align-bottom" : ""} font-medium ${className}`}
+ title={col.th?.title}
+ >
+ {label}
+ </th>,
+ );
+ }
+ }
- // HOW MANY ROWS THE FOCUS IS HOLDING, for the focus line's one-line summary.
- // The reason itself stays per-row (each held chip carries the full sentence
- // as its screen-reader text); the COUNT is the corpus-wide fact, and stating
- // it once above the table is what lets the rows stop repeating the sentence.
- const heldCount = channels.filter((c) => c.priority.heldReason).length;
+ const renderRow = (c: ChannelRowView) => (
+ <ChannelTableRow
+ key={c.slug}
+ channel={c}
+ columns={visibleColumns}
+ pipelineColumns={pipelineColumns}
+ selection={selection}
+ extras={rowExtras?.[c.slug]}
+ rowLabelPrefix={rowLabelPrefix}
+ sticky={sticky}
+ now={now}
+ />
+ );
- return (
- // On md+ this is a pane of the page's viewport-height flex column: the
- // instrument bar and the deck are fixed chrome and only the region between
- // them scrolls. Below md it has no height of its own and the document
- // scrolls, exactly as it always has.
- <div className="flex min-h-0 flex-1 flex-col">
- <ChannelFocusBar
- sites={sites}
- focusLabel={focusLabel}
- heldCount={heldCount}
- />
- <ChannelVolumeBar
- volumes={volumes}
- active={locationFilter}
- onFreeUp={freeUp}
- />
- {/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left,
- how to read it (the band legend, and the two-route explainer behind a
- disclosure) on the right. Both used to live in an 11px stack UNDER 67
- rows, where the legend for a chart is of no use to anyone. */}
- <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-2 text-xs text-muted-foreground">
- {sections && sections.length > 0 ? (
- <label className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={grouped}
- onChange={(e) => setGrouped(e.target.checked)}
- className="accent-primary"
- />
- Group by section
- </label>
- ) : (
- <span />
- )}
- <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
- <BandLegend />
- {columns.some((c) => c.id.startsWith("attribution-")) && (
- <details className="max-w-prose">
- <summary className="cursor-pointer hover:text-foreground">
- Names·A / Names·T
- </summary>
- <p className="pt-1">
- Speaker names come from the audio (Names·A) or the
- transcript (Names·T) — two routes to the same
- field, counted separately because they cost different things.
- </p>
- </details>
- )}
- </div>
- </div>
- {/* THE RACK. One scroll box: the thead pins to its top, the checkbox and
- Slug cells pin to its left, and the sixteen columns move underneath
- them. The table itself must NOT clip (`overflow-hidden` would make it
- the sticky ancestor and nothing would pin) — the rounded corners are
- the region's. */}
- <div
- ref={regionRef}
- className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border"
+ const table = (
+ <table
+ className={
+ sticky
+ ? "w-full text-sm"
+ : "text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full"
+ }
+ >
+ <thead
+ ref={theadRef}
+ className={sticky ? "sticky top-0 z-30 bg-muted" : "bg-muted"}
>
- <table className="w-full text-sm">
- <thead ref={theadRef} className="sticky top-0 z-30 bg-muted">
- <tr>
- <th className="sticky left-0 z-10 w-9 bg-muted px-2 py-1.5 align-bottom">
- <input
- type="checkbox"
- aria-label="select all channels"
- checked={allSelected}
- onChange={(e) =>
- setSelected(
- e.target.checked
- ? new Set(channels.map((c) => c.slug))
- : new Set(),
- )
- }
- className="accent-primary"
- />
- </th>
- <SortableTh
- label="Slug"
- sortKey="slug"
- sort={sort}
- onClick={onHeaderClick}
- className="sticky left-8 z-20 bg-muted shadow-[1px_0_0_var(--color-border)]"
- />
- <SortableTh
- label="Name"
- sortKey="name"
- sort={sort}
- onClick={onHeaderClick}
- />
- <SortableTh
- label="Handling"
- sortKey="handling"
- sort={sort}
- onClick={onHeaderClick}
- />
- <SortableTh
- label="Build"
- sortKey="build"
- sort={sort}
- onClick={onHeaderClick}
- />
- <SortableTh
- label="Tier"
- sortKey="tier"
- sort={sort}
- onClick={onHeaderClick}
- title="Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column."
- />
- <SortableTh
- label="Playlist"
- sortKey="playlist"
- sort={sort}
- onClick={onHeaderClick}
- align="right"
- />
- <SortableTh
- label="Last sync"
- sortKey="lastSync"
- sort={sort}
- onClick={onHeaderClick}
- className="whitespace-nowrap"
- />
- <SortableTh
- label="Report"
- sortKey="report"
- sort={sort}
- onClick={onHeaderClick}
- className="whitespace-nowrap"
- title="When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong."
- />
- {/* THE TWO STORAGE COLUMNS. Which disk the media is on, and how
- much of it there is — the pair that makes "what should I move"
- answerable without opening 71 channel pages. Free space is
- deliberately NOT here: it is a fact about a volume, not about
- a row, and it is stated once in the volume bar above. */}
- <SortableTh
- label="Location"
- sortKey="location"
- sort={sort}
- onClick={onHeaderClick}
- className="whitespace-nowrap"
- title="Which storage volume this channel's media is on. Internal is the corpus disk; anything else is a location configured on /storage."
- />
- <SortableTh
- label="Size"
- sortKey="size"
- sort={sort}
- onClick={onHeaderClick}
- align="right"
- className="whitespace-nowrap"
- title="Every byte under this channel's data/ — audio, transcripts, cues, metadata — from its last report. Sorts biggest first."
- />
- {/* THE METER BRIDGE. Six loose grey dashes become one block: the
- band columns share an eyebrow naming them, and the rules that
- open and close the block run the full height of the rack. */}
- {columns.map((col, i) => (
- <SortableTh
- key={col.id}
- label={col.shortLabel}
- sortKey={`op:${col.id}`}
- sort={sort}
- onClick={onHeaderClick}
- eyebrow={i === 0 ? "Pipeline" : undefined}
- className={
- "w-24 min-w-20" +
- (i === 0 ? " border-l border-border" : "") +
- (i === columns.length - 1 ? " border-r border-border" : "")
- }
- title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`}
- />
- ))}
- <th className="whitespace-nowrap px-2 py-1.5 text-left align-bottom font-medium">
- Actions
- </th>
- </tr>
- </thead>
- {showSections ? (
- // One sort state, applied independently per section — which IS
- // "sorts within each group".
- sections.map((section) => (
- <tbody
- key={section.group.id}
- aria-labelledby={`group-${section.group.id}`}
- >
- <ChannelGroupHeaderRow
- section={section}
- siteId={siteId as string}
- colSpan={colSpan}
- />
- {applySort(
- section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []),
- sort,
- ).map((c) => (
- <ChannelTableRow
- key={c.slug}
- channel={c}
- columns={columns}
- selected={selected.has(c.slug)}
- onToggle={toggleOne}
- />
- ))}
- </tbody>
- ))
- ) : (
- <tbody>
- {rows.map((c) => (
- <ChannelTableRow
- key={c.slug}
- channel={c}
- columns={columns}
- selected={selected.has(c.slug)}
- onToggle={toggleOne}
- />
- ))}
- </tbody>
- )}
- </table>
- </div>
- {/* OUTSIDE the overflow box, always: inside it, `sticky bottom-0` pins to
- the table's scroll box rather than to the screen, which is what put
- the old bar off the bottom of a phone. */}
- <ChannelSelectionDeck
- slugs={selectedSlugs}
- onClear={() => {
- setSelected(new Set());
- setFreeUpNote(null);
- }}
- destinations={mediaDestinations}
- defaultLocationId={defaultLocationId}
- // THE NOTE, NOT THE CONTROL. The helper itself lives in the volume bar
- // (it is about volumes and free space, and it has to be reachable with
- // nothing ticked — the deck does not exist then). What belongs down
- // here is what the proposal actually came to, beside the Move button
- // that will act on it.
- freeUpNote={freeUpNote}
- />
+ <tr>{head}</tr>
+ </thead>
+ {showSections ? (
+ // One sort state, applied independently per section — which IS
+ // "sorts within each group".
+ sections.map((section) => (
+ <tbody
+ key={section.group.id}
+ aria-labelledby={`group-${section.group.id}`}
+ >
+ <ChannelGroupHeaderRow
+ section={section}
+ siteId={siteId as string}
+ colSpan={colSpan}
+ />
+ {applySort(
+ section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []),
+ sort,
+ ).map(renderRow)}
+ </tbody>
+ ))
+ ) : (
+ <tbody>{rows.map(renderRow)}</tbody>
+ )}
+ </table>
+ );
+ if (sticky) return table;
+ return (
+ <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
+ {table}
</div>
);
}
@@ -672,6 +436,7 @@ function PipelineCell({
band,
count,
className = "",
+ pad,
}: {
slug: string;
column: PipelineColumn;
@@ -687,12 +452,18 @@ function PipelineCell({
// close the block. Owned by the row, because only the row knows which cell is
// first and which is last.
className?: string;
+ pad: string;
}) {
if (!band)
- return <Td className={`text-muted-foreground ${className}`}>—</Td>;
+ return (
+ <Td pad={pad} className={`text-muted-foreground ${className}`}>
+ —
+ </Td>
+ );
const sentence = bandSentence(band);
return (
<Td
+ pad={pad}
className={className}
ariaLabel={`${column.countLabel ?? column.label} for ${slug}`}
title={`${column.label} — ${sentence}`}
@@ -705,24 +476,35 @@ function PipelineCell({
);
}
-// One channel's row, shared by the flat and the sectioned render so the two
-// can never drift apart column-for-column.
+// One channel's row, shared by the flat and the sectioned render — and by
+// every table that draws a channel — so they can never drift apart
+// column-for-column.
function ChannelTableRow({
channel: c,
columns,
- selected,
- onToggle,
+ pipelineColumns,
+ selection,
+ extras,
+ rowLabelPrefix,
+ sticky,
+ now,
}: {
- channel: ChannelRow;
- columns: PipelineColumn[];
- selected: boolean;
- onToggle: (slug: string) => void;
+ channel: ChannelRowView;
+ columns: readonly ChannelColumnId[];
+ pipelineColumns: PipelineColumn[];
+ selection: false | ChannelSelection;
+ extras: ChannelRowExtras | undefined;
+ rowLabelPrefix: string | undefined;
+ sticky: boolean;
+ now: number | null;
}) {
+ const selected = selection !== false && selection.selected.has(c.slug);
// A sticky cell paints over the row behind it, so it cannot inherit the row's
// background — it has to carry the same one explicitly or the rows would show
// through the pinned identity column while the rest scrolls.
const stickyBg = selected ? "bg-accent" : "bg-background";
const bridge = "bg-surface w-24 min-w-20";
+ const pad = sticky ? "px-2 py-1.5" : "px-3 py-2";
// Dimmed for the two things that take the row out of a pipeline: it is
// excluded from the export build, or its base tier is Paused. (The sync
// exclusion flag that used to dim it is now a `sync` pin, which is a
@@ -734,152 +516,101 @@ function ChannelTableRow({
// `absolute z-30` Advanced popover (ChannelTierSelect) can no longer paint
// above the rows that follow, however high its z-index — every later row
// draws over it and swallows the clicks. So the Tier cell — the one that
- // hosts the popover — is the one cell that is NOT dimmed. Moving this back
- // onto the row re-breaks the menu (operator report 2026-09-17).
+ // hosts the popover — is the one cell that is NOT dimmed (its registry entry
+ // says `dim: false`). Moving this back onto the row re-breaks the menu
+ // (operator report 2026-09-17).
+ //
+ // RACK ONLY. The dashboard and the work tables never dimmed a row: their
+ // priority is neutral, and build exclusion only affects the export — dimming
+ // a channel in an "undownloaded" list would read as "out of the pipeline"
+ // when it is not.
const dim =
- c.config.excludeFromBuild || c.priority.tier === "paused"
+ sticky && (c.excludeFromBuild || c.priority.tier === "paused")
? " opacity-60"
: "";
- return (
- <tr className={"border-t border-border " + (selected ? "bg-accent " : "")}>
- <td
- className={`sticky left-0 z-10 w-9 px-2 py-1.5 align-middle ${stickyBg}${dim}`}
- >
- <input
- type="checkbox"
- aria-label={`select ${c.slug}`}
- checked={selected}
- onChange={() => onToggle(c.slug)}
- className="accent-primary"
- />
- </td>
- <Td
- className={`sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${stickyBg}${dim}`}
- >
- <span className="inline-flex items-center gap-1.5">
- <Link
- href={`/channels/${c.slug}`}
- className="underline hover:text-foreground"
- >
- {c.slug}
- </Link>
- <MediaLocationBadge media={c.media} compact />
- </span>
- </Td>
- <Td className={`max-w-48 truncate${dim}`}>{c.config.name ?? ""}</Td>
- <Td className={`font-mono text-[11px] text-muted-foreground${dim}`}>
- {c.config.handling}
- </Td>
- <Td className={dim}>
- <ChannelBuildToggle
- slug={c.slug}
- excluded={c.config.excludeFromBuild === true}
- />
- </Td>
- <Td>
- <ChannelTierSelect
- slug={c.slug}
- tier={c.priority.tier}
- overrides={c.priority.overrides}
- focused={c.priority.focused}
- heldReason={c.priority.heldReason}
- autoPausedReason={c.priority.autoPausedReason}
- />
- </Td>
- <Td
- className={`text-right tabular-nums${dim}`}
- ariaLabel={`playlist count for ${c.slug}`}
- >
- {c.playlistCount ?? "—"}
- </Td>
- <Td
- className={`whitespace-nowrap text-xs tabular-nums text-muted-foreground${dim}`}
- >
- {formatStamp(c.config.lastSyncedAt)}
- </Td>
- <Td
- ariaLabel={`report age for ${c.slug}`}
- className={`whitespace-nowrap text-xs tabular-nums ${
- c.report.state === "current"
- ? "text-muted-foreground"
- : "text-warning"
- }${dim}`}
- >
- {c.report.state === "current"
- ? formatStamp(c.report.generatedAt)
- : c.report.state}
- </Td>
+ const ctx: ChannelCellCtx = {
+ dim,
+ stickyBg,
+ sticky,
+ selected,
+ onToggle: selection === false ? () => {} : selection.onToggle,
+ extras,
+ now,
+ };
+ const cells: ReactNode[] = [];
+ for (const id of columns) {
+ if (id === "select") {
+ cells.push(
+ <Td
+ key="select"
+ pad="px-2 py-1.5"
+ className={`${sticky ? `sticky left-0 z-10 ${stickyBg} ` : ""}w-9${dim}`}
+ >
+ <input
+ type="checkbox"
+ aria-label={`select ${c.slug}`}
+ checked={selected}
+ onChange={() => ctx.onToggle(c.slug)}
+ className="accent-primary"
+ />
+ </Td>,
+ );
+ continue;
+ }
+ if (id === "pipelines") {
+ pipelineColumns.forEach((col, i) => {
+ cells.push(
+ <PipelineCell
+ key={col.id}
+ pad={pad}
+ slug={c.slug}
+ column={col}
+ band={bandOf(c, col.id)}
+ className={
+ bridge +
+ (i === 0 ? " border-l border-border" : "") +
+ (i === pipelineColumns.length - 1
+ ? " border-r border-border"
+ : "") +
+ dim
+ }
+ count={
+ col.id === "download"
+ ? c.downloadCount
+ : col.id === "transcription"
+ ? c.transcriptCount
+ : null
+ }
+ />,
+ );
+ });
+ continue;
+ }
+ const col = CHANNEL_COLUMNS[id];
+ const cell = col.cell(c, ctx);
+ cells.push(
<Td
- ariaLabel={`media location for ${c.slug}`}
- className={`whitespace-nowrap text-xs${dim}`}
- title={c.media?.target ?? undefined}
+ key={id}
+ pad={pad}
+ className={`${cell.className ?? ""}${col.dim === false ? "" : dim}`}
+ ariaLabel={cell.ariaLabel}
+ title={cell.title}
+ suppressHydrationWarning={cell.suppressHydrationWarning}
>
- {/* THE LABEL ONLY — NO SECOND BADGE. The unreachable / in-transition
- marking is the badge's, and the badge is already on this row, in the
- Slug cell beside the channel's name; drawing another here put two
- elements with the same accessible name on every relocated row, which
- is a strict-mode violation for the suite and a screen reader reading
- the same sentence twice for a human. The COLUMN answers "which
- disk"; the badge answers "can it be reached", and one of each per
- row is the right number. */}
- {c.volumeLabel}
- </Td>
- <Td
- ariaLabel={`media size for ${c.slug}`}
- className={`whitespace-nowrap text-right tabular-nums${dim}`}
- title={
- c.mediaBytes === null
- ? "No size in this channel's report yet — refresh it for a figure."
- : undefined
- }
- >
- {c.mediaBytes === null ? "—" : formatBytes(c.mediaBytes)}
- </Td>
- {columns.map((col, i) => (
- <PipelineCell
- key={col.id}
- slug={c.slug}
- column={col}
- band={bandOf(c, col.id)}
- className={
- bridge +
- (i === 0 ? " border-l border-border" : "") +
- (i === columns.length - 1 ? " border-r border-border" : "") +
- dim
- }
- count={
- col.id === "download"
- ? c.downloadCount
- : col.id === "transcription"
- ? c.transcriptCount
- : null
- }
- />
- ))}
- <Td className={`whitespace-nowrap${dim}`}>
- <div className="flex items-center gap-2">
- <ChannelSyncButton slug={c.slug} disabled={!c.config.url} />
- <ChannelAvailabilityButton slug={c.slug} disabled={!c.config.url} />
- <InlineActionButton
- variant={{ kind: "refreshReport", slug: c.slug }}
- />
- </div>
- </Td>
+ {cell.content}
+ </Td>,
+ );
+ }
+ return (
+ <tr
+ aria-label={rowLabelPrefix ? `${rowLabelPrefix} row ${c.slug}` : undefined}
+ className={"border-t border-border " + (selected ? "bg-accent " : "")}
+ >
+ {cells}
</tr>
);
}
-// A 40px row has one line for a date, so the date has to fit one line. The
-// locale default spells the year and the seconds out ("11/09/2026, 10:24:31");
-// medium/short is the same instant in the same locale, two thirds the width.
-function formatStamp(iso: string | null | undefined): string {
- if (!iso) return "";
- return new Date(iso).toLocaleString(undefined, {
- dateStyle: "medium",
- timeStyle: "short",
- });
-}
-
function SortableTh({
label,
sortKey,
@@ -891,9 +622,9 @@ function SortableTh({
eyebrow,
}: {
label: string;
- sortKey: SortKey;
- sort: SortState;
- onClick: (key: SortKey) => void;
+ sortKey: ChannelSortKey;
+ sort: ChannelSortState;
+ onClick: (key: ChannelSortKey) => void;
align?: "left" | "right";
className?: string;
title?: string;
@@ -948,17 +679,24 @@ function Td({
className = "",
ariaLabel,
title,
+ pad,
+ suppressHydrationWarning,
}: {
- children: React.ReactNode;
+ children: ReactNode;
className?: string;
ariaLabel?: string;
title?: string;
+ pad: string;
+ // Locale-formatted dates: the server's locale/timezone need not be the
+ // browser's (a Docker runtime is UTC), as JobRow's date cells already allow.
+ suppressHydrationWarning?: boolean;
}) {
return (
<td
- className={`px-2 py-1.5 align-middle ${className}`}
+ className={`${pad} align-middle ${className}`}
aria-label={ariaLabel}
title={title}
+ suppressHydrationWarning={suppressHydrationWarning}
>
{children}
</td>
diff --git a/editor/app/channels/components/channelColumnPresets.ts b/editor/app/channels/components/channelColumnPresets.ts
@@ -0,0 +1,96 @@
+// THE CHANNEL COLUMN IDS AND PRESETS — plain data, deliberately NOT in the
+// "use client" registry (./channelColumns.tsx). A server component that imports
+// a value from a client module gets a client REFERENCE, not the value: the work
+// tables' server shell calling `WORK_COLUMNS.filter(...)` threw "filter is not
+// a function". So the ids, the sort keys, the pipeline-column shape and the
+// presets live here, importable from either side; the cells stay client-only.
+
+export type ChannelColumnId =
+ | "select"
+ | "slug"
+ | "name"
+ | "handling"
+ | "build"
+ | "tier"
+ | "playlist"
+ | "videos"
+ | "digestReachable"
+ | "count"
+ | "extra"
+ | "lastSync"
+ | "lastSyncRelative"
+ | "report"
+ | "location"
+ | "size"
+ | "pipelines"
+ | "actions";
+
+// Sorting a pipeline column sorts by `reachable` — "which channel has the most
+// diarizable audio left right now".
+export type ChannelSortKey =
+ | "slug"
+ | "name"
+ | "handling"
+ | "build"
+ | "tier"
+ | "playlist"
+ | "lastSync"
+ | "report"
+ | "location"
+ | "size"
+ | `op:${string}`;
+
+// A column heading for one pipeline. Comes off the operation registry on the
+// server (shortLabel, costBasis) rather than being abbreviated here — see
+// operations.ts.
+export type PipelineColumn = {
+ id: string;
+ shortLabel: string;
+ label: string;
+ costBasis: string;
+ // The two externally-dispatched pipelines keep their existing count column
+ // semantics: the cell's text node is the bare number the suite asserts on,
+ // under this accessible name. The slug is appended by the cell, so this is
+ // the prefix — `"downloads count"` becomes `"downloads count for foo"`, which
+ // is the name channels-counts.spec.ts has always addressed these cells by.
+ // Null for the derived operations, which have never had a count column and
+ // get the band's sentence as their text.
+ countLabel: string | null;
+};
+
+// THE PRESETS. The rack draws everything; the dashboard and a work section
+// each name the handful they have always drawn.
+export const RACK_COLUMNS: readonly ChannelColumnId[] = [
+ "select",
+ "slug",
+ "name",
+ "handling",
+ "build",
+ "tier",
+ "playlist",
+ "lastSync",
+ "report",
+ "location",
+ "size",
+ "pipelines",
+ "actions",
+];
+
+export const DASHBOARD_COLUMNS: readonly ChannelColumnId[] = [
+ "slug",
+ "handling",
+ "videos",
+ "digestReachable",
+ "lastSyncRelative",
+ "actions",
+];
+
+// `extra` is dropped by the caller when the section has no extra column.
+export const WORK_COLUMNS: readonly ChannelColumnId[] = [
+ "slug",
+ "count",
+ "extra",
+ "report",
+ "lastSync",
+ "actions",
+];
diff --git a/editor/app/channels/components/channelColumns.tsx b/editor/app/channels/components/channelColumns.tsx
@@ -0,0 +1,350 @@
+"use client";
+
+import Link from "next/link";
+import type { ReactNode } from "react";
+import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { MediaLocationBadge } from "../../components/MediaLocationBadge";
+import { InlineActionButton } from "../../components/actions/InlineActionButton";
+import { fmtTime } from "../../widget/lib/relativeTime";
+import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton";
+import { ChannelBuildToggle } from "./ChannelBuildToggle";
+import { ChannelSyncButton } from "./ChannelSyncButton";
+import ChannelTierSelect from "./ChannelTierSelect";
+import type {
+ ChannelColumnId,
+ ChannelSortKey,
+} from "./channelColumnPresets";
+
+// Re-exported for the client importers; a SERVER importer must take these from
+// ./channelColumnPresets.ts directly (see there).
+export {
+ DASHBOARD_COLUMNS,
+ RACK_COLUMNS,
+ WORK_COLUMNS,
+ type ChannelColumnId,
+ type ChannelSortKey,
+ type PipelineColumn,
+} from "./channelColumnPresets";
+
+// THE CHANNEL COLUMN REGISTRY.
+//
+// A REGISTRY BY ID, NOT COLUMN OBJECTS PASSED AS PROPS. The work tables are
+// drawn by a SERVER shell (ChannelWorkTable — its section configs hold
+// functions that return elements), and a function cannot cross the
+// server/client boundary as a prop. So a caller names columns by id — a preset
+// below, or its own list — and the cells live here, on the client, where the
+// shared table (./ChannelsTable.tsx) looks them up. What a server caller needs
+// to put INTO a cell (a count, an extra figure, an actions cluster) travels as
+// data or as an already-rendered element in `rowExtras`, never as a function.
+//
+// Every accessible name a spec addresses a cell by is spelled here, once:
+// `playlist count for`, `report age for`, `media location for`, `media size
+// for`, and — in the table's pipeline cell — `${countLabel} for`.
+
+// What a server caller adds to one row: the section's count and extra figure,
+// and its actions cluster as an ELEMENT (elements cross the boundary; the
+// functions that built them stay on the server).
+export type ChannelRowExtras = {
+ count?: number;
+ extra?: string;
+ actions?: ReactNode;
+};
+
+export type ChannelColumnLabels = Partial<Record<ChannelColumnId, string>>;
+
+export type ChannelCellCtx = {
+ // " opacity-60" when the row is out of a pipeline (build-excluded or
+ // paused), else "". Applied by the TABLE, per cell, to every column whose
+ // `dim` is not false — never to the <tr>; see ChannelsTable.
+ dim: string;
+ // The rack's pinned-column background (a sticky cell paints over the row
+ // behind it, so it must carry the row's background explicitly).
+ stickyBg: string;
+ sticky: boolean;
+ selected: boolean;
+ onToggle: (slug: string) => void;
+ extras: ChannelRowExtras | undefined;
+ // The relative-time clock; null until mounted.
+ now: number | null;
+};
+
+export type ChannelCell = {
+ content: ReactNode;
+ className?: string;
+ ariaLabel?: string;
+ title?: string;
+ // Set on locale-formatted date cells; see Td in ./ChannelsTable.tsx.
+ suppressHydrationWarning?: boolean;
+};
+
+
+export type ChannelColumn = {
+ // A caller's `labels[id]` wins over this — a work section names its count
+ // column ("undownloaded") and calls the report column "Last report".
+ label: string;
+ align?: "left" | "right";
+ sortKey?: ChannelSortKey;
+ // `stickyClassName` applies only in the rack (`sticky`), where the Slug
+ // column pins left under the pinned header.
+ th?: { className?: string; stickyClassName?: string; title?: string };
+ cell: (row: ChannelRowView, ctx: ChannelCellCtx) => ChannelCell;
+ // False for the one cell that must not dim: Tier hosts the Advanced popover,
+ // and a dimmed cell is a stacking context that would trap it.
+ dim?: boolean;
+};
+
+// A 40px row has one line for a date, so the date has to fit one line. The
+// locale default spells the year and the seconds out ("11/09/2026, 10:24:31");
+// medium/short is the same instant in the same locale, two thirds the width.
+export function formatStamp(iso: string | null | undefined): string {
+ if (!iso) return "";
+ return new Date(iso).toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+}
+
+function toMs(iso: string | null): number | null {
+ if (!iso) return null;
+ const n = Date.parse(iso);
+ return Number.isNaN(n) ? null : n;
+}
+
+export const CHANNEL_COLUMNS: Record<
+ Exclude<ChannelColumnId, "select" | "pipelines">,
+ ChannelColumn
+> = {
+ slug: {
+ label: "Slug",
+ sortKey: "slug",
+ th: {
+ stickyClassName:
+ "sticky left-8 z-20 bg-muted shadow-[1px_0_0_var(--color-border)]",
+ },
+ cell: (c, ctx) => ({
+ className: ctx.sticky
+ ? `sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${ctx.stickyBg}`
+ : "whitespace-nowrap font-mono",
+ content: (
+ <span className="inline-flex items-center gap-1.5">
+ <Link
+ href={`/channels/${c.slug}`}
+ className="underline hover:text-foreground"
+ >
+ {c.slug}
+ </Link>
+ <MediaLocationBadge media={c.media} compact />
+ </span>
+ ),
+ }),
+ },
+ name: {
+ label: "Name",
+ sortKey: "name",
+ cell: (c) => ({ className: "max-w-48 truncate", content: c.name }),
+ },
+ handling: {
+ label: "Handling",
+ sortKey: "handling",
+ cell: (c) => ({
+ className: "font-mono text-[11px] text-muted-foreground",
+ content: c.handling,
+ }),
+ },
+ build: {
+ label: "Build",
+ sortKey: "build",
+ cell: (c) => ({
+ content: (
+ <ChannelBuildToggle slug={c.slug} excluded={c.excludeFromBuild} />
+ ),
+ }),
+ },
+ tier: {
+ label: "Tier",
+ sortKey: "tier",
+ th: {
+ title:
+ "Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column.",
+ },
+ dim: false,
+ cell: (c) => ({
+ content: (
+ <ChannelTierSelect
+ slug={c.slug}
+ tier={c.priority.tier}
+ overrides={c.priority.overrides}
+ focused={c.priority.focused}
+ heldReason={c.priority.heldReason}
+ autoPausedReason={c.priority.autoPausedReason}
+ />
+ ),
+ }),
+ },
+ playlist: {
+ label: "Playlist",
+ sortKey: "playlist",
+ align: "right",
+ cell: (c) => ({
+ className: "text-right tabular-nums",
+ ariaLabel: `playlist count for ${c.slug}`,
+ content: c.playlistCount ?? "—",
+ }),
+ },
+ // From the snapshot rather than a corpus walk, so it lags a sync by the
+ // snapshot scheduler's debounce.
+ videos: {
+ label: "Videos",
+ align: "right",
+ cell: (c) => ({
+ className: "text-right tabular-nums",
+ content: c.work.videos,
+ }),
+ },
+ // Muted: during the backfill this is nearly every video, so it is a
+ // coverage readout rather than a call to action.
+ digestReachable: {
+ label: "Digest to do",
+ align: "right",
+ th: {
+ className: "whitespace-nowrap",
+ title:
+ "Videos the digest lane can work on now — the same reachable figure the /operations rail draws; blocked and deferred are not in it",
+ },
+ cell: (c) => ({
+ className: "text-right tabular-nums text-xs text-muted-foreground",
+ content: c.work.digestReachable,
+ }),
+ },
+ // A work section's own figure — undownloaded, untranscribed, reclaimable —
+ // computed by the section's `getCount` on the server and carried here as data.
+ count: {
+ label: "Count",
+ align: "right",
+ cell: (_c, ctx) => ({
+ className: "text-right tabular-nums",
+ content: ctx.extras?.count ?? "",
+ }),
+ },
+ extra: {
+ label: "Extra",
+ align: "right",
+ th: { className: "whitespace-nowrap" },
+ cell: (_c, ctx) => ({
+ className: "text-right tabular-nums whitespace-nowrap text-muted-foreground",
+ content: ctx.extras?.extra ?? "",
+ }),
+ },
+ lastSync: {
+ label: "Last sync",
+ sortKey: "lastSync",
+ th: { className: "whitespace-nowrap" },
+ cell: (c, ctx) => ({
+ className: "whitespace-nowrap text-xs tabular-nums text-muted-foreground",
+ suppressHydrationWarning: true,
+ // The rack leaves a never-synced cell empty; a work table says so.
+ content: formatStamp(c.lastSyncedAt) || (ctx.sticky ? "" : "never"),
+ }),
+ },
+ // The dashboard's live "5m ago", ticking off the cockpit's clock.
+ lastSyncRelative: {
+ label: "Last sync",
+ th: { className: "whitespace-nowrap" },
+ cell: (c, ctx) => {
+ const ms = toMs(c.lastSyncedAt);
+ return {
+ className: "whitespace-nowrap text-xs tabular-nums text-muted-foreground",
+ content: ms === null ? "never" : (fmtTime(ms, ctx.now, false) ?? "…"),
+ };
+ },
+ },
+ report: {
+ label: "Report",
+ sortKey: "report",
+ th: {
+ className: "whitespace-nowrap",
+ title:
+ "When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong.",
+ },
+ // The rack says only the state word when the report cannot be trusted; a
+ // work table ("Last report") keeps the date beside it — how stale is what
+ // decides whether that row's count can be believed.
+ cell: (c, ctx) => {
+ const stamp = formatStamp(c.report.generatedAt);
+ return {
+ ariaLabel: `report age for ${c.slug}`,
+ className: `whitespace-nowrap text-xs tabular-nums ${
+ c.report.state === "current" ? "text-muted-foreground" : "text-warning"
+ }`,
+ title: stamp || undefined,
+ suppressHydrationWarning: true,
+ content:
+ c.report.state === "current"
+ ? stamp
+ : !ctx.sticky && c.report.state === "stale" && stamp
+ ? `stale · ${stamp}`
+ : c.report.state,
+ };
+ },
+ },
+ // THE TWO STORAGE COLUMNS. Which disk the media is on, and how much of it
+ // there is — the pair that makes "what should I move" answerable without
+ // opening 71 channel pages. Free space is deliberately NOT here: it is a fact
+ // about a volume, not about a row, and it is stated once in the volume bar.
+ location: {
+ label: "Location",
+ sortKey: "location",
+ th: {
+ className: "whitespace-nowrap",
+ title:
+ "Which storage volume this channel's media is on. Internal is the corpus disk; anything else is a location configured on /storage.",
+ },
+ // THE LABEL ONLY — NO SECOND BADGE. The unreachable / in-transition marking
+ // is the badge's, and the badge is already on this row, in the Slug cell;
+ // drawing another here put two elements with the same accessible name on
+ // every relocated row (a strict-mode violation for the suite, and a screen
+ // reader reading the same sentence twice). The COLUMN answers "which disk";
+ // the badge answers "can it be reached".
+ cell: (c) => ({
+ ariaLabel: `media location for ${c.slug}`,
+ className: "whitespace-nowrap text-xs",
+ title: c.media?.target ?? undefined,
+ content: c.volumeLabel,
+ }),
+ },
+ size: {
+ label: "Size",
+ sortKey: "size",
+ align: "right",
+ th: {
+ className: "whitespace-nowrap",
+ title:
+ "Every byte under this channel's data/ — audio, transcripts, cues, metadata — from its last report. Sorts biggest first.",
+ },
+ cell: (c) => ({
+ ariaLabel: `media size for ${c.slug}`,
+ className: "whitespace-nowrap text-right tabular-nums",
+ title:
+ c.mediaBytes === null
+ ? "No size in this channel's report yet — refresh it for a figure."
+ : undefined,
+ content: c.mediaBytes === null ? "—" : formatBytes(c.mediaBytes),
+ }),
+ },
+ // The rack's own cluster unless the caller rendered one for this row.
+ actions: {
+ label: "Actions",
+ th: { className: "whitespace-nowrap" },
+ cell: (c, ctx) => ({
+ className: "whitespace-nowrap",
+ content: ctx.extras?.actions ?? (
+ <div className="flex items-center gap-2">
+ <ChannelSyncButton slug={c.slug} disabled={!c.hasUrl} />
+ <ChannelAvailabilityButton slug={c.slug} disabled={!c.hasUrl} />
+ <InlineActionButton variant={{ kind: "refreshReport", slug: c.slug }} />
+ </div>
+ ),
+ }),
+ },
+};
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -32,26 +32,26 @@ import {
type SiteSettings,
} from "yt-dlp-transcript-common/lib/settings";
import {
- locationOfDataDir,
- type StorageLocation,
-} from "yt-dlp-transcript-common/lib/storageLocations";
-import {
INTERNAL_LOCATION_ID,
INTERNAL_LOCATION_LABEL,
volumeFreeBytes,
} from "yt-dlp-transcript-common/controller/storageLocations";
import { buildChannelBands } from "yt-dlp-transcript-common/views/pipeline/buildBands";
import { EXTERNAL_BAND_IDS } from "yt-dlp-transcript-common/views/pipeline/buildBands";
+import { ChannelsRack } from "./components/ChannelsRack";
+import type { PipelineColumn } from "./components/channelColumnPresets";
import {
- ChannelsTable,
- type ChannelRow,
- type PipelineColumn,
-} from "./components/ChannelsTable";
+ buildChannelRowView,
+ channelVolumeOf,
+ type ChannelRowView,
+} from "yt-dlp-transcript-common/views/channelRow";
import type { ChannelVolume } from "./components/ChannelVolumeBar";
-import { buildChannelGroupSections } from "yt-dlp-transcript-common/views/channelGroupSections";
+import {
+ buildChannelGroupSections,
+ type ChannelGroupSectionView,
+} from "yt-dlp-transcript-common/views/channelGroupSections";
import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton";
import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton";
-import { reportStateOf } from "../lib/actionable/loadActionable";
import { resolveActiveSite } from "../lib/activeSite";
export const dynamic = "force-dynamic";
@@ -155,28 +155,6 @@ function focusLabelOf(
: `${focusSlugs.length} channels`;
}
-// WHICH VOLUME A CHANNEL'S MEDIA IS ON, as a filterable id.
-//
-// Same derivation the badge uses (`config.dataDir` under a location's root,
-// longest match wins) with one addition: no `dataDir` at all means the corpus
-// volume, which is the row the operator is trying to empty and therefore the
-// one they most need to filter to. A `dataDir` under a root NOBODY named is
-// neither — it gets "" and falls out of every volume filter, which is the
-// honest answer and the nudge to name that root on /storage.
-function volumeOf(
- dataDir: string | undefined,
- locations: StorageLocation[],
-): { id: string; label: string } {
- const trimmed = dataDir?.trim();
- if (!trimmed) {
- return { id: INTERNAL_LOCATION_ID, label: "Internal" };
- }
- const found = locationOfDataDir(trimmed, locations);
- return found
- ? { id: found.id, label: found.label || found.id }
- : { id: "", label: "Elsewhere" };
-}
-
export default async function ChannelsPage({
searchParams,
}: {
@@ -242,44 +220,23 @@ export default async function ChannelsPage({
// TWO SYSCALLS PER VOLUME, NOT A PROBE. See volumeFreeBytes: this table draws
// 71 rows on every auto-refresh and the rule is that tables never shell out.
const freeByVolume = await volumeFreeBytes({ paths, locations });
- const all: ChannelRow[] = stats.map((stat) => {
+ // ONE ROW PER CHANNEL, off the shared builder (common/views/channelRow.ts):
+ // the dashboard and the operation pages build theirs the same way. The view
+ // carries no `config` — this table is a client component, and a channel
+ // config holds cookie paths and yt-dlp args no cell draws.
+ const all: ChannelRowView[] = stats.map((stat) => {
const brief = briefBySlug.get(stat.slug);
- const media = mediaBySlug.get(stat.slug);
- const volume = volumeOf(brief?.config.dataDir, locations);
- return {
- ...stat,
- // WHERE THE BYTES ARE, AND HOW MANY. Both off the snapshot already in
- // hand — no walk, no probe. `mediaBytes` is null for a report written
- // before the field existed, and the cell renders that as "—" rather than
- // "0 B": a zero would sort a 400 GB channel to the bottom of the very
- // list the operator opened to find it.
- volumeId: volume.id,
- volumeLabel: volume.label,
- mediaBytes: brief?.snapshot?.totalMediaBytes ?? null,
- pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids),
- report: {
- generatedAt: brief?.snapshot?.generatedAt ?? null,
- state: brief ? reportStateOf(brief) : ("missing" as const),
- },
- media:
- media && media.status !== "in-place"
- ? {
- status: media.status,
- target: media.target,
- detail: media.detail,
- // WHICH NAMED LOCATION — a pure prefix match of the recorded
- // target against the configured roots, done here because the
- // settings are here and the table is a client component. NEVER a
- // probe: this table draws one badge per row. Undefined for a root
- // nobody named, which renders exactly what it rendered before.
- // The row already derived it once for the Location column;
- // re-deriving it here would be a second answer to the same
- // question. "" is the root nobody named — the badge takes
- // undefined for that, which renders exactly what it rendered
- // before locations existed.
- locationLabel: volume.id === "" ? undefined : volume.label,
- }
- : null,
+ return buildChannelRowView({
+ slug: stat.slug,
+ config: stat.config,
+ snapshot: snapshots.get(stat.slug) ?? null,
+ playlistCount: stat.playlistCount,
+ bands: buildChannelBands(snapshots.get(stat.slug) ?? null, ids),
+ // WHERE THE MEDIA IS: inspectChannelMedia's result; the builder drops an
+ // in-place one and names the location from the volume, which is the
+ // same prefix match the Location column shows — one answer, not two.
+ media: mediaBySlug.get(stat.slug) ?? null,
+ volume: channelVolumeOf(brief?.config.dataDir, locations),
priority: {
tier: tierOf(priority, stat.slug),
rank: rankOf(priority, stat.slug),
@@ -303,7 +260,7 @@ export default async function ChannelsPage({
? `Held — focus: ${focusLabel}`
: null,
},
- };
+ });
});
// Scope to the active site's membership; "all sites" shows the full pool.
//
@@ -351,14 +308,26 @@ export default async function ChannelsPage({
locationFilter === null
? inSite
: inSite.filter((c) => c.volumeId === locationFilter);
+ const shownSlugs = new Set(channels.map((c) => c.slug));
// GROUPS ARE A PARTITION OF A SITE, so a volume filter and the grouped render
// cannot both be true: half a group is not a group. Filtering flattens.
- const sections =
+ // PROJECTED to slugs before they cross to the client: a section's stats
+ // carry each channel's full config, and the rack reads only slug and count.
+ const sections: ChannelGroupSectionView[] | null =
activeSite && locationFilter === null
- ? buildChannelGroupSections(activeSite, channels, briefs, settings)
+ ? buildChannelGroupSections(
+ activeSite,
+ stats.filter((st) => shownSlugs.has(st.slug)),
+ briefs,
+ settings,
+ ).map((s) => ({
+ ...s,
+ channels: s.channels.map((c) => ({ slug: c.slug })),
+ }))
: null;
- const shown = new Set(channels.map((c) => c.slug));
- const freshness = summariseFreshness(briefs.filter((b) => shown.has(b.slug)));
+ const freshness = summariseFreshness(
+ briefs.filter((b) => shownSlugs.has(b.slug)),
+ );
return (
// ON md+ THE DOCUMENT STOPS SCROLLING. The page is a flex column exactly
// the height of the viewport (main carries py-6, hence -3rem) so that the
@@ -448,7 +417,7 @@ export default async function ChannelsPage({
: "No channels yet."}
</p>
) : (
- <ChannelsTable
+ <ChannelsRack
channels={channels}
columns={columns}
sections={sections}
diff --git a/editor/app/components/MediaLocationBadge.tsx b/editor/app/components/MediaLocationBadge.tsx
@@ -1,7 +1,5 @@
-import type {
- ChannelMediaLocation,
- ChannelMediaStatus,
-} from "yt-dlp-transcript-common/lib/channelMedia";
+import type { ChannelMediaStatus } from "yt-dlp-transcript-common/lib/channelMedia";
+import type { ChannelRowMedia } from "yt-dlp-transcript-common/views/channelRow";
// THE ONE RENDERING OF "where is this channel's media, and can we reach it".
//
@@ -31,16 +29,10 @@ import type {
// The prop shape, deliberately narrower than ChannelMediaLocation: a row only
// needs what it draws, so a server page can project three fields onto a client
-// component instead of serializing a whole location per channel.
-export type MediaBadgeInput = Pick<
- ChannelMediaLocation,
- "status" | "target" | "detail"
-> & {
- // The name of the storage location this channel's media is on, projected by
- // the server that built the row (see `mediaBadgeOf`). A fourth string is
- // still cheaper than shipping the location list to every table.
- locationLabel?: string;
-};
+// component instead of serializing a whole location per channel. It is the
+// channel row's own media field (common/views/channelRow.ts), which is where
+// the in-place filter and the location label are decided, once.
+export type MediaBadgeInput = ChannelRowMedia;
export type MediaBadgeTone = "neutral" | "danger";
diff --git a/editor/app/components/actions/PrioritizeButton.tsx b/editor/app/components/actions/PrioritizeButton.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { prioritizeChannelDownloadAction } from "../../operations/actions";
+
+// One-click "Top of queue": prepend this channel to the auto-download policy and
+// start the runner (prioritizeChannelDownloadAction).
+export function PrioritizeButton({ slug }: { slug: string }) {
+ const [pending, startTransition] = useTransition();
+ const [done, setDone] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ function handleClick() {
+ setError(null);
+ startTransition(async () => {
+ const res = await prioritizeChannelDownloadAction(slug);
+ if (res.ok) setDone(true);
+ else setError(res.error);
+ });
+ }
+
+ return (
+ <span className="inline-flex items-center gap-1.5">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={pending || done}
+ aria-label={`prioritize ${slug} for download`}
+ title="Prepend to the auto-download queue and start the runner"
+ className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
+ >
+ {done ? "Queued ↑" : pending ? "…" : "Top of queue"}
+ </button>
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}
+ </span>
+ );
+}
diff --git a/editor/app/components/channelWork/ChannelWorkTable.tsx b/editor/app/components/channelWork/ChannelWorkTable.tsx
@@ -1,22 +1,35 @@
-import Link from "next/link";
-import {
- isStaleOrMissing,
- type ActionableRow,
- type ActionableSummary,
+import type {
+ ActionableRow,
+ ActionableSummary,
} from "../../lib/actionable/loadActionable";
+import {
+ buildChannelRowView,
+ neutralChannelPriority,
+ type ChannelRowView,
+} from "yt-dlp-transcript-common/views/channelRow";
import { InlineActionButton } from "../actions/InlineActionButton";
+import { ChannelsTable } from "../../channels/components/ChannelsTable";
+import type { ChannelRowExtras } from "../../channels/components/channelColumns";
+// From the plain-data module, never the "use client" registry: a server
+// component importing a client module's value gets a reference, not the array.
+import { WORK_COLUMNS } from "../../channels/components/channelColumnPresets";
import type { SectionConfig } from "./sections";
// A server component on purpose: `SectionConfig.primaryAction` is a function
// that returns an element, and a function cannot cross the server/client
-// boundary as a prop.
+// boundary as a prop. So this is the SHELL — section, heading, description,
+// empty paragraph — and the rows are the shared channel table
+// (channels/components/ChannelsTable.tsx) with the WORK_COLUMNS preset, fed
+// each row's count, extra figure and actions as data and elements.
//
// THE ARIA CONTRACT BELOW IS LOAD-BEARING. `channel-work.spec`,
// `cleanup-actionable.spec`, `incomplete-transcript.spec` and
// `download-format-guard.spec` all locate by these exact strings:
// <section aria-label="<id>">, <h2>{title}</h2>,
// <p aria-label="<id> empty">, <tr aria-label="<id> row <slug>">,
-// columns Slug / countLabel / [extra] / Last report / Last sync / Actions,
+// columns Slug / countLabel / [extra] / Last report / Last sync / Actions
+// (the report cell is the rack's: `report age for <slug>`, "stale" /
+// "missing" in the warning colour),
// and a `refresh report <slug>` InlineActionButton on every row.
//
// NEVER add `role="status"` in here. This renders on the RUNNER operation
@@ -51,6 +64,27 @@ function Section({
config: SectionConfig;
rows: ActionableRow[];
}) {
+ // The functions stay HERE, on the server: each row's count, extra figure and
+ // actions are computed now and cross to the shared (client) table as a
+ // number, a string and an element.
+ const rowExtras: Record<string, ChannelRowExtras> = {};
+ for (const row of rows) {
+ rowExtras[row.channel.slug] = {
+ count: config.getCount(row),
+ extra: config.extraColumn?.getValue(row),
+ actions: (
+ <span className="inline-flex items-center gap-3 flex-wrap">
+ {config.primaryAction(row)}
+ <InlineActionButton
+ variant={{ kind: "refreshReport", slug: row.channel.slug }}
+ />
+ </span>
+ ),
+ };
+ }
+ const columns = config.extraColumn
+ ? WORK_COLUMNS
+ : WORK_COLUMNS.filter((id) => id !== "extra");
return (
<section
aria-label={config.id}
@@ -69,110 +103,37 @@ function Section({
{config.emptyLabel}
</p>
) : (
- <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
- <table className="text-sm border-y md:border md:border-border border-border md:rounded-md md:overflow-hidden w-full">
- <thead className="bg-muted">
- <tr>
- <th className="text-left font-medium px-3 py-2">Slug</th>
- <th className="text-right font-medium px-3 py-2">
- {config.countLabel}
- </th>
- {config.extraColumn && (
- <th className="text-right font-medium px-3 py-2 whitespace-nowrap">
- {config.extraColumn.label}
- </th>
- )}
- <th className="text-left font-medium px-3 py-2 whitespace-nowrap">
- Last report
- </th>
- <th className="text-left font-medium px-3 py-2 whitespace-nowrap">
- Last sync
- </th>
- <th className="text-right font-medium px-3 py-2">Actions</th>
- </tr>
- </thead>
- <tbody>
- {rows.map((row) => (
- <Row
- key={row.channel.slug}
- row={row}
- count={config.getCount(row)}
- extraValue={config.extraColumn?.getValue(row) ?? null}
- primaryAction={config.primaryAction(row)}
- sectionId={config.id}
- />
- ))}
- </tbody>
- </table>
- </div>
+ <ChannelsTable
+ rows={rows.map(workRowOf)}
+ columns={columns}
+ rowLabelPrefix={config.id}
+ rowExtras={rowExtras}
+ labels={{
+ count: config.countLabel,
+ extra: config.extraColumn?.label,
+ report: "Last report",
+ }}
+ sort={false}
+ selection={false}
+ />
)}
</section>
);
}
-function Row({
- row,
- count,
- extraValue,
- primaryAction,
- sectionId,
-}: {
- row: ActionableRow;
- count: number;
- extraValue: string | null;
- primaryAction: React.ReactNode;
- sectionId: string;
-}) {
- const { channel, snapshot } = row;
- const lastSync = channel.config.lastSyncedAt;
- const lastReport = snapshot?.generatedAt ?? null;
- // The loader's own definition, which this used to carry a copy of.
- const isStale = isStaleOrMissing(row);
- return (
- <tr
- aria-label={`${sectionId} row ${channel.slug}`}
- className="border-t border-border"
- >
- <td className="px-3 py-2 font-mono">
- <Link
- href={`/channels/${channel.slug}`}
- className="underline hover:text-foreground"
- >
- {channel.slug}
- </Link>
- </td>
- <td className="px-3 py-2 text-right tabular-nums">{count}</td>
- {extraValue !== null && (
- <td className="px-3 py-2 text-right tabular-nums whitespace-nowrap text-muted-foreground">
- {extraValue}
- </td>
- )}
- <td
- className={`px-3 py-2 text-xs whitespace-nowrap ${
- isStale
- ? "text-warning"
- : "text-muted-foreground"
- }`}
- >
- {lastReport ? (
- <time dateTime={lastReport}>
- {new Date(lastReport).toLocaleString()}
- </time>
- ) : (
- "never"
- )}
- </td>
- <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">
- {lastSync ? new Date(lastSync).toLocaleString() : "never"}
- </td>
- <td className="px-3 py-2 text-right">
- <span className="inline-flex items-center justify-end gap-3 flex-wrap">
- {primaryAction}
- <InlineActionButton
- variant={{ kind: "refreshReport", slug: channel.slug }}
- />
- </span>
- </td>
- </tr>
- );
+// The shared row, for a table that draws no bands, no tier, no media badge and
+// no Location column: those inputs are neutral rather than read. The report
+// state is the builder's `reportStateOf` — the loader's own definition, which
+// this table used to carry a copy of.
+function workRowOf(row: ActionableRow): ChannelRowView {
+ return buildChannelRowView({
+ slug: row.channel.slug,
+ config: row.channel.config,
+ snapshot: row.snapshot,
+ playlistCount: null,
+ bands: [],
+ priority: neutralChannelPriority(),
+ media: null,
+ volume: { id: "", label: "" },
+ });
}
diff --git a/editor/app/components/dashboard/ChannelsTable.tsx b/editor/app/components/dashboard/ChannelsTable.tsx
@@ -1,192 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useState, useTransition } from "react";
-import { syncAction } from "../../channels/[slug]/pipelineActions";
-import { prioritizeChannelDownloadAction } from "../../operations/actions";
-import { InlineActionButton } from "../actions/InlineActionButton";
-import { fmtTime } from "../../widget/lib/relativeTime";
-import type { DashboardChannel } from "./types";
-import { MediaLocationBadge } from "../MediaLocationBadge";
-
-// The enriched channels table: the plain slug/handling/videos list plus a
-// relative "last sync" that ticks live, and per-row inline actions (Sync,
-// Download missing, and a one-click "Top of queue" that prioritizes the channel
-// for auto-download).
-export function ChannelsTable({
- channels,
- now,
-}: {
- channels: DashboardChannel[];
- now: number | null;
-}) {
- if (channels.length === 0) return null;
- return (
- <section className="flex flex-col gap-3">
- <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- Channels
- </h2>
- <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
- <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full">
- <thead className="bg-muted">
- <tr>
- <th className="text-left font-medium px-3 py-2">Slug</th>
- <th className="text-left font-medium px-3 py-2">Handling</th>
- <th className="text-right font-medium px-3 py-2">Videos</th>
- <th
- className="text-right font-medium px-3 py-2 whitespace-nowrap"
- title="Videos the digest lane can work on now — the same reachable figure the /operations rail draws; blocked and deferred are not in it"
- >
- Digest to do
- </th>
- <th className="text-left font-medium px-3 py-2 whitespace-nowrap">
- Last sync
- </th>
- <th className="text-left font-medium px-3 py-2">Actions</th>
- </tr>
- </thead>
- <tbody>
- {channels.map((c) => (
- <tr
- key={c.slug}
- aria-label={`channel row ${c.slug}`}
- className="border-t border-border align-top"
- >
- <td className="px-3 py-2 font-mono">
- <span className="inline-flex items-center gap-1.5">
- <Link
- href={`/channels/${c.slug}`}
- className="underline underline-offset-2 hover:text-brand transition-colors"
- >
- {c.slug}
- </Link>
- <MediaLocationBadge media={c.media} compact />
- </span>
- </td>
- <td className="px-3 py-2">{c.handling}</td>
- <td className="px-3 py-2 text-right tabular-nums">
- {c.videoCount}
- </td>
- {/* Muted: during the backfill this is nearly every video, so
- it is a coverage readout rather than a call to action. */}
- <td className="px-3 py-2 text-right tabular-nums text-xs text-muted-foreground">
- {c.digestReachable}
- </td>
- <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap tabular-nums">
- {c.lastSyncedAt == null
- ? "never"
- : (fmtTime(c.lastSyncedAt, now, false) ?? "…")}
- </td>
- <td className="px-3 py-2">
- <div className="flex flex-wrap items-center gap-1.5">
- {c.hasUrl ? (
- <>
- <InlineSyncButton slug={c.slug} />
- {c.undownloaded > 0 && (
- <InlineActionButton
- variant={{ kind: "downloadMissing", slug: c.slug }}
- />
- )}
- <PrioritizeButton slug={c.slug} />
- </>
- ) : (
- <span className="text-xs text-muted-foreground">
- no URL
- </span>
- )}
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </section>
- );
-}
-
-// Single-channel Sync: kicks the streaming sync and drains the stream so the job
-// keeps running server-side while this row returns to idle.
-function InlineSyncButton({ slug }: { slug: string }) {
- const [status, setStatus] = useState<"idle" | "running" | "done" | "error">(
- "idle",
- );
- const [message, setMessage] = useState<string | null>(null);
-
- async function handleClick() {
- setStatus("running");
- setMessage(null);
- try {
- const res = await syncAction(slug);
- if (!res.ok) {
- setStatus("error");
- setMessage(res.error);
- return;
- }
- void res.stream.cancel();
- setStatus("done");
- } catch (e) {
- setStatus("error");
- setMessage((e as Error).message);
- }
- }
-
- return (
- <span className="inline-flex items-center gap-1.5">
- <button
- type="button"
- onClick={handleClick}
- disabled={status === "running"}
- aria-label={`sync ${slug}`}
- className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
- >
- {status === "running" ? "Syncing…" : "Sync"}
- </button>
- {status === "done" && (
- <span className="text-xs text-muted-foreground">queued</span>
- )}
- {status === "error" && message && (
- <span role="alert" className="text-xs text-destructive">
- {message}
- </span>
- )}
- </span>
- );
-}
-
-// One-click "Top of queue": prepend this channel to the auto-download policy and
-// start the runner (prioritizeChannelDownloadAction).
-function PrioritizeButton({ slug }: { slug: string }) {
- const [pending, startTransition] = useTransition();
- const [done, setDone] = useState(false);
- const [error, setError] = useState<string | null>(null);
-
- function handleClick() {
- setError(null);
- startTransition(async () => {
- const res = await prioritizeChannelDownloadAction(slug);
- if (res.ok) setDone(true);
- else setError(res.error);
- });
- }
-
- return (
- <span className="inline-flex items-center gap-1.5">
- <button
- type="button"
- onClick={handleClick}
- disabled={pending || done}
- aria-label={`prioritize ${slug} for download`}
- title="Prepend to the auto-download queue and start the runner"
- className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
- >
- {done ? "Queued ↑" : pending ? "…" : "Top of queue"}
- </button>
- {error && (
- <span role="alert" className="text-xs text-destructive">
- {error}
- </span>
- )}
- </span>
- );
-}
diff --git a/editor/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx
@@ -8,8 +8,15 @@ import { usePolledPayload, useNow } from "../../lib/usePolledPayload";
import { PipelineBand } from "./PipelineBand";
import { NeedsWorkPanel } from "./NeedsWorkPanel";
import { QuickAddPanel } from "./QuickAddPanel";
-import { ChannelsTable } from "./ChannelsTable";
-import type { DashboardChannel } from "./types";
+import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow";
+import { ChannelsTable } from "../../channels/components/ChannelsTable";
+import {
+ DASHBOARD_COLUMNS,
+ type ChannelRowExtras,
+} from "../../channels/components/channelColumns";
+import { ChannelSyncButton } from "../../channels/components/ChannelSyncButton";
+import { InlineActionButton } from "../actions/InlineActionButton";
+import { PrioritizeButton } from "../actions/PrioritizeButton";
// Poll cadences mirror the monitor widget: job/worker state moves by the second;
// the worklist and sync/scheduler markers move by the minute, so they poll no
@@ -22,7 +29,9 @@ export type DashboardCockpitInitial = {
workers: WorkersPayload;
actionable: WidgetActionablePayload;
sync: WidgetSyncPayload;
- channels: DashboardChannel[];
+ // Built server-side in app/page.tsx and passed straight through — never into
+ // client state — so a global AutoRefresh re-seeds them live.
+ channels: ChannelRowView[];
changelogSource: string | null;
};
@@ -78,7 +87,59 @@ export function DashboardCockpit({
<NeedsWorkPanel data={actionable} />
<QuickAddPanel changelogSource={initial.changelogSource} />
</div>
- <ChannelsTable channels={initial.channels} now={now} />
+ <DashboardChannels channels={initial.channels} now={now} />
</div>
);
}
+
+// The dashboard's channels table: the shared channel table with six columns,
+// a relative "last sync" that ticks live, and per-row inline actions (Sync,
+// Download missing, and a one-click "Top of queue" that prioritizes the channel
+// for auto-download).
+function DashboardChannels({
+ channels,
+ now,
+}: {
+ channels: ChannelRowView[];
+ now: number | null;
+}) {
+ if (channels.length === 0) return null;
+ const rowExtras: Record<string, ChannelRowExtras> = {};
+ for (const c of channels) {
+ rowExtras[c.slug] = {
+ actions: (
+ <div className="flex flex-wrap items-center gap-1.5">
+ {c.hasUrl ? (
+ <>
+ <ChannelSyncButton slug={c.slug} />
+ {c.work.undownloaded > 0 && (
+ <InlineActionButton
+ variant={{ kind: "downloadMissing", slug: c.slug }}
+ />
+ )}
+ <PrioritizeButton slug={c.slug} />
+ </>
+ ) : (
+ <span className="text-xs text-muted-foreground">no URL</span>
+ )}
+ </div>
+ ),
+ };
+ }
+ return (
+ <section className="flex flex-col gap-3">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ Channels
+ </h2>
+ <ChannelsTable
+ rows={channels}
+ columns={DASHBOARD_COLUMNS}
+ rowLabelPrefix="channel"
+ rowExtras={rowExtras}
+ sort={false}
+ selection={false}
+ now={now}
+ />
+ </section>
+ );
+}
diff --git a/editor/app/components/dashboard/types.ts b/editor/app/components/dashboard/types.ts
@@ -1,30 +0,0 @@
-import type { MediaBadgeInput } from "../MediaLocationBadge";
-
-// Enriched per-channel row for the dashboard cockpit's Channels table. Built
-// server-side in app/page.tsx from the actionable summary and passed straight
-// through (not into client state), so a global AutoRefresh re-seeds it live.
-export type DashboardChannel = {
- slug: string;
- handling: string;
- videoCount: number;
- // Epoch ms of the channel's last sync, or null when it has never synced.
- lastSyncedAt: number | null;
- // Whether the channel has a URL — inline Sync / Download need one.
- hasUrl: boolean;
- undownloaded: number;
- untranscribed: number;
- // The digest band's reachable — what the lane could act on today; not
- // blocked, not deferred. Missing, stale or part-done at the CURRENT identity,
- // from the operation registry. The only per-channel number that makes corpus
- // coverage legible while a multi-week sweep is running.
- //
- // Videos with no transcript are NOT in it: they are classified as waiting on
- // transcription and counted separately.
- digestReachable: number;
- // Where this channel's media physically is, when that is not "in the channel
- // directory". Null for an in-place channel — the overwhelming majority — so
- // the badge marks only the rows whose other numbers may not be trustworthy:
- // an unmounted drive reads as "nothing downloaded" to every count on this
- // row. See app/components/MediaLocationBadge.tsx.
- media: MediaBadgeInput | null;
-};
diff --git a/editor/app/jobs/components/JobProgressBars.tsx b/editor/app/jobs/components/JobProgressBars.tsx
@@ -1,30 +1,23 @@
"use client";
-import { useEffect, useState } from "react";
import { formatDuration } from "yt-dlp-transcript-common/lib/format";
+import { useNow } from "../../lib/usePolledPayload";
import type {
JobProgressMetric,
JobTaskKind,
} from "yt-dlp-transcript-common/jobs/registry";
import type { JobRowTask, JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
-// The progress bars, shared by the card renderer (RunningJobsList) and the
-// /jobs table. The task bar's aria-label and its `gap-0.5` wrapper are what the
-// suite climbs from to read the elapsed timer, so they live in ONE place rather
-// than being restated by whichever surface draws them.
-
-// Live wall-clock that re-renders once a second on the client. Starts null so
-// the SSR and first client render agree (no hydration mismatch from Date.now());
-// the elapsed timers fill in right after mount.
-function useNow(): number | null {
- const [now, setNow] = useState<number | null>(null);
- useEffect(() => {
- setNow(Date.now());
- const id = setInterval(() => setNow(Date.now()), 1000);
- return () => clearInterval(id);
- }, []);
- return now;
-}
+// The progress bars, drawn by every job row (./JobRow.tsx): the /jobs table,
+// the card list, and — `compact` — the monitor widget and the in-flight lists.
+// The task bar's aria-label and its `gap-0.5` wrapper are what the suite climbs
+// from to read the elapsed timer, so they live in ONE place rather than being
+// restated by whichever surface draws them.
+//
+// COMPACT KEEPS THE WIDGET'S GLYPHS. A pinned widget reads "↓ 5/10", "✎ id";
+// the full form reads "Downloads: 5 / 10", "Transcribing id", and the suite
+// asserts the full form's names (`/Transcribing slowop/`), so the two label
+// sets are both kept, each on its own variant.
// Records over JobTaskKind, so adding a kind is a compile error here.
const TASK_KIND_VERB: Record<JobTaskKind, string> = {
@@ -43,12 +36,34 @@ const METRIC_FILL_BY_TASK: Record<JobTaskKind, string> = {
relocate: "bg-info/70",
};
-export function TaskProgressBar({ task }: { task: JobRowTask }) {
+// The compact bar's glyph per task kind, and the probing glyph.
+const TASK_KIND_GLYPH: Record<JobTaskKind, string> = {
+ download: "\u2193",
+ transcribe: "\u270e",
+ digest: "\u00b6",
+ backfill: "\u21ba",
+ // A right arrow: a relocate task is a channel's media moving to another disk.
+ relocate: "\u21e2",
+};
+
+export function TaskProgressBar({
+ task,
+ compact = false,
+}: {
+ task: JobRowTask;
+ compact?: boolean;
+}) {
// How long this task has been running. Null until mounted (see useNow);
// formatDuration returns "" for 0, so the just-started case shows "0:00".
const now = useNow();
const probing = task.phase === "probing";
- const verb = probing ? "Probing audio" : TASK_KIND_VERB[task.kind];
+ const verb = compact
+ ? probing
+ ? "🔍"
+ : TASK_KIND_GLYPH[task.kind]
+ : probing
+ ? "Probing audio"
+ : TASK_KIND_VERB[task.kind];
// While probing, fill against the estimated probe duration (a distinct violet
// "scanning" bar) rather than the frozen download fraction. yt-dlp is paused,
// so the download fraction wouldn't advance anyway. Falls back to an
@@ -83,17 +98,25 @@ export function TaskProgressBar({ task }: { task: JobRowTask }) {
const meta = [
elapsed,
hasFraction ? `${pct}%` : null,
- probing ? "probing audio…" : task.detail,
+ probing ? (compact ? "probing" : "probing audio…") : task.detail,
]
.filter(Boolean)
.join(" · ");
return (
- <div className="flex flex-col gap-0.5">
- <div className="flex items-baseline justify-between gap-2 text-xs">
+ <div
+ className={
+ compact
+ ? "flex flex-col gap-0.5 pl-2 border-l-2 border-border"
+ : "flex flex-col gap-0.5"
+ }
+ >
+ <div
+ className={`flex items-baseline justify-between gap-2 ${compact ? "text-[11px]" : "text-xs"}`}
+ >
<span className="truncate">
<span className="text-muted-foreground">{verb} </span>
<span className="font-mono">{task.label}</span>
- {task.workerName && (
+ {!compact && task.workerName && (
<span className="text-muted-foreground">
{" "}
on <span className="font-mono">{task.workerName}</span>
@@ -104,11 +127,11 @@ export function TaskProgressBar({ task }: { task: JobRowTask }) {
</div>
<div
role="progressbar"
- aria-valuemin={0}
- aria-valuemax={100}
+ aria-valuemin={compact ? undefined : 0}
+ aria-valuemax={compact ? undefined : 100}
aria-valuenow={hasFraction ? pct : undefined}
- aria-label={`${verb} ${task.label}`}
- className="relative h-1.5 w-full overflow-hidden rounded bg-muted"
+ aria-label={compact ? task.label : `${verb} ${task.label}`}
+ className={`relative ${compact ? "h-1" : "h-1.5"} w-full overflow-hidden rounded bg-muted`}
>
{hasFraction ? (
<div
@@ -137,6 +160,15 @@ const METRIC_LABELS: Record<JobProgressMetric, string> = {
scans: "Metadata scan",
};
+// Per-metric glyph for the compact line ("↓ 5/10").
+const METRIC_PREFIX: Record<JobProgressMetric, string> = {
+ downloads: "\u2193 ",
+ transcripts: "",
+ digests: "\u00b6 ",
+ backfills: "\u21ba ",
+ scans: "\u2315 ",
+};
+
const METRIC_FILL: Record<JobProgressMetric, string> = {
downloads: "bg-success/60",
transcripts: "bg-success",
@@ -145,11 +177,64 @@ const METRIC_FILL: Record<JobProgressMetric, string> = {
scans: "bg-info/60",
};
+// One-line textual summary of a job's batch progress, e.g. "↓ 5/10 · ~2m left"
+// — the compact heading (headingProgress) and the compact bar's caption.
+export function compactProgressText(
+ progress: NonNullable<JobRowView["progress"]>,
+ showEta: boolean,
+): string {
+ const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`;
+ const etaText = compactEta(progress, showEta);
+ return etaText ? `${label} · ${etaText}` : label;
+}
+
+function compactEta(
+ progress: NonNullable<JobRowView["progress"]>,
+ showEta: boolean,
+): string | null {
+ const remaining = progress.target - progress.current;
+ return !showEta || remaining <= 0
+ ? null
+ : typeof progress.etaSeconds === "number"
+ ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left`
+ : "…";
+}
+
export function JobProgressBar({
progress,
+ compact = false,
+ showEta = true,
}: {
progress: NonNullable<JobRowView["progress"]>;
+ compact?: boolean;
+ // Compact only: the widget's `eta` switch.
+ showEta?: boolean;
}) {
+ if (compact) {
+ const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`;
+ const etaText = compactEta(progress, showEta);
+ return (
+ <div className="flex flex-col gap-0.5">
+ <div
+ role="progressbar"
+ aria-valuemin={progress.initial}
+ aria-valuemax={progress.target}
+ aria-valuenow={progress.current}
+ aria-label={label}
+ className="relative h-1.5 w-full overflow-hidden rounded bg-muted"
+ >
+ <div
+ className="absolute inset-y-0 left-0 bg-success"
+ style={{ width: `${progress.pct}%` }}
+ />
+ </div>
+ <div className="flex items-baseline justify-between gap-2 text-[11px] text-muted-foreground">
+ <span>{label}</span>
+ {etaText && <span className="shrink-0">{etaText}</span>}
+ </div>
+ </div>
+ );
+ }
const label = `${METRIC_LABELS[progress.metric]}: ${progress.current} / ${progress.target}`;
// Append an ETA once the batch has a measured average. formatDuration returns
// "" for 0/falsy, so guard against printing a bare "·".
diff --git a/editor/app/jobs/components/JobRow.tsx b/editor/app/jobs/components/JobRow.tsx
@@ -0,0 +1,563 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
+import { formatDuration } from "yt-dlp-transcript-common/lib/format";
+import { JobLogTail } from "../[id]/components/JobLogTail";
+import { jobKindLabel } from "../jobKindLabels";
+import { formatElapsed } from "../../lib/formatElapsed";
+import { CancelJobButton } from "./CancelJobButton";
+import { DrainJobButton } from "./DrainJobButton";
+import { ForceReleaseJobButton } from "./ForceReleaseJobButton";
+import { ReorderJobButtons } from "./ReorderJobButtons";
+import { RetryJobButton } from "./RetryJobButton";
+import {
+ JobProgressBar,
+ TaskProgressBar,
+ compactProgressText,
+} from "./JobProgressBars";
+
+// ONE JOB, DRAWN ONE WAY — in three sizes.
+//
+// Every surface that shows a job in flight draws a `JobRowView`
+// (common/views/jobRowView.ts) through this file: the /jobs table (`table`, a
+// <tr>), the card list a page shows for its own jobs (`card`, an <li>), and
+// the dense lines of the monitor widget and the operations board's in-flight
+// list (`compact`, a <div>). The heading, the status colours, the action
+// buttons and the progress bars are this file's, once; a variant decides only
+// the container and which parts it `show`s.
+
+// Which parts a row draws. Every flag defaults per variant (see DEFAULTS).
+export type JobRowShow = {
+ tasks: boolean; // the per-task progress bars
+ jobBar: boolean; // the batch progress bar
+ headingProgress: boolean; // compact: "↓ 5/10" in the heading line
+ eta: boolean; // compact: "~2m left" beside the batch figure
+ actions: boolean; // JobRowActions
+ links: boolean; // Job / channel / video links (compact: plain text when off)
+ log: boolean; // card: the Show log toggle + tail
+ elapsed: boolean; // compact: "3m07s" since startedAt, at the line's end
+ statusPill: boolean; // off where every row is running by construction
+};
+
+type Variant = "table" | "card" | "compact";
+
+const DEFAULTS: Record<Variant, JobRowShow> = {
+ table: {
+ tasks: true,
+ jobBar: true,
+ headingProgress: false,
+ eta: true,
+ actions: true,
+ links: true,
+ log: false,
+ elapsed: false,
+ statusPill: true,
+ },
+ card: {
+ tasks: true,
+ jobBar: true,
+ headingProgress: false,
+ eta: true,
+ actions: true,
+ links: true,
+ log: false,
+ elapsed: false,
+ statusPill: true,
+ },
+ compact: {
+ tasks: true,
+ jobBar: false,
+ headingProgress: false,
+ eta: false,
+ actions: false,
+ links: false,
+ log: false,
+ elapsed: false,
+ statusPill: true,
+ },
+};
+
+function fmtDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`;
+ const s = ms / 1000;
+ if (s < 60) return `${s.toFixed(1)}s`;
+ const m = s / 60;
+ return `${m.toFixed(1)}m`;
+}
+
+// "next in line" reads better than "1st"; past that, an ordinal.
+function queuePlace(position: number): string {
+ if (position === 1) return "next in line";
+ const rem100 = position % 100;
+ const rem10 = position % 10;
+ const suffix =
+ rem100 >= 11 && rem100 <= 13
+ ? "th"
+ : rem10 === 1
+ ? "st"
+ : rem10 === 2
+ ? "nd"
+ : rem10 === 3
+ ? "rd"
+ : "th";
+ return `${position}${suffix} in line`;
+}
+
+export function statusColor(status: string): string {
+ switch (status) {
+ case "queued":
+ return "bg-muted text-muted-foreground";
+ case "running":
+ return "bg-info-soft text-info";
+ case "done":
+ return "bg-success-soft text-success";
+ case "failed":
+ return "bg-destructive-soft text-destructive";
+ case "cancelled":
+ return "bg-warning-soft text-warning";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+// A row with a page at /jobs/<id>. A runner's in-flight unit that is a task
+// on its runner's job (a transcription, a digest) is not a registry job, so it
+// does not link to one; a download unit is, and carries its id.
+function linksToJob(job: JobRowView): boolean {
+ return job.source !== "runner" || Boolean(job.inRegistry);
+}
+
+function videoHref(job: JobRowView): string | null {
+ return job.channelSlug && job.videoId
+ ? `/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}`
+ : null;
+}
+
+// THE ACTIONS, by status — one rule for every surface: Retry a failed
+// replayable job, Reorder a queued one, Drain a drainable running one, Cancel
+// anything live, and Force-release anything running or stuck (the escape hatch
+// for a Cancel that leaves a queue wedged; see registry.forceRelease — a stuck
+// terminal/evicted row has no Cancel to offer, so it gets this instead).
+export function JobRowActions({
+ job,
+ forceRelease = "running-or-stuck",
+}: {
+ job: JobRowView;
+ // The lane line offers Force-release only on a STUCK runner: for a healthy
+ // one it does what Cancel beside it does, as a second, filled-red button.
+ forceRelease?: "running-or-stuck" | "stuck";
+}) {
+ return (
+ <>
+ {job.status === "failed" && job.replayable && (
+ <RetryJobButton jobId={job.id} />
+ )}
+ {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && (
+ <ReorderJobButtons
+ jobId={job.id}
+ canMoveUp={job.canMoveUp}
+ canMoveDown={job.canMoveDown}
+ />
+ )}
+ {job.status === "running" && (job.drainable || job.draining) && (
+ <DrainJobButton jobId={job.id} draining={job.draining} />
+ )}
+ {(job.status === "running" || job.status === "queued") && (
+ <CancelJobButton jobId={job.id} />
+ )}
+ {((forceRelease === "running-or-stuck" && job.status === "running") ||
+ Boolean(job.stuck)) && <ForceReleaseJobButton jobId={job.id} />}
+ </>
+ );
+}
+
+function AutoBadge({ compact }: { compact?: boolean }) {
+ return (
+ <span
+ className={
+ compact
+ ? "uppercase tracking-wide px-1 py-0.5 rounded text-[9px] bg-warning-soft text-warning"
+ : "text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning"
+ }
+ title="Background work — yields to a manually-triggered sync/download on the same platform"
+ >
+ auto
+ </span>
+ );
+}
+
+// THE HEADING: status pill · kind · detail · auto badge · links · queue key.
+// "Fetch window" alone is the least useful row on a page — every window fetch
+// has that kind — so WHO ASKED (`detail`) sits beside the kind.
+function JobRowHeading({
+ job,
+ compact = false,
+ links = true,
+ hideChannelSlug,
+ hideVideoId,
+ headingProgress = false,
+ eta = false,
+ statusPill = true,
+}: {
+ statusPill?: boolean;
+ job: JobRowView;
+ compact?: boolean;
+ links?: boolean;
+ hideChannelSlug?: boolean;
+ hideVideoId?: boolean;
+ headingProgress?: boolean;
+ eta?: boolean;
+}) {
+ const linkClass = "font-mono text-xs underline hover:text-foreground";
+ const video = videoHref(job);
+ return (
+ <>
+ {statusPill && (
+ <span
+ className={
+ compact
+ ? `uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusColor(job.status)}`
+ : `text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(job.status)}`
+ }
+ >
+ {job.status}
+ </span>
+ )}
+ <span
+ className={compact ? "shrink-0 font-mono" : "text-xs font-medium"}
+ title={job.kind}
+ >
+ {job.kind ? jobKindLabel(job.kind) : "—"}
+ </span>
+ {job.detail &&
+ (compact ? (
+ // One line, always: a pinned widget and an in-flight list are
+ // narrow, and "rule N · <leaf sentence>" would wrap into a paragraph.
+ <span
+ className="min-w-0 truncate text-xs text-muted-foreground"
+ title={job.detail}
+ >
+ · {job.detail}
+ </span>
+ ) : (
+ <span className="text-xs text-muted-foreground">· {job.detail}</span>
+ ))}
+ {job.background && <AutoBadge compact={compact} />}
+ {links && linksToJob(job) && (
+ <Link href={`/jobs/${job.id}`} className={linkClass}>
+ Job {job.id}
+ </Link>
+ )}
+ {!hideChannelSlug &&
+ job.channelSlug &&
+ (links ? (
+ <Link href={`/channels/${job.channelSlug}`} className={linkClass}>
+ {job.channelSlug}
+ </Link>
+ ) : (
+ <span className="font-mono text-muted-foreground truncate max-w-[12rem]">
+ {job.channelSlug}
+ </span>
+ ))}
+ {links && !hideVideoId && video && (
+ <Link href={video} className={linkClass}>
+ {job.videoId}
+ </Link>
+ )}
+ {headingProgress && job.progress && (
+ <span className="font-mono text-muted-foreground shrink-0">
+ {compactProgressText(job.progress, eta)}
+ </span>
+ )}
+ {!compact && job.status === "queued" && (
+ <span className="text-xs text-muted-foreground">
+ queue <code className="font-mono">{job.queueKey || "—"}</code>
+ {job.background && " · waiting behind a manual job"}
+ </span>
+ )}
+ </>
+ );
+}
+
+function TaskList({ job, compact }: { job: JobRowView; compact?: boolean }) {
+ if (!job.tasks || job.tasks.length === 0) return null;
+ if (compact)
+ return (
+ <>
+ {job.tasks.map((t) => (
+ <TaskProgressBar key={t.id} task={t} compact />
+ ))}
+ </>
+ );
+ return (
+ <ul
+ aria-label="In-progress operations"
+ className="flex flex-col gap-1.5 pl-2 border-l-2 border-border"
+ >
+ {job.tasks.map((t) => (
+ <li key={t.id}>
+ <TaskProgressBar task={t} />
+ </li>
+ ))}
+ </ul>
+ );
+}
+
+export function JobRow({
+ job,
+ variant,
+ hideChannelSlug,
+ hideVideoId,
+ show: showPatch,
+ live = false,
+ now = null,
+}: {
+ job: JobRowView;
+ variant: Variant;
+ hideChannelSlug?: boolean;
+ hideVideoId?: boolean;
+ show?: Partial<JobRowShow>;
+ // table: the row came from the live payload (SSR seed or poll) — `data-live`.
+ live?: boolean;
+ // The ticking clock (useNow), for elapsed and running durations. null until
+ // mounted; the table falls back to Date.now() behind suppressHydrationWarning,
+ // as it always has.
+ now?: number | null;
+}) {
+ const show = { ...DEFAULTS[variant], ...showPatch };
+ if (variant === "table") return <TableRow job={job} live={live} now={now} />;
+ if (variant === "card")
+ return (
+ <CardRow
+ job={job}
+ show={show}
+ hideChannelSlug={hideChannelSlug}
+ hideVideoId={hideVideoId}
+ />
+ );
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-xs">
+ <JobRowHeading
+ job={job}
+ compact
+ statusPill={show.statusPill}
+ links={show.links}
+ hideChannelSlug={hideChannelSlug}
+ hideVideoId={hideVideoId}
+ headingProgress={show.headingProgress}
+ eta={show.eta}
+ />
+ {(show.actions || show.elapsed) && (
+ <span className="ml-auto flex items-center gap-2">
+ {show.actions && <JobRowActions job={job} />}
+ {show.elapsed && (
+ <span className="tabular-nums text-xs text-muted-foreground">
+ {now !== null && typeof job.startedAt === "number"
+ ? formatElapsed(now - job.startedAt)
+ : "—"}
+ </span>
+ )}
+ </span>
+ )}
+ </div>
+ {show.jobBar && job.progress && (
+ <JobProgressBar progress={job.progress} compact showEta={show.eta} />
+ )}
+ {show.tasks && <TaskList job={job} compact />}
+ </div>
+ );
+}
+
+// THE CARD: a page's own jobs (channel, video, build, operation pages).
+function CardRow({
+ job,
+ show,
+ hideChannelSlug,
+ hideVideoId,
+}: {
+ job: JobRowView;
+ show: JobRowShow;
+ hideChannelSlug?: boolean;
+ hideVideoId?: boolean;
+}) {
+ const [showLog, setShowLog] = useState(false);
+ return (
+ <li className="flex flex-col gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0">
+ <div className="flex flex-wrap items-center gap-2 text-sm">
+ <JobRowHeading
+ job={job}
+ links={show.links}
+ hideChannelSlug={hideChannelSlug}
+ hideVideoId={hideVideoId}
+ />
+ {(show.actions || show.log) && (
+ <div className="ml-auto flex items-center gap-2">
+ {show.actions && <JobRowActions job={job} />}
+ {show.log && (
+ <button
+ type="button"
+ onClick={() => setShowLog((s) => !s)}
+ aria-expanded={showLog}
+ aria-label={`${showLog ? "hide" : "show"} log for job ${job.id}`}
+ className="px-2 py-1 rounded-md bg-muted text-foreground text-xs font-medium hover:opacity-90"
+ >
+ {showLog ? "Hide log" : "Show log"}
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ {show.jobBar && job.progress && (
+ <JobProgressBar progress={job.progress} />
+ )}
+ {show.tasks && <TaskList job={job} />}
+ {showLog && <JobLogTail jobId={job.id} initiallyRunning />}
+ </li>
+ );
+}
+
+// THE TABLE ROW: /jobs, one <tr> per job, live head and history tail alike.
+function TableRow({
+ job: j,
+ live,
+ now,
+}: {
+ job: JobRowView;
+ live: boolean;
+ now: number | null;
+}) {
+ const start = j.startedAt ?? j.queuedAt;
+ const dur = j.endedAt
+ ? j.endedAt - (start ?? j.endedAt)
+ : j.startedAt
+ ? (now ?? Date.now()) - j.startedAt
+ : 0;
+ const video = videoHref(j);
+ return (
+ <tr
+ // The kind cell renders a human label (jobKindLabel), so the machine
+ // kind is not matchable from the row text. Expose it here as well as on
+ // the cell's title: asserting on the label text is what rotted 9 e2e
+ // tests when kinds gained labels.
+ data-kind={j.kind || undefined}
+ data-job-id={j.id}
+ // Came from the live payload — the SSR seed or the poll.
+ data-live={live ? "" : undefined}
+ data-stuck={j.stuck?.reason}
+ className="border-t border-border"
+ >
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ <Link
+ href={`/jobs/${j.id}`}
+ className="underline hover:text-foreground"
+ >
+ {j.id}
+ </Link>
+ </td>
+ <td className="px-3 py-2 align-top" title={j.kind || undefined}>
+ <span className="flex items-center gap-1.5">
+ {j.kind ? jobKindLabel(j.kind) : "—"}
+ {j.detail && (
+ <span className="text-xs text-muted-foreground">· {j.detail}</span>
+ )}
+ {j.background && <AutoBadge />}
+ </span>
+ </td>
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ {j.channelSlug ? (
+ <Link
+ href={`/channels/${j.channelSlug}`}
+ className="underline hover:text-foreground"
+ >
+ {j.channelSlug}
+ </Link>
+ ) : (
+ "—"
+ )}
+ </td>
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ {video ? (
+ <Link href={video} className="underline hover:text-foreground">
+ {j.videoId}
+ </Link>
+ ) : (
+ "—"
+ )}
+ </td>
+ <td className="px-3 py-2 font-mono text-xs align-top">
+ {j.queueKey === undefined
+ ? "—"
+ : j.queueKey === ""
+ ? "(immediate)"
+ : j.queueKey}
+ {j.status === "queued" &&
+ typeof j.position === "number" &&
+ j.position >= 1 && (
+ <span className="text-muted-foreground">
+ {" "}
+ · {queuePlace(j.position)}
+ </span>
+ )}
+ </td>
+ <td className="px-3 py-2 align-top min-w-56">
+ {/* gap-1, deliberately NOT gap-0.5: the suite climbs from a task's
+ progressbar to its nearest gap-0.5 ancestor to read the elapsed
+ timer, and that ancestor must stay the task's own wrapper. */}
+ <div className="flex flex-col gap-1">
+ <span
+ className={`self-start text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`}
+ >
+ {j.status}
+ </span>
+ {j.stuck && (
+ <span
+ data-stuck={j.stuck.reason}
+ title={j.pid ? `pid ${j.pid} — kill -9 ${j.pid}` : undefined}
+ className="self-start text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-destructive-soft text-destructive"
+ >
+ stuck · {j.stuck.reason}
+ </span>
+ )}
+ {j.progress && <JobProgressBar progress={j.progress} />}
+ <TaskList job={j} />
+ </div>
+ </td>
+ <td
+ className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
+ suppressHydrationWarning
+ >
+ {start === undefined ? "—" : new Date(start).toLocaleString()}
+ </td>
+ <td
+ className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
+ suppressHydrationWarning
+ >
+ {j.startedAt === undefined && typeof j.heldMs === "number"
+ ? `held for ${formatDuration(Math.round(j.heldMs / 1000)) || "0:00"}`
+ : fmtDuration(dur)}
+ </td>
+ <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap align-top">
+ {j.stuck && j.lastLogLine ? (
+ <span
+ className="font-mono text-xs text-muted-foreground truncate max-w-64 block"
+ title={j.lastLogLine}
+ >
+ {j.lastLogLine}
+ </span>
+ ) : typeof j.logSize === "number" ? (
+ `${j.logSize.toLocaleString()} B`
+ ) : (
+ "—"
+ )}
+ </td>
+ <td className="px-3 py-2 text-right align-top">
+ <div className="flex items-center justify-end gap-2">
+ <JobRowActions job={j} />
+ </div>
+ </td>
+ </tr>
+ );
+}
diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx
@@ -1,21 +1,14 @@
"use client";
import { useEffect, useMemo, useState } from "react";
-import Link from "next/link";
import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs";
import { usePolledPayload } from "../../lib/usePolledPayload";
import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
import { isLive, mergeJobRows } from "yt-dlp-transcript-common/views/jobRows";
-import { CancelJobButton } from "./CancelJobButton";
-import { DrainJobButton } from "./DrainJobButton";
-import { ForceReleaseJobButton } from "./ForceReleaseJobButton";
-import { ReorderJobButtons } from "./ReorderJobButtons";
-import { RetryJobButton } from "./RetryJobButton";
import { ReapStuckButton } from "./ReapStuckButton";
import { LaneStrip } from "./LaneStrip";
-import { JobProgressBar, TaskProgressBar } from "./JobProgressBars";
+import { JobRow, statusColor } from "./JobRow";
import { jobKindLabel } from "../jobKindLabels";
-import { formatDuration } from "yt-dlp-transcript-common/lib/format";
import {
clearJobsFilters,
defaultJobsFilters,
@@ -28,32 +21,6 @@ import {
// per-task progress bars still advance smoothly.
const POLL_MS = 1000;
-function fmtDuration(ms: number): string {
- if (ms < 1000) return `${ms}ms`;
- const s = ms / 1000;
- if (s < 60) return `${s.toFixed(1)}s`;
- const m = s / 60;
- return `${m.toFixed(1)}m`;
-}
-
-// "next in line" reads better than "1st"; past that, an ordinal.
-function queuePlace(position: number): string {
- if (position === 1) return "next in line";
- const rem100 = position % 100;
- const rem10 = position % 10;
- const suffix =
- rem100 >= 11 && rem100 <= 13
- ? "th"
- : rem10 === 1
- ? "st"
- : rem10 === 2
- ? "nd"
- : rem10 === 3
- ? "rd"
- : "th";
- return `${position}${suffix} in line`;
-}
-
// One figure on the health line. `data-stat` is the hook; the label text is the
// contract (the suite matches "stuck" and "active queues" exactly).
function healthStat(
@@ -76,23 +43,6 @@ function healthStat(
);
}
-function statusColor(status: string): string {
- switch (status) {
- case "queued":
- return "bg-muted text-muted-foreground";
- case "running":
- return "bg-info-soft text-info";
- case "done":
- return "bg-success-soft text-success";
- case "failed":
- return "bg-destructive-soft text-destructive";
- case "cancelled":
- return "bg-warning-soft text-warning";
- default:
- return "bg-muted text-muted-foreground";
- }
-}
-
// ONE LIST.
//
// The live head is what the registry and the scheduler say NOW, polled at 1 s
@@ -382,189 +332,14 @@ export function JobsTable({
</tr>
</thead>
<tbody>
- {visible.map((j) => {
- const start = j.startedAt ?? j.queuedAt;
- const dur = j.endedAt
- ? j.endedAt - (start ?? j.endedAt)
- : j.startedAt
- ? Date.now() - j.startedAt
- : 0;
- return (
- <tr
- key={j.id}
- // The kind cell renders a human label (jobKindLabel), so the
- // machine kind is not matchable from the row text. Expose it
- // here as well as on the cell's title: asserting on the label
- // text is what rotted 9 e2e tests when kinds gained labels.
- data-kind={j.kind || undefined}
- data-job-id={j.id}
- // Came from the live payload — the SSR seed or the poll.
- data-live={liveIds.has(j.id) ? "" : undefined}
- data-stuck={j.stuck?.reason}
- className="border-t border-border"
- >
- <td className="px-3 py-2 font-mono text-xs align-top">
- <Link
- href={`/jobs/${j.id}`}
- className="underline hover:text-foreground"
- >
- {j.id}
- </Link>
- </td>
- <td className="px-3 py-2 align-top" title={j.kind || undefined}>
- <span className="flex items-center gap-1.5">
- {j.kind ? jobKindLabel(j.kind) : "—"}
- {/* See RunningJobsList: the kind alone cannot say which
- tool asked for which clip. */}
- {j.detail && (
- <span className="text-xs text-muted-foreground">
- · {j.detail}
- </span>
- )}
- {j.background && (
- <span
- className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning"
- title="Background work — yields to a manually-triggered sync/download on the same platform"
- >
- auto
- </span>
- )}
- </span>
- </td>
- <td className="px-3 py-2 font-mono text-xs align-top">
- {j.channelSlug ? (
- <Link
- href={`/channels/${j.channelSlug}`}
- className="underline hover:text-foreground"
- >
- {j.channelSlug}
- </Link>
- ) : (
- "—"
- )}
- </td>
- <td className="px-3 py-2 font-mono text-xs align-top">
- {j.channelSlug && j.videoId ? (
- <Link
- href={`/channels/${j.channelSlug}/videos/${encodeURIComponent(j.videoId)}`}
- className="underline hover:text-foreground"
- >
- {j.videoId}
- </Link>
- ) : (
- "—"
- )}
- </td>
- <td className="px-3 py-2 font-mono text-xs align-top">
- {j.queueKey === undefined
- ? "—"
- : j.queueKey === ""
- ? "(immediate)"
- : j.queueKey}
- {j.status === "queued" &&
- typeof j.position === "number" &&
- j.position >= 1 && (
- <span className="text-muted-foreground">
- {" "}
- · {queuePlace(j.position)}
- </span>
- )}
- </td>
- <td className="px-3 py-2 align-top min-w-56">
- {/* gap-1, deliberately NOT gap-0.5: the suite climbs from a
- task's progressbar to its nearest gap-0.5 ancestor to
- read the elapsed timer, and that ancestor must stay the
- task's own wrapper. */}
- <div className="flex flex-col gap-1">
- <span
- className={`self-start text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`}
- >
- {j.status}
- </span>
- {j.stuck && (
- <span
- data-stuck={j.stuck.reason}
- title={
- j.pid ? `pid ${j.pid} — kill -9 ${j.pid}` : undefined
- }
- className="self-start text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-destructive-soft text-destructive"
- >
- stuck · {j.stuck.reason}
- </span>
- )}
- {j.progress && <JobProgressBar progress={j.progress} />}
- {j.tasks && j.tasks.length > 0 && (
- <ul
- aria-label="In-progress operations"
- className="flex flex-col gap-1.5 pl-2 border-l-2 border-border"
- >
- {j.tasks.map((t) => (
- <li key={t.id}>
- <TaskProgressBar task={t} />
- </li>
- ))}
- </ul>
- )}
- </div>
- </td>
- <td
- className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
- suppressHydrationWarning
- >
- {start === undefined ? "—" : new Date(start).toLocaleString()}
- </td>
- <td
- className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap align-top"
- suppressHydrationWarning
- >
- {j.startedAt === undefined && typeof j.heldMs === "number"
- ? `held for ${formatDuration(Math.round(j.heldMs / 1000)) || "0:00"}`
- : fmtDuration(dur)}
- </td>
- <td className="px-3 py-2 text-xs text-muted-foreground text-right whitespace-nowrap align-top">
- {j.stuck && j.lastLogLine ? (
- <span
- className="font-mono text-xs text-muted-foreground truncate max-w-64 block"
- title={j.lastLogLine}
- >
- {j.lastLogLine}
- </span>
- ) : typeof j.logSize === "number" ? (
- `${j.logSize.toLocaleString()} B`
- ) : (
- "—"
- )}
- </td>
- <td className="px-3 py-2 text-right align-top">
- <div className="flex items-center justify-end gap-2">
- {j.status === "failed" && j.replayable && (
- <RetryJobButton jobId={j.id} />
- )}
- {j.status === "queued" && (j.canMoveUp || j.canMoveDown) && (
- <ReorderJobButtons
- jobId={j.id}
- canMoveUp={j.canMoveUp}
- canMoveDown={j.canMoveDown}
- />
- )}
- {j.status === "running" && (j.drainable || j.draining) && (
- <DrainJobButton jobId={j.id} draining={j.draining} />
- )}
- {(j.status === "running" || j.status === "queued") && (
- <CancelJobButton jobId={j.id} />
- )}
- {(j.status === "running" || Boolean(j.stuck)) && (
- // Escape hatch: force-free the scheduler slot for any
- // running job, for when a Cancel leaves the queue wedged
- // (see registry.forceRelease). A stuck terminal/evicted
- // row has no Cancel to offer, so it gets this instead.
- <ForceReleaseJobButton jobId={j.id} />
- )}
- </div>
- </td>
- </tr>
- );
- })}
+ {visible.map((j) => (
+ <JobRow
+ key={j.id}
+ job={j}
+ variant="table"
+ live={liveIds.has(j.id)}
+ />
+ ))}
</tbody>
</table>
</div>
diff --git a/editor/app/jobs/components/LaneStrip.tsx b/editor/app/jobs/components/LaneStrip.tsx
@@ -4,8 +4,7 @@ import Link from "next/link";
import type { ActiveLaneView } from "yt-dlp-transcript-common/views/activeJobs";
import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
import { LANE_DOT, LANE_TEXT, LANE_WORD } from "yt-dlp-transcript-common/views/laneState";
-import { DrainJobButton } from "./DrainJobButton";
-import { CancelJobButton } from "./CancelJobButton";
+import { JobRowActions } from "./JobRow";
// THE LANE STRIP. One line per pipeline, in place of one card per runner.
//
@@ -14,6 +13,11 @@ import { CancelJobButton } from "./CancelJobButton";
// a line instead of a card, and that it now says WHY it is not working. Four
// bordered cards saying "running" above the work with the progress bars is the
// layout this replaces.
+//
+// A LANE IS NOT A JOB, so this is not a JobRow: it keeps its own line (the
+// suite pins the section, the heading and the Drain/Cancel on it). What it
+// shares with every job row is the actions — its runner job's buttons are
+// JobRowActions, the same status rules as /jobs and the card list.
export function LaneStrip({
lanes,
jobs,
@@ -77,10 +81,7 @@ function LaneRow({
>
log
</Link>
- {job.drainable && (
- <DrainJobButton jobId={job.id} draining={job.draining} />
- )}
- <CancelJobButton jobId={job.id} />
+ <JobRowActions job={job} forceRelease="stuck" />
</span>
)}
</section>
diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx
@@ -1,15 +1,7 @@
"use client";
-import Link from "next/link";
-import { useState } from "react";
import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
-import { JobLogTail } from "../[id]/components/JobLogTail";
-import { jobKindLabel } from "../jobKindLabels";
-import { DrainJobButton } from "./DrainJobButton";
-import { CancelJobButton } from "./CancelJobButton";
-import { ForceReleaseJobButton } from "./ForceReleaseJobButton";
-import { ReorderJobButtons } from "./ReorderJobButtons";
-import { JobProgressBar, TaskProgressBar } from "./JobProgressBars";
+import { JobRow } from "./JobRow";
type Props = {
jobs: JobRowView[];
@@ -17,9 +9,9 @@ type Props = {
hideVideoId?: boolean;
};
-// THE CARD RENDERER for a page's own jobs (channel, video, build, operation
-// pages). /jobs draws the same JobRowView as a table row; both read the one
-// type, and both get their rows from the one builder (liveJobRows).
+// THE CARD LIST for a page's own jobs (channel, video, build, operation
+// pages). /jobs draws the same JobRowView as a table row; both are ./JobRow.tsx,
+// and both get their rows from the one builder (liveJobRows).
export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) {
if (jobs.length === 0) return null;
return (
@@ -35,6 +27,8 @@ export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) {
<JobRow
key={j.id}
job={j}
+ variant="card"
+ show={{ log: true }}
hideChannelSlug={hideChannelSlug}
hideVideoId={hideVideoId}
/>
@@ -43,116 +37,3 @@ export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) {
</section>
);
}
-
-function JobRow({
- job,
- hideChannelSlug,
- hideVideoId,
-}: {
- job: JobRowView;
- hideChannelSlug?: boolean;
- hideVideoId?: boolean;
-}) {
- const [showLog, setShowLog] = useState(false);
- const statusClass =
- job.status === "running"
- ? "bg-info-soft text-info"
- : "bg-muted text-muted-foreground";
- return (
- <li className="flex flex-col gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0">
- <div className="flex flex-wrap items-center gap-2 text-sm">
- <span
- className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusClass}`}
- >
- {job.status}
- </span>
- <span className="text-xs font-medium" title={job.kind}>
- {jobKindLabel(job.kind)}
- </span>
- {/* WHO ASKED, beside the kind. "Fetch window" alone is the least
- useful row on the page: every window fetch has that kind, and the
- question is which tool wanted which clip. */}
- {job.detail && (
- <span className="text-xs text-muted-foreground">· {job.detail}</span>
- )}
- {job.background && (
- <span
- className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-warning-soft text-warning"
- title="Background work — yields to a manually-triggered sync/download on the same platform"
- >
- auto
- </span>
- )}
- <Link
- href={`/jobs/${job.id}`}
- className="font-mono text-xs underline hover:text-foreground"
- >
- Job {job.id}
- </Link>
- {!hideChannelSlug && job.channelSlug && (
- <Link
- href={`/channels/${job.channelSlug}`}
- className="font-mono text-xs underline hover:text-foreground"
- >
- {job.channelSlug}
- </Link>
- )}
- {!hideVideoId && job.channelSlug && job.videoId && (
- <Link
- href={`/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}`}
- className="font-mono text-xs underline hover:text-foreground"
- >
- {job.videoId}
- </Link>
- )}
- {job.status === "queued" && (
- <span className="text-xs text-muted-foreground">
- queue <code className="font-mono">{job.queueKey || "—"}</code>
- {job.background && " · waiting behind a manual job"}
- </span>
- )}
- <div className="ml-auto flex items-center gap-2">
- {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && (
- <ReorderJobButtons
- jobId={job.id}
- canMoveUp={job.canMoveUp}
- canMoveDown={job.canMoveDown}
- />
- )}
- {job.status === "running" && (job.drainable || job.draining) && (
- <DrainJobButton jobId={job.id} draining={job.draining} />
- )}
- <CancelJobButton jobId={job.id} />
- {job.status === "running" && (
- // Escape hatch: force-free the scheduler slot for any running job,
- // for when a Cancel leaves the queue wedged (see registry.forceRelease).
- <ForceReleaseJobButton jobId={job.id} />
- )}
- <button
- type="button"
- onClick={() => setShowLog((s) => !s)}
- aria-expanded={showLog}
- aria-label={`${showLog ? "hide" : "show"} log for job ${job.id}`}
- className="px-2 py-1 rounded-md bg-muted text-foreground text-xs font-medium hover:opacity-90"
- >
- {showLog ? "Hide log" : "Show log"}
- </button>
- </div>
- </div>
- {job.progress && <JobProgressBar progress={job.progress} />}
- {job.tasks && job.tasks.length > 0 && (
- <ul
- aria-label="In-progress operations"
- className="flex flex-col gap-1.5 pl-2 border-l-2 border-border"
- >
- {job.tasks.map((t) => (
- <li key={t.id}>
- <TaskProgressBar task={t} />
- </li>
- ))}
- </ul>
- )}
- {showLog && <JobLogTail jobId={job.id} initiallyRunning />}
- </li>
- );
-}
diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts
@@ -3,11 +3,21 @@ import { cache } from "react";
import type { ChannelBrief } from "yt-dlp-transcript-common/controller/channels";
import type { WidgetActionableChannel } from "yt-dlp-transcript-common/views/widgetActionable";
import { getChannelBriefs } from "../requestCache";
+import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
import {
- digestWorkOf,
- excludedDownloadIdSet,
- type ChannelSnapshot,
-} from "yt-dlp-transcript-common/controller/channelSnapshot";
+ cleanExtraFormatsBytesOf,
+ cleanExtraFormatsCountOf,
+ cleanTranscribedBytesOf,
+ cleanTranscribedCountOf,
+ digestReachableCountOf,
+ digestWarningsCountOf,
+ incompleteTranscriptCountOf,
+ metadataScanCountOf,
+ missingNeverFetchedCountOf,
+ shortAudioCountOf,
+ undownloadedCountOf,
+ untranscribedCountOf,
+} from "yt-dlp-transcript-common/views/actionableCounts";
export type ActionableRow = {
channel: ChannelBrief;
@@ -29,128 +39,36 @@ export type ActionableSummary = {
digestWarnings: ActionableRow[];
};
-// How old a channel's report is, in the three states /channels draws:
-// "missing" (never generated), "stale" (older than the last sync — so every
-// count read off it may be wrong) and "current".
-//
-// Takes a brief rather than an ActionableRow because /channels holds briefs and
-// the two carry the same two fields; `isStaleOrMissing` is this function with
-// the two non-current states collapsed.
-export function reportStateOf(
- brief: Pick<ChannelBrief, "config" | "snapshot">,
-): "current" | "stale" | "missing" {
- if (!brief.snapshot) return "missing";
- const synced = brief.config.lastSyncedAt;
- if (!synced) return "current";
- return new Date(synced).getTime() >
- new Date(brief.snapshot.generatedAt).getTime()
- ? "stale"
- : "current";
-}
-
-export function isStaleOrMissing(row: ActionableRow): boolean {
- return reportStateOf(row.channel) !== "current";
-}
-
-// Counts that drive the actionable lists exclude IDs that the availability
-// check has flagged as deleted / members-only / private — those videos
-// can't be acted on, so they shouldn't inflate "needs attention" totals.
-// `undownloadedIds` is already filtered at snapshot generation time, but we
-// apply the filter again so a stale snapshot can't surface excluded IDs.
-function countActionable(
- snapshot: ChannelSnapshot | null | undefined,
- ids: readonly string[] | undefined,
-): number {
- if (!snapshot || !ids) return 0;
- const excluded = excludedDownloadIdSet(snapshot);
- if (excluded.size === 0) return ids.length;
- let n = 0;
- for (const id of ids) if (!excluded.has(id)) n++;
- return n;
-}
-
-export function actionableUndownloadedCount(row: ActionableRow): number {
- return countActionable(row.snapshot, row.snapshot?.undownloadedIds);
-}
-
-// Videos the roster says we were told about, never downloaded, and that have
-// since left the listing. Deliberately NOT run through countActionable: the
-// availability exclusions are keyed on videos we have on disk, and these have no
-// dir at all. Default 0 for snapshots written before the bucket existed.
-export function actionableMissingNeverFetchedCount(row: ActionableRow): number {
- return row.snapshot?.missingNeverFetched?.length ?? 0;
-}
-
-// Listed videos the metadata scan has neither read nor recently failed on —
-// the scan operation's backlog, counted at snapshot-generation time so this
-// costs no extra read. Deliberately NOT run through countActionable: these
-// videos have no directory, so the availability exclusions (which are keyed on
-// what is on disk) cannot say anything about them. Default 0 for snapshots
-// written before the field existed.
-export function actionableMetadataScanCount(row: ActionableRow): number {
- return row.snapshot?.metadataScan?.unscanned ?? 0;
-}
-
-export function actionableUntranscribedCount(row: ActionableRow): number {
- return countActionable(
- row.snapshot,
- row.snapshot?.buckets.downloadedNoTranscript,
- );
-}
-
-// Transcribed videos whose transcript is badly truncated (the audio download
-// stopped early). Default 0 for snapshots written before the bucket existed.
-export function actionableIncompleteTranscriptCount(row: ActionableRow): number {
- return row.snapshot?.buckets.incompleteTranscript?.length ?? 0;
-}
-
-// Downloads the duration guard flagged as truncated at the source (short audio
-// kept on disk, not transcribed). Default 0 for snapshots predating the bucket.
-export function actionableShortAudioCount(row: ActionableRow): number {
- return row.snapshot?.buckets.shortAudio?.length ?? 0;
-}
-
-// Cleanup buckets are filtered by "do not clean" at snapshot-generation time,
-// so the length is the actionable count directly (default undefined → 0 for
-// snapshots written before the bucket existed).
-export function actionableCleanTranscribedCount(row: ActionableRow): number {
- return row.snapshot?.buckets.transcribedWithAudio?.length ?? 0;
-}
-
-export function actionableCleanExtraFormatsCount(row: ActionableRow): number {
- return row.snapshot?.buckets.multipleAudioFormats?.length ?? 0;
-}
-
-// The digest layer's two work lists. The first is "has no digest at the current
-// identity" — missing, stale or part-done. `digestWarnings` is "the model
-// produced something a human should look at", which includes the total failures
-// that write no section and so are invisible to any count of files.
-//
-// Read through digestWorkOf: the registry's classification is the one the runner
-// uses, and it excludes videos with no transcript (blocked) and videos whose
-// cues.json is stale (deferred) — work the old `noDigest` bucket offered here
-// and the runner then declined.
-//
-// This IS the digest band's `reachable`, per channel: buildBands.ts:63 folds
-// the same call. The name says so now, so nobody re-sources a number that was
-// already the right one.
-export function actionableDigestReachableCount(row: ActionableRow): number {
- return digestWorkOf(row.snapshot).reachable;
-}
-
-export function actionableDigestWarningsCount(row: ActionableRow): number {
- return row.snapshot?.buckets.digestWarnings?.length ?? 0;
-}
-
-// Estimated bytes each cleanup would reclaim (default 0 for snapshots written
-// before cleanupBytes existed).
-export function actionableCleanTranscribedBytes(row: ActionableRow): number {
- return row.snapshot?.cleanupBytes?.transcribedWithAudio ?? 0;
-}
-
-export function actionableCleanExtraFormatsBytes(row: ActionableRow): number {
- return row.snapshot?.cleanupBytes?.multipleAudioFormats ?? 0;
-}
+// THE COUNT HELPERS, as wrappers. The counting itself is
+// common/views/actionableCounts.ts — pure functions over one snapshot — which
+// the channel-row builder calls too, so a dashboard cell and this census cannot
+// count a channel two ways. See that module for what each one excludes.
+export const actionableUndownloadedCount = (row: ActionableRow): number =>
+ undownloadedCountOf(row.snapshot);
+export const actionableMissingNeverFetchedCount = (row: ActionableRow): number =>
+ missingNeverFetchedCountOf(row.snapshot);
+export const actionableMetadataScanCount = (row: ActionableRow): number =>
+ metadataScanCountOf(row.snapshot);
+export const actionableUntranscribedCount = (row: ActionableRow): number =>
+ untranscribedCountOf(row.snapshot);
+export const actionableIncompleteTranscriptCount = (row: ActionableRow): number =>
+ incompleteTranscriptCountOf(row.snapshot);
+export const actionableShortAudioCount = (row: ActionableRow): number =>
+ shortAudioCountOf(row.snapshot);
+export const actionableCleanTranscribedCount = (row: ActionableRow): number =>
+ cleanTranscribedCountOf(row.snapshot);
+export const actionableCleanExtraFormatsCount = (row: ActionableRow): number =>
+ cleanExtraFormatsCountOf(row.snapshot);
+// This IS the digest band's `reachable`, per channel: buildBands.ts folds the
+// same call.
+export const actionableDigestReachableCount = (row: ActionableRow): number =>
+ digestReachableCountOf(row.snapshot);
+export const actionableDigestWarningsCount = (row: ActionableRow): number =>
+ digestWarningsCountOf(row.snapshot);
+export const actionableCleanTranscribedBytes = (row: ActionableRow): number =>
+ cleanTranscribedBytesOf(row.snapshot);
+export const actionableCleanExtraFormatsBytes = (row: ActionableRow): number =>
+ cleanExtraFormatsBytesOf(row.snapshot);
// The census rows as the widget's "Needs work" strip counts them: one row per
// channel, through the three count helpers above. The filter and the sort are
diff --git a/editor/app/lib/formatElapsed.ts b/editor/app/lib/formatElapsed.ts
@@ -0,0 +1,14 @@
+// A running clock, compactly: "42s", "3m07s", "2h05m". The operations board's
+// runner header and every in-flight row read it. (Not formatDuration's
+// "3:07": that one is a media duration, and it renders 0 as "".)
+export function formatElapsed(ms: number): string {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) {
+ const rem = s % 60;
+ return rem === 0 ? `${m}m` : `${m}m${String(rem).padStart(2, "0")}s`;
+ }
+ const h = Math.floor(m / 60);
+ return `${h}h${String(m % 60).padStart(2, "0")}m`;
+}
diff --git a/editor/app/operations/components/InFlightList.tsx b/editor/app/operations/components/InFlightList.tsx
@@ -1,12 +1,32 @@
"use client";
-import Link from "next/link";
import type { AutoQueueKindStatus } from "yt-dlp-transcript-common/views/autoQueueStatus";
-import { type Channel, formatElapsed, leafOrder, leafSentence } from "./dispatch";
+import { fromInFlight } from "yt-dlp-transcript-common/views/jobRows";
+import { JobRow } from "../../jobs/components/JobRow";
+import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes";
+import { type Channel, leafOrder, leafSentence } from "./dispatch";
+
+// The JOB kind a lane's unit runs under, so the row's label resolves
+// (jobKindLabel) rather than showing a raw lane id. A download unit is its own
+// registry job; every other lane's unit is a task on the lane's runner job.
+// Mirrors autoRunner.ts's RUNNER_JOB_KINDS / AUTO_DOWNLOAD_UNIT_KIND, which a
+// client component cannot import (that module reaches the filesystem); a
+// Record over the lane union, so a new lane is a compile error here.
+const UNIT_JOB_KIND: Record<AutoQueueKind, string> = {
+ download: "auto-download-unit",
+ transcription: "auto-transcribe",
+ digest: "auto-digest",
+ backfill: "auto-backfill",
+};
// What the runner is doing RIGHT NOW. `runner.inFlight` has always been in the
// status payload — video id, owning channel, the leaf that claimed it and when
// it started — and the old page rendered a count and threw the rest away.
+//
+// Each unit is drawn as a JOB ROW (jobs/components/JobRow.tsx, compact), off
+// the pure adapter `fromInFlight` — the same row the widget and /jobs draw. A
+// download unit is a registry job and links to its page; a transcription,
+// digest or backfill unit is a task on the runner's job and does not.
export function InFlightList({
status,
@@ -30,52 +50,38 @@ export function InFlightList({
{[...items]
.sort((a, b) => a.startedAt - b.startedAt)
.map((item) => {
+ // WHICH RULE CLAIMED IT, as the ladder numbers them. A unit that
+ // is not a video (the download lane's metadata-scan unit, whose
+ // `videoId` is a synthetic key naming no directory) carries a
+ // `note`, which fromInFlight says instead — no "rule N" for a leaf
+ // that exists in no tree, and no link to a video that is not there.
const at = leaves.findIndex((l) => l.id === item.leafId);
const leaf = at >= 0 ? leaves[at] : null;
+ const detail = `${at >= 0 ? `rule ${at + 1}` : item.leafId}${
+ leaf ? ` · ${leafSentence(leaf, channels)}` : ""
+ }`;
+ const row = fromInFlight(item, {
+ kind: UNIT_JOB_KIND[status.kind],
+ runnerJobId: status.runner.jobId,
+ detail,
+ });
return (
- <li
- key={item.videoId}
- className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm"
- >
- <span
- aria-hidden="true"
- className="size-1.5 shrink-0 self-center rounded-full bg-info animate-pulse motion-reduce:animate-none"
+ <li key={item.videoId} className="text-sm">
+ <JobRow
+ job={row}
+ variant="compact"
+ show={{
+ actions: false,
+ tasks: false,
+ links: true,
+ elapsed: true,
+ // Every unit here is in flight by construction; a pill
+ // saying so on each line is noise (and wrong for a download
+ // unit still queued on its platform).
+ statusPill: false,
+ }}
+ now={now}
/>
- {/* A UNIT THAT IS NOT A VIDEO SAYS SO INSTEAD OF LINKING.
- The download lane's metadata-scan unit is channel-scoped:
- its `videoId` is a synthetic key naming no directory, so the
- usual link would be a 404 and the usual "rule N" would name
- a leaf that exists in no tree. It carries a `note`, and a
- unit with one renders the note — see AutoRunnerInFlight. */}
- {item.note ? (
- <>
- <Link
- href={`/channels/${item.channelSlug}`}
- className="font-mono text-foreground underline underline-offset-2 hover:text-brand"
- >
- {item.channelSlug}
- </Link>
- <span className="text-xs text-muted-foreground">
- {item.note}
- </span>
- </>
- ) : (
- <>
- <Link
- href={`/channels/${item.channelSlug}/videos/${item.videoId}`}
- className="font-mono text-foreground underline underline-offset-2 hover:text-brand"
- >
- {item.channelSlug}/{item.videoId}
- </Link>
- <span className="text-xs text-muted-foreground">
- {at >= 0 ? `rule ${at + 1}` : item.leafId}
- {leaf ? ` · ${leafSentence(leaf, channels)}` : ""}
- </span>
- </>
- )}
- <span className="ml-auto tabular-nums text-xs text-muted-foreground">
- {now !== null ? formatElapsed(now - item.startedAt) : "—"}
- </span>
</li>
);
})}
diff --git a/editor/app/operations/components/dispatch.ts b/editor/app/operations/components/dispatch.ts
@@ -185,17 +185,8 @@ export function formatClock(ms: number): string {
// Compact elapsed time: 42s, 3m21s, 14m, 2h06m. Used for uptime and per-unit
// age, where a full duration string would swamp the line it sits on.
-export function formatElapsed(ms: number): string {
- const s = Math.max(0, Math.floor(ms / 1000));
- if (s < 60) return `${s}s`;
- const m = Math.floor(s / 60);
- if (m < 60) {
- const rem = s % 60;
- return rem === 0 ? `${m}m` : `${m}m${String(rem).padStart(2, "0")}s`;
- }
- const h = Math.floor(m / 60);
- return `${h}h${String(m % 60).padStart(2, "0")}m`;
-}
+// Moved to lib/formatElapsed.ts, where the job row reads it too.
+export { formatElapsed } from "../../lib/formatElapsed";
// A recency key as a date, marked when it was estimated rather than read.
export function formatRecency(
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -10,10 +10,8 @@ import {
siteChannelSlugs,
} from "yt-dlp-transcript-common/lib/site";
import {
- actionableDigestReachableCount,
- actionableUndownloadedCount,
- actionableUntranscribedCount,
getActionableSummary,
+ widgetActionableRows,
} from "./lib/actionable/loadActionable";
import { resolveActiveSite } from "./lib/activeSite";
import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs";
@@ -21,8 +19,13 @@ import { buildWorkersPayload } from "./workers/buildWorkers";
import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
import { widgetSyncInputs } from "./widget/lib/syncInputs";
import { DashboardCockpit } from "./components/dashboard/DashboardCockpit";
-import type { DashboardChannel } from "./components/dashboard/types";
-import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
+import {
+ buildChannelRowView,
+ channelVolumeOf,
+ neutralChannelPriority,
+ type ChannelRowView,
+} from "yt-dlp-transcript-common/views/channelRow";
+import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type { ActionableRow } from "./lib/actionable/loadActionable";
export const dynamic = "force-dynamic";
@@ -43,12 +46,6 @@ function loadChangelog(): string | null {
}
}
-function toMs(iso: string | undefined): number | null {
- if (!iso) return null;
- const n = Date.parse(iso);
- return Number.isNaN(n) ? null : n;
-}
-
export default async function Dashboard({
searchParams,
}: {
@@ -87,49 +84,35 @@ export default async function Dashboard({
);
// Read once for the whole table, not once per row.
const locations = getSettings().storage.locations;
- const channels: DashboardChannel[] = rows.map((r) => ({
- slug: r.channel.slug,
- handling: r.channel.config.handling,
- // From the snapshot rather than a corpus walk, so it lags a sync by the
- // snapshot scheduler's debounce.
- videoCount: r.snapshot?.totals.videos ?? 0,
- lastSyncedAt: toMs(r.channel.config.lastSyncedAt),
- hasUrl: Boolean(r.channel.config.url),
- undownloaded: actionableUndownloadedCount(r),
- untranscribed: actionableUntranscribedCount(r),
- digestReachable: actionableDigestReachableCount(r),
- media: (() => {
- const m = mediaBySlug.get(r.channel.slug);
- return m && m.status !== "in-place"
- ? {
- status: m.status,
- target: m.target,
- detail: m.detail,
- // The named location, matched against the configured roots here
- // because the settings are here and the table is a client
- // component. A pure prefix match, never a probe.
- locationLabel: locationLabelOfDataDir(m.target, locations),
- }
- : null;
- })(),
- }));
+ // The shared channel row (common/views/channelRow.ts), drawn by the shared
+ // table with the dashboard's six columns. No pipeline bands and a neutral
+ // priority: this table draws neither.
+ const channels: ChannelRowView[] = rows.map((r) => {
+ const media = mediaBySlug.get(r.channel.slug) ?? null;
+ return buildChannelRowView({
+ slug: r.channel.slug,
+ config: r.channel.config,
+ // From the snapshot rather than a corpus walk, so the Videos figure lags
+ // a sync by the snapshot scheduler's debounce.
+ snapshot: r.snapshot,
+ playlistCount: null,
+ bands: [],
+ priority: neutralChannelPriority(),
+ media,
+ volume: channelVolumeOf(r.channel.config.dataDir, locations),
+ // The named location, matched against the configured roots from the
+ // media TARGET, as this table always has. A pure prefix match, never a
+ // probe.
+ mediaLocationLabel: media
+ ? locationLabelOfDataDir(media.target, locations)
+ : undefined,
+ });
+ });
- // "Needs work" payload, same shape the widget endpoint the cockpit polls
- // returns, scoped to the active site for the SSR seed.
- const actionable: WidgetActionablePayload = {
- channels: channels
- .filter((c) => c.undownloaded > 0 || c.untranscribed > 0)
- .map((c) => ({
- slug: c.slug,
- undownloaded: c.undownloaded,
- untranscribed: c.untranscribed,
- digestReachable: c.digestReachable,
- }))
- .sort(
- (a, b) =>
- b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed),
- ),
- };
+ // "Needs work" payload, same shape (and the same two functions) the
+ // /api/view/widgetActionable handler the cockpit polls serves, scoped to the
+ // active site for the SSR seed.
+ const actionable = buildWidgetActionablePayload(widgetActionableRows(rows));
const [jobs, sync] = await Promise.all([
buildActiveJobsPayload(),
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -1,7 +1,7 @@
"use client";
import { Fragment, useState, type CSSProperties, type ReactNode } from "react";
-import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import { usePolledPayload, useNow } from "../../lib/usePolledPayload";
import { fmtTime } from "../lib/relativeTime";
import type {
@@ -9,11 +9,7 @@ import type {
DiskStatusView,
} from "yt-dlp-transcript-common/views/activeJobs";
import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
-import type {
- JobProgressMetric,
- JobTaskKind,
-} from "yt-dlp-transcript-common/jobs/registry";
-import { jobKindLabel } from "../../jobs/jobKindLabels";
+import { JobRow } from "../../jobs/components/JobRow";
import type { WorkersPayload, WorkerView } from "yt-dlp-transcript-common/views/workers";
import { InlineActionButton } from "../../components/actions/InlineActionButton";
import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
@@ -1017,10 +1013,15 @@ function ActiveJobsStrip({
<li key={j.id}>
<JobRow
job={j}
- compact={compact}
- jobBar={jobBar}
- headingProgress={headingProgress}
- eta={eta}
+ variant="compact"
+ show={{
+ tasks: !compact,
+ jobBar,
+ headingProgress,
+ eta,
+ actions: false,
+ links: false,
+ }}
/>
</li>
))}
@@ -1030,226 +1031,6 @@ function ActiveJobsStrip({
);
}
-function JobRow({
- job,
- compact,
- jobBar,
- headingProgress,
- eta,
-}: {
- job: JobRowView;
- compact: boolean;
- jobBar: boolean;
- headingProgress: boolean;
- eta: boolean;
-}) {
- const statusClass =
- job.status === "running"
- ? "bg-info-soft text-info"
- : "bg-muted text-muted-foreground";
- return (
- <div className="flex flex-col gap-1">
- <div className="flex flex-wrap items-center gap-1.5 text-xs">
- <span
- className={`uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusClass}`}
- >
- {job.status}
- </span>
- <span className="font-mono" title={job.kind}>
- {jobKindLabel(job.kind)}
- </span>
- {job.background && (
- <span
- className="uppercase tracking-wide px-1 py-0.5 rounded text-[9px] bg-warning-soft text-warning"
- title="Background work — yields to a manual sync/download"
- >
- auto
- </span>
- )}
- {job.channelSlug && (
- <span className="font-mono text-muted-foreground truncate max-w-[12rem]">
- {job.channelSlug}
- </span>
- )}
- {headingProgress && job.progress && (
- <span className="font-mono text-muted-foreground shrink-0">
- {jobProgressText(job.progress, eta)}
- </span>
- )}
- </div>
- {jobBar && job.progress && (
- <JobProgressBar progress={job.progress} showEta={eta} />
- )}
- {!compact &&
- job.tasks &&
- job.tasks.length > 0 &&
- job.tasks.map((t) => <TaskBar key={t.id} task={t} />)}
- </div>
- );
-}
-
-// Per-task-kind glyph/fill, as Records over JobTaskKind so a new kind is a
-// compile error rather than silently rendering as a transcription.
-const TASK_KIND_VERB: Record<JobTaskKind, string> = {
- download: "\u2193",
- transcribe: "\u270e",
- digest: "\u00b6",
- backfill: "\u21ba",
- // A right arrow: a relocate task is a channel's media moving to another disk.
- relocate: "\u21e2",
-};
-
-const TASK_KIND_FILL: Record<JobTaskKind, string> = {
- download: "bg-success/60",
- transcribe: "bg-success",
- digest: "bg-info",
- backfill: "bg-warning",
- relocate: "bg-info/70",
-};
-
-// Per-metric glyph for the compact widget line. A Record over JobProgressMetric
-// so a new metric is a compile error, not a mislabelled bar (the two copies of
-// this ternary previously had to be kept in sync by hand).
-const METRIC_PREFIX: Record<JobProgressMetric, string> = {
- downloads: "\u2193 ",
- transcripts: "",
- digests: "\u00b6 ",
- backfills: "\u21ba ",
- scans: "\u2315 ",
-};
-
-// One-line textual summary of a job's batch progress, e.g. "↓ 5/10 · ~2m left".
-// Shared by the job heading (headingProgress) and the job bar's caption so the
-// label/ETA formatting stays in one place.
-function jobProgressText(
- progress: NonNullable<JobRowView["progress"]>,
- showEta: boolean,
-): string {
- const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`;
- const remaining = progress.target - progress.current;
- const etaText =
- !showEta || remaining <= 0
- ? null
- : typeof progress.etaSeconds === "number"
- ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left`
- : "…";
- return etaText ? `${label} · ${etaText}` : label;
-}
-
-function JobProgressBar({
- progress,
- showEta,
-}: {
- progress: NonNullable<JobRowView["progress"]>;
- showEta: boolean;
-}) {
- const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`;
- const remaining = progress.target - progress.current;
- const etaText =
- !showEta || remaining <= 0
- ? null
- : typeof progress.etaSeconds === "number"
- ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left`
- : "…";
- return (
- <div className="flex flex-col gap-0.5">
- <div
- role="progressbar"
- aria-valuemin={progress.initial}
- aria-valuemax={progress.target}
- aria-valuenow={progress.current}
- aria-label={label}
- className="relative h-1.5 w-full overflow-hidden rounded bg-muted"
- >
- <div
- className="absolute inset-y-0 left-0 bg-success"
- style={{ width: `${progress.pct}%` }}
- />
- </div>
- <div className="flex items-baseline justify-between gap-2 text-[11px] text-muted-foreground">
- <span>{label}</span>
- {etaText && <span className="shrink-0">{etaText}</span>}
- </div>
- </div>
- );
-}
-
-function TaskBar({
- task,
-}: {
- task: NonNullable<JobRowView["tasks"]>[number];
-}) {
- const now = useNow();
- const probing = task.phase === "probing";
- const verb = probing ? "🔍" : TASK_KIND_VERB[task.kind];
- // While probing, fill against the estimated probe duration (violet "scanning"
- // bar) rather than the frozen download fraction. See TaskProgressBar.
- const probeFraction =
- probing &&
- now !== null &&
- typeof task.probeStartedAt === "number" &&
- typeof task.probeEtaSeconds === "number" &&
- task.probeEtaSeconds > 0
- ? Math.max(
- 0,
- Math.min(1, (now - task.probeStartedAt) / (task.probeEtaSeconds * 1000)),
- )
- : undefined;
- const hasFraction = probing
- ? typeof probeFraction === "number"
- : typeof task.fraction === "number";
- const fraction = probing ? probeFraction : task.fraction;
- const pct = hasFraction ? Math.round((fraction as number) * 100) : 0;
- const fillClass = probing
- ? "bg-violet-400 dark:bg-violet-500"
- : TASK_KIND_FILL[task.kind];
- const pulseClass = probing
- ? "bg-violet-400 dark:bg-violet-500"
- : "bg-warning";
- const elapsed =
- now === null
- ? null
- : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) ||
- "0:00";
- const meta = [
- elapsed,
- hasFraction ? `${pct}%` : null,
- probing ? "probing" : task.detail,
- ]
- .filter(Boolean)
- .join(" · ");
- return (
- <div className="flex flex-col gap-0.5 pl-2 border-l-2 border-border">
- <div className="flex items-baseline justify-between gap-2 text-[11px]">
- <span className="truncate">
- <span className="text-muted-foreground">{verb} </span>
- <span className="font-mono">{task.label}</span>
- </span>
- <span className="font-mono text-muted-foreground shrink-0">{meta}</span>
- </div>
- <div
- role="progressbar"
- aria-valuenow={hasFraction ? pct : undefined}
- aria-label={`${task.label}`}
- className="relative h-1 w-full overflow-hidden rounded bg-muted"
- >
- {hasFraction ? (
- <div
- className={`absolute inset-y-0 left-0 ${fillClass}`}
- style={{ width: `${pct}%` }}
- />
- ) : (
- // No fraction yet (just started, an engine that reports no %, or the
- // first audio probe): a full-width pulse reads as "working, no
- // progress number" — distinct from the emerald fill so it can't be
- // mistaken for ~33%.
- <div className={`absolute inset-0 animate-pulse ${pulseClass}`} />
- )}
- </div>
- </div>
- );
-}
-
// The settings gear and its in-place config overlay. The gear anchors to the
// widget's own (relative) container; the overlay covers the whole widget window
// (fixed inset-0) and hosts the same form the builder uses, so a pinned widget
diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md
@@ -832,3 +832,88 @@ docs) at `5b43dc8d`, **1650** after `81deae69` (+2 docs wiring tests); `test:scr
diarization, attribution, scheduler, cadence-ui, storage-locations, channel-storage, workers,
worker-remote, parakeet, parakeet-partial, chough, transcription-app-migration, disk-space,
channel-priority, settings — **157/157 passed, exit 0, 9.9 min**, first run, nothing re-run.
+
+## Slice 3a, as shipped — one drawing per noun (2026-09-24)
+
+Branch `one-core/phase-3-s3a` off `1e27f7c3`, unmerged; ten commits (the eight below,
+this record `b4890c78`, and the review fixes `001f91bc`).
+
+| sha | what |
+|---|---|
+| `ab7a4739` | `common/views/channelRow.ts` (`ChannelRowView`, `buildChannelRowView`, `reportStateOf` moved, `ChannelRowPriority`, `ChannelRowMedia`, `channelVolumeOf`) + `actionableCounts.ts`, both tested; `loadActionable` count helpers become wrappers |
+| `155efeb9` | rack split: `ChannelsRack.tsx` (chrome) + shared `ChannelsTable.tsx`; `channelColumns.tsx` registry; `/channels` builds rows with the builder |
+| `01f08670` | dashboard on the shared table; "Needs work" seed = `buildWidgetActionablePayload(widgetActionableRows(rows))`; `PrioritizeButton` moved; `dashboard/ChannelsTable.tsx` + `types.ts` deleted |
+| `f63260d8` | `ChannelWorkTable` = server shell over the shared table; `Row` and `isStaleOrMissing` deleted |
+| `0d20c42a` | `jobs/components/JobRow.tsx` (table / card / compact) + `JobRowActions` + `JobRowHeading`; JobsTable, RunningJobsList, LaneStrip on them; compact bars; `lib/formatElapsed.ts` |
+| `797078c2` | `AutoRunnerInFlight.jobId` (type + the two `onChildJob` callbacks); `fromInFlight` + `source: "runner"`; InFlightList on `JobRow compact` |
+| `4a784486` | widget `ActiveJobsStrip` on `JobRow compact`; its private row, bars, glyph tables deleted |
+| `c944475f` | fix: column ids/presets move to plain `channelColumnPresets.ts` (see deviation 1) |
+
+**Gates.** tsc clean after every commit. common **1677** (1663 + 5 actionableCounts + 6
+channelRow + 3 fromInFlight); editor unit **67**; `test:scripts` 156 + 1 skip. **e2e**, one
+detached run from the worktree root, 41 spec files (both lists; every named file exists):
+**280 passed, 0 failed, 0 skipped, 0 flaky, exit 0, 14.7 min** — the second run; the first
+was stopped in its first minute on the bug `c944475f` fixes. **Numbers**
+(`phase3-view-numbers.ts`, primary's `transcripts/` + `settings.json`, read-only): main
+`1e27f7c3` (a clean detached checkout) and the branch at `4a784486`, back to back — **diff
+empty, 5,925 bytes each**. The `autoQueueStatus` view gains the optional per-unit `jobId`;
+the numbers tool does not cover that view.
+
+Deviations:
+1. **A column registry by id, not column objects.** A server shell cannot pass `cell: (row) =>
+ ReactNode`. And a server component that imports a VALUE from a `"use client"` module gets
+ a client reference, not the value (`WORK_COLUMNS.filter is not a function` on every
+ operation page — invisible to tsc), so the ids, sort keys, `PipelineColumn` and presets
+ live in the directive-free `channelColumnPresets.ts`; the cells stay in the client registry.
+2. **The rack split** into `ChannelsRack` (focus / volume / instrument bars, scroll region +
+ `--thead-h`, selection + free-up, the deck outside the scroll box) and the shared table;
+ group-header `colSpan` is counted from the drawn columns.
+3. **LaneStrip keeps its lane line**; its runner job's buttons are `JobRowActions`, so it now
+ also offers Force-release while that job runs, and Cancel only while running/queued.
+4. **Runner `jobId`**: three lines in `autoRunner.ts`; the private `childJobIds` map stays
+ private. A `source: "runner"` row links `Job <id>` only when `inRegistry`.
+5. `formatElapsed` went to `editor/app/lib/formatElapsed.ts` (dispatch.ts re-exports), not
+ `common/lib/format.ts`: that file was the parallel slice's, and it is not `formatDuration`.
+6. `channelVolumeOf` (the page's `volumeOf`) lives in `channelRow.ts`; the builder takes an
+ optional `mediaLocationLabel` so the dashboard still names the badge's location from the
+ media target.
+7. Work tables use the rack's report cell (`report age for <slug>`, "stale"/"missing");
+ an unset last sync still reads "never". Actions are left-aligned everywhere.
+8. The dashboard's Sync is `ChannelSyncButton` (same `sync <slug>`, same `role="alert"`).
+9. Widget job lines now show `detail`, as every other job row does.
+10. Group sections still carry `ChannelStat` (config included) to the client — pre-existing,
+ untouched.
+11. Commits 1–7 and the fix carry an Opus 5.5 trailer, not the header's; left as they are.
+12. **Card actions follow the table's rule** (`JobRowActions`): a RunningJobsList card now
+ offers Retry on a failed replayable job, Force-release on a stuck one, no Cancel on a
+ finished one, and its status pill takes `statusColor` (done/failed coloured, not muted).
+13. **Dashboard cosmetics**: the handling cell is the rack's (`font-mono text-[11px]` muted);
+ the slug link lost `underline-offset-2 hover:text-brand`; rows lost `align-top`; Sync is
+ `ChannelSyncButton`, which drains its stream and reads "job …" / "done (job …)" where the
+ old inline button cancelled the stream and said "queued".
+
+**Builds** (at `b4890c78`): editor `next build` exit 0, route table lists
+`ƒ /api/view/[name]`; export `next build` exit 0.
+
+**Bisect hazard.** From `f63260d8` up to (not including) `c944475f`, every operation page
+throws at render (`WORK_COLUMNS.filter is not a function`). History is not rewritten; skip
+that range when bisecting.
+
+**Review fixes (`001f91bc`), from the slice review, all eight in one commit:**
+1. Dim only in the rack (`sticky`): the dashboard and work tables never dimmed, and a
+ build-excluded channel in a work list is not out of any pipeline.
+2. /channels projects the group sections to `{slug}` (`ChannelGroupSectionView`, in
+ `common/views/channelGroupSections.ts`) before they reach the client — deviation 10 is
+ resolved: no ChannelConfig reaches the browser from /channels.
+3. Work tables' report cell: "stale · <stamp>" / "missing", stamp in `title` (the rack's
+ is unchanged) — deviation 7 no longer loses the date.
+4. `suppressHydrationWarning` through `Td` on the date cells.
+5. Compact JobRow: one-line heading, truncated `detail` with the full text in `title`
+ (deviation 9's overflow).
+6. InFlightList: each unit's real job kind (`auto-download-unit`, `auto-transcribe`,
+ `auto-digest`, `auto-backfill`, a `Record<AutoQueueKind, string>` mirroring
+ autoRunner's, which a client cannot import) and no status pill (`show.statusPill`);
+ `fromInFlight` keeps `status: "running"`.
+7. Lane line: Force-release only when the runner job is stuck (deviation 3 narrowed).
+8. Dead exports removed (loadActionable's `reportStateOf` re-export, function-form column
+ labels + `ChannelHeadCtx`, JobRow's internal helpers).