commit 9566528f8da7430cce110ec264caac2bd841aed3 parent 2164c33e1b3f6f69253f91390316e344ecb9ecef Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Mon, 14 Sep 2026 16:52:54 -0400 views: laneState, jobRows, channelGroupSections, builtInPresets One-core phase 3 slice 1, sub-slice A3 — the second and last batch of pure renames. `git mv` plus import rewrites; no logic changed. - `laneState` (lane word/dot/text from a lane's counts) and its 4 tests - `jobRowView` (the row TYPE, imported by four `"use client"` files) and `jobRows` (merge, sort, stuck-detection) with its 13 tests - `channelGroupSections` (the /channels grouping) with its 16 tests - `builtInPresets` (the widget's shipped presets) `laneState` sits at the top of views/ and not beside the job payloads it describes, and that placement is load-bearing: `deriveLaneState` is value-imported by the `"use client"` `LaneDeck.tsx`, while the activeJobs builder that will land next to it in sub-slice B value-imports `controller/autoRunner` → `node:path`. One noun, one file, or the client bundle dies. `builtInPresets.test.ts` stays in the editor: it imports `./config`, which is widget URL plumbing and not a view. It now imports the presets by package name — the one test in this batch that reaches across rather than moving. Same rewrite as A2 for the same reason: every `yt-dlp-transcript-common/…` import inside a moved file becomes relative, or the layer guard cannot see the edge. `laneState` is allowed exactly one import and has exactly one, `./pipeline/tone`. `channelGroupSections` keeps `normalizeBuckets`, now `./pipeline/stageStatus`. Twenty-two editor files repointed, `buildActiveJobs.ts` among them — that file is otherwise sub-slice B's, and this is only its import block moving. Editor unit tests 92 → 59 as the 33 that moved land in common's suite, which goes 1191 → 1224. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Diffstat:
36 files changed, 1602 insertions(+), 1602 deletions(-)
diff --git a/editor/app/widget/lib/builtInPresets.ts b/common/views/builtInPresets.ts diff --git a/common/views/channelGroupSections.test.ts b/common/views/channelGroupSections.test.ts @@ -0,0 +1,408 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { + ChannelBrief, + ChannelStat, +} from "../controller/channels"; +import type { ChannelConfig } from "../lib/channelConfig"; +import type { ChannelSnapshot } from "../controller/channelSnapshot"; +import type { OperationSnapshotEntry } from "../lib/operations"; +import type { SiteSettings } from "../lib/settings"; +import type { Site } from "../lib/site"; +import { normalizeBuckets } from "./pipeline/stageStatus"; +import { + buildChannelGroupSections, + slugsInGroup, + type ChannelGroupSection, +} from "./channelGroupSections"; + +// Run from this directory: +// cd editor/app/channels/lib && ../../../../node_modules/.bin/tsx --test channelGroupSections.test.ts + +// Snapshot shape as WRITTEN TO DISK — same trick as channelFlow.test.ts. +function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot { + return { + generatedAt: "2026-08-01T00:00:00.000Z", + totals: { videos: 100, transcribed: 40, downloaded: 60 }, + buckets: normalizeBuckets(undefined), + undownloadedIds: [], + ...patch, + }; +} + +function entry(patch: Partial<OperationSnapshotEntry>): OperationSnapshotEntry { + return { + ids: [], + missing: 0, + stale: 0, + missingInput: 0, + ...patch, + } as OperationSnapshotEntry; +} + +function channel( + slug: string, + config: Partial<ChannelConfig> = {}, + snapshot: ChannelSnapshot | null = snapshotOf(), +): { stat: ChannelStat; brief: ChannelBrief } { + const merged: ChannelConfig = { + handling: "transcribe", + url: `https://example.test/${slug}`, + ...config, + }; + return { + stat: { + slug, + config: merged, + playlistCount: 0, + videoCount: 0, + transcriptCount: 0, + downloadCount: 0, + }, + brief: { slug, config: merged, snapshot }, + }; +} + +function siteOf(patch: Partial<Site> = {}): Site { + return { + siteId: "alpha", + siteTitle: "Alpha", + siteDescription: "", + headerTitle: "Alpha", + homeTagline: "", + groups: [], + defaultGroupId: "default", + channels: [], + ...patch, + }; +} + +// backfillLaneOperations / allOperations only read these two branches, so a +// partial cast exercises the real registry predicates. +function settingsOf(opts: { + diarization?: boolean; + attribution?: boolean; +}): SiteSettings { + return { + diarization: { + enabled: opts.diarization === true, + segModel: "seg.onnx", + embModel: "emb.onnx", + }, + attribution: { + enabled: opts.attribution === true, + textOnlyEnabled: opts.attribution === true, + diarizedEnabled: opts.attribution === true, + }, + } as unknown as SiteSettings; +} + +const LANE_ON = settingsOf({ diarization: true }); +const LANE_OFF = settingsOf({}); + +function build( + site: Site, + channels: { stat: ChannelStat; brief: ChannelBrief }[], + settings: SiteSettings = LANE_ON, +): ChannelGroupSection[] { + return buildChannelGroupSections( + site, + channels.map((c) => c.stat), + channels.map((c) => c.brief), + settings, + ); +} + +const GROUPS = [ + { id: "extended", name: "Extended Universe", selectedByDefault: false, order: 2 }, + { id: "archives", name: "Archives", selectedByDefault: true, order: 1 }, +]; + +test("groups render in sortGroups order, and empty groups are omitted", () => { + const a = channel("arch-a"); + const b = channel("ext-b"); + const sections = build( + siteOf({ + groups: [ + ...GROUPS, + { id: "guests", name: "Guest Appearances", selectedByDefault: true, order: 3 }, + ], + defaultGroupId: "archives", + channels: [ + { slug: "arch-a", groupId: "archives" }, + { slug: "ext-b", groupId: "extended" }, + ], + }), + [a, b], + ); + assert.deepEqual( + sections.map((s) => s.group.id), + ["archives", "extended"], + ); + assert.deepEqual(sections[0].channels.map((c) => c.slug), ["arch-a"]); +}); + +test("an unknown groupId folds onto the site's default group", () => { + const sections = build( + siteOf({ + groups: GROUPS, + defaultGroupId: "archives", + channels: [ + { slug: "stray", groupId: "no-such-group" }, + { slug: "ext-b", groupId: "extended" }, + ], + }), + [channel("stray"), channel("ext-b")], + ); + assert.deepEqual( + sections.map((s) => s.group.id), + ["archives", "extended"], + ); + assert.deepEqual(sections[0].channels.map((c) => c.slug), ["stray"]); +}); + +test("a site with no configured groups gets the one fallback section", () => { + const sections = build( + siteOf({ channels: [{ slug: "only" }] }), + [channel("only")], + ); + assert.equal(sections.length, 1); + assert.equal(sections[0].group.id, "default"); + assert.equal(sections[0].group.name, "All channels"); +}); + +test("within a group, membership order wins and absent order sorts last", () => { + const sections = build( + siteOf({ + channels: [ + { slug: "no-order" }, + { slug: "second", order: 2 }, + { slug: "first", order: 1 }, + ], + }), + [channel("no-order"), channel("second"), channel("first")], + ); + assert.deepEqual(sections[0].channels.map((c) => c.slug), [ + "first", + "second", + "no-order", + ]); +}); + +test("a channel with no snapshot lands in `unknown`, so the total is a floor", () => { + const sections = build( + siteOf({ channels: [{ slug: "reported" }, { slug: "silent" }] }), + [ + channel("reported", {}, snapshotOf({ undownloadedIds: ["v1", "v2"] })), + channel("silent", {}, null), + ], + ); + const { download } = sections[0]; + assert.deepEqual(download.eligible.sort(), ["reported", "silent"]); + assert.equal(download.total, 2); + assert.deepEqual(download.unknown, ["silent"]); +}); + +test("download excludes members-only/deleted/private ids", () => { + const sections = build( + siteOf({ channels: [{ slug: "c" }] }), + [ + channel( + "c", + {}, + snapshotOf({ + undownloadedIds: ["keep", "gone"], + excludedFromDownload: { + membersOnly: [], + deleted: ["gone"], + private: [], + }, + }), + ), + ], + ); + assert.equal(sections[0].download.total, 1); +}); + +test("a youtube-handling channel is not transcribe-eligible but is download-eligible", () => { + const sections = build( + siteOf({ channels: [{ slug: "yt" }, { slug: "whisper" }] }), + [ + channel("yt", { handling: "youtube" }, snapshotOf({ + undownloadedIds: ["a"], + buckets: normalizeBuckets({ downloadedNoTranscript: ["x", "y"] }), + })), + channel("whisper", { handling: "transcribe" }, snapshotOf({ + undownloadedIds: ["b"], + buckets: normalizeBuckets({ downloadedNoTranscript: ["z"] }), + })), + ], + ); + const { download, transcribe } = sections[0]; + assert.deepEqual(download.eligible.sort(), ["whisper", "yt"]); + assert.equal(download.total, 2); + assert.deepEqual(transcribe.eligible, ["whisper"]); + // The youtube channel's two awaiting-whisper videos are NOT in the figure — + // whisper will never run on them. + assert.equal(transcribe.total, 1); +}); + +test("a social channel is eligible for sync only", () => { + const sections = build( + siteOf({ channels: [{ slug: "posts" }] }), + [channel("posts", { sourceKind: "social", handling: "transcribe" })], + ); + const s = sections[0]; + assert.deepEqual(s.sync.eligible, ["posts"]); + assert.deepEqual(s.download.eligible, []); + assert.deepEqual(s.transcribe.eligible, []); + assert.deepEqual(s.digest.eligible, []); + assert.deepEqual(s.speakers.eligible, []); +}); + +test("sync skips a channel with no url", () => { + const sections = build( + siteOf({ channels: [{ slug: "ok" }, { slug: "nourl" }] }), + [channel("ok"), channel("nourl", { url: undefined })], + ); + assert.deepEqual(sections[0].sync.eligible, ["ok"]); + // Download needs a url too. + assert.deepEqual(sections[0].download.eligible.sort(), ["ok"]); +}); + +// The priority document answers the same question the flag does, for the `sync` +// operation specifically — which is what makes the migration lossless: a channel +// paused for sync alone is still drawn by every other station. +test("sync skips a channel paused for the sync operation, and only sync does", () => { + const settings = { + ...LANE_ON, + channelPriority: { + focus: { kind: "none" as const }, + channels: { + pinned: { tier: "normal" as const, overrides: { sync: "paused" as const } }, + off: { tier: "paused" as const }, + }, + }, + } as SiteSettings; + const sections = build( + siteOf({ + channels: [{ slug: "ok" }, { slug: "pinned" }, { slug: "off" }], + }), + [channel("ok"), channel("pinned"), channel("off")], + settings, + ); + assert.deepEqual(sections[0].sync.eligible, ["ok"]); + // A `sync` pin moves nothing else: both are still download candidates. + assert.deepEqual(sections[0].download.eligible.sort(), ["off", "ok", "pinned"]); +}); + +test("speakers reports `off`, not 0, when no operation on the backfill lane is enabled", () => { + const withWork = channel( + "c", + {}, + snapshotOf({ + backfill: { diarization: entry({ missing: 7 }) }, + }), + ); + const on = build(siteOf({ channels: [{ slug: "c" }] }), [withWork], LANE_ON); + assert.equal(on[0].speakers.laneOff, undefined); + assert.equal(on[0].speakers.total, 7); + + const off = build(siteOf({ channels: [{ slug: "c" }] }), [withWork], LANE_OFF); + // The snapshot still records 7 — an off lane is not a finished one, and the + // UI must be able to tell the difference. + assert.equal(off[0].speakers.laneOff, true); + assert.equal(off[0].speakers.total, 0); + assert.deepEqual(off[0].speakers.eligible, []); +}); + +test("digest and speakers totals are disjoint", () => { + const sections = build( + siteOf({ channels: [{ slug: "c" }] }), + [ + channel( + "c", + {}, + snapshotOf({ + backfill: { + // The per-kind map carries EVERY catalog operation, digest included. + digest: entry({ missing: 1200, stale: 4 }), + diarization: entry({ missing: 80, partial: 8 }), + }, + }), + ), + ], + ); + assert.equal(sections[0].digest.total, 1204); + assert.equal(sections[0].speakers.total, 88); +}); + +test("digest counts reachable work from the registry entry", () => { + const sections = build( + siteOf({ channels: [{ slug: "some-work" }] }), + [ + channel( + "some-work", + {}, + // The per-kind entry is the work list. `blocked` and `deferred` are work + // the lane cannot do today and are deliberately not in the total. + snapshotOf({ + backfill: { + digest: entry({ + missing: 2, + stale: 1, + blocked: 4, + deferred: 5, + ids: ["a", "b", "c"], + eligible: 3, + }), + }, + }), + ), + ], + ); + assert.equal(sections[0].digest.total, 3); +}); + +test("digest skips a channel whose report says it has no transcripts", () => { + const sections = build( + siteOf({ channels: [{ slug: "none" }, { slug: "some" }, { slug: "silent" }] }), + [ + channel("none", {}, snapshotOf({ + totals: { videos: 10, transcribed: 0, downloaded: 10 }, + })), + channel("some", {}, snapshotOf({ + totals: { videos: 10, transcribed: 5, downloaded: 10 }, + backfill: { digest: entry({ missing: 5 }) }, + })), + // No report at all cannot claim "no transcripts" — it stays eligible. + channel("silent", {}, null), + ], + ); + const { digest } = sections[0]; + assert.deepEqual(digest.eligible.sort(), ["silent", "some"]); + assert.equal(digest.total, 5); + assert.deepEqual(digest.unknown, ["silent"]); +}); + +test("slugsInGroup buckets the same way the sections do", () => { + const site = siteOf({ + groups: GROUPS, + defaultGroupId: "archives", + channels: [ + { slug: "a", groupId: "archives" }, + { slug: "b", groupId: "extended" }, + { slug: "c", groupId: "no-such-group" }, + { slug: "d" }, + ], + }); + assert.deepEqual(slugsInGroup(site, "archives"), ["a", "c", "d"]); + assert.deepEqual(slugsInGroup(site, "extended"), ["b"]); + assert.deepEqual(slugsInGroup(site, "guests"), []); +}); + +test("slugsInGroup uses the fallback id when the site configures no groups", () => { + const site = siteOf({ channels: [{ slug: "a" }, { slug: "b" }] }); + assert.deepEqual(slugsInGroup(site, "default"), ["a", "b"]); + assert.deepEqual(slugsInGroup(site, "archives"), []); +}); diff --git a/common/views/channelGroupSections.ts b/common/views/channelGroupSections.ts @@ -0,0 +1,321 @@ +import type { ChannelBrief, ChannelStat } from "../controller/channels"; +import { isSocialChannel } from "../lib/channelConfig"; +import { + FALLBACK_GROUP, + resolveChannelGroupId, + sortGroups, + type ChannelGroup, +} from "../lib/channelGroups"; +import { + digestWorkOf, + excludedDownloadIdSet, +} from "../controller/channelSnapshot"; +import { + DIGEST_OPERATION_ID, + allOperations, + backfillLaneOperations, + backfillLaneEntriesOf, + operationsGroupLabel, + reachableOperationWork, +} from "../lib/operations"; +import { + defaultChannelPriority, + isChannelPaused, +} from "../lib/channelPriority"; +import type { SiteSettings } from "../lib/settings"; +import type { Site } from "../lib/site"; +import { normalizeBuckets } from "./pipeline/stageStatus"; + +// Groups a site's channels into the sections /channels renders, and totals each +// section's pipeline work off the SAME snapshot readers the channel page's +// transit line uses — so a group figure can never disagree with the per-channel +// one. +// +// No "use server", no node:fs: imported by both the page (server) and the group +// actions, and unit-tested with plain tsx --test. The actions reuse +// stationWorkFor below, which is what keeps a button from acting on a different +// set than the number printed on it. + +// "speakers" is the backfill LANE's station: every operation sharing +// BACKFILL_QUEUE, run over a group by the lane runner. The id names what the +// station is about (the operations), not the queue — the queue key and the job +// kind behind it still say backfill, on purpose. +export type StationId = "sync" | "download" | "transcribe" | "digest" | "speakers"; + +export type StationWork = { + // Slugs this operation applies to at all. A channel whose report says it has + // nothing to do IS in here — it is skipped at click time, where the read is + // fresh, and dropping it here would make the "+" floor unreadable. + eligible: string[]; + // The sum of ONE reader across `eligible`. + // + // NEVER summed with another station's total. Five separate figures on five + // separate controls, for the reason channelSnapshot.ts documents at length: a + // single "remaining" number puts every channel permanently at the top of + // every list. + total: number; + // Eligible channels with no snapshot at all. `total` is a FLOOR while this is + // non-empty, which the UI marks with a trailing "+". + unknown: string[]; + // digest/speakers only: the lane is switched off in settings. A snapshot's + // counts outlive the feature being switched off, so "off" is rendered instead + // of 0 (which reads as finished) or — (which reads as unknown). + laneOff?: boolean; +}; + +export type ChannelGroupSection = { + group: ChannelGroup; + // Membership order asc (absent sorts last), then slug. + channels: ChannelStat[]; + sync: StationWork; + download: StationWork; + transcribe: StationWork; + digest: StationWork; + speakers: StationWork; + // What the speakers station is called, derived from the operations enabled on + // the backfill lane exactly as the channel page's stage title is — "Speakers" + // once a speaker operation is on, "Derived data" when none is (the default + // test settings), so the button never claims work its lane is not doing. + speakersLabel: string; +}; + +// Whether a whole station is unrunnable because its lane is switched off. +// +// The digest kind declares `enabled: () => true` — it has no master switch, and +// its pause is honoured at DISPATCH precisely so a paused lane still reports +// what is outstanding. So digest is never "off" today; the question is asked of +// the registry rather than hardcoded so that if digest ever gains a real switch, +// this figure stops lying on its own. +export function laneOffFor( + station: StationId, + settings: SiteSettings, +): boolean { + if (station === "digest") { + return !allOperations(settings).some((k) => k.id === DIGEST_OPERATION_ID); + } + if (station === "speakers") return backfillLaneOperations(settings).length === 0; + return false; +} + +export type StationChannelWork = { + // Whether the operation applies to this channel at all. + eligible: boolean; + // How much work its report says there is, or null when it cannot say (no + // snapshot). Null is NOT zero: the figures above it are floors, and a click + // must not skip a channel that never reported. + work: number | null; + // Why the channel is not eligible, for the skip readout. + reason?: string; +}; + +// The single per-channel reader every group figure and every group button goes +// through. One derivation, so the label and the fan-out can never disagree. +export function stationWorkFor( + station: StationId, + // The slug is part of the question now: whether an operation applies to a + // channel is answered by the corpus-wide priority document as well as by the + // channel's own config, and that document is keyed by slug. + brief: Pick<ChannelBrief, "slug" | "config" | "snapshot">, + settings: SiteSettings, +): StationChannelWork { + const { slug, config, snapshot } = brief; + if (laneOffFor(station, settings)) { + return { eligible: false, work: 0, reason: "the lane is switched off" }; + } + const social = isSocialChannel(config); + + if (station === "sync") { + // The same predicate "Sync every channel" applies. No figure: syncAction + // decides per channel whether it is due, so there is no count to promise. + if (!config.url) return { eligible: false, work: 0, reason: "no url" }; + // THE PAUSED SECTION. The tier document is asked for the `sync` OPERATION + // — `isChannelPaused(model, slug, "sync")` — which is precisely what the + // deleted `excludeFromSync` flag meant, read the other way round, and is + // now the only thing asked: S5 deleted the flag and migrated the 15 + // channels that carried it. + // `?? defaultChannelPriority()` for the same reason `isGateHeld` reaches + // its key with optional chaining: this function is handed partial settings + // objects by unit tests and by any caller that has not been through + // `getSettings`, and an absent document means today's behaviour. + const priority = settings.channelPriority ?? defaultChannelPriority(); + if (isChannelPaused(priority, slug, "sync")) { + return { eligible: false, work: 0, reason: "paused for sync" }; + } + return { eligible: true, work: 0 }; + } + + if (social) { + return { eligible: false, work: 0, reason: "social account" }; + } + + if (station === "download") { + if (!config.url) return { eligible: false, work: 0, reason: "no url" }; + if (!snapshot) return { eligible: true, work: null }; + const excluded = excludedDownloadIdSet(snapshot); + return { + eligible: true, + work: (snapshot.undownloadedIds ?? []).filter((id) => !excluded.has(id)) + .length, + }; + } + + if (station === "transcribe") { + // A `youtube`-handling channel never runs whisper, so counting it would + // inflate the figure on a button that would skip it anyway. + if (config.handling !== "transcribe") { + return { eligible: false, work: 0, reason: "not set to transcribe" }; + } + if (!snapshot) return { eligible: true, work: null }; + const excluded = excludedDownloadIdSet(snapshot); + return { + eligible: true, + work: normalizeBuckets(snapshot.buckets).downloadedNoTranscript.filter( + (id) => !excluded.has(id), + ).length, + }; + } + + if (station === "digest") { + // Nothing to digest without transcripts — but a channel that has never + // reported cannot claim it has none, so it stays eligible and unknown. + if (!snapshot) return { eligible: true, work: null }; + if (snapshot.totals.transcribed <= 0) { + return { eligible: false, work: 0, reason: "no transcripts yet" }; + } + // digestWorkOf — the operation registry's entry is the one definition of + // "digested". The `noDigest` bucket it replaced had no cues-staleness gate + // and no transcript gate, so it called deferred and blocked videos done. + return { eligible: true, work: digestWorkOf(snapshot).reachable }; + } + + // Speakers — the backfill lane. backfillLaneEntriesOf, NEVER Object.values: the per-kind map carries + // every catalog operation including digest, which has its own station right + // beside this one. The lane filter is what keeps the two figures disjoint. + if (!snapshot) return { eligible: true, work: null }; + return { + eligible: true, + work: backfillLaneEntriesOf(snapshot.backfill).reduce( + (n, e) => n + reachableOperationWork(e), + 0, + ), + }; +} + +// The groups a site's channels actually fall into, in render order. Mirrors the +// bucketing MCP's list_channels does (handleListChannels), including its +// stray-bucket fallback, so the two never disagree about where a channel lives. +function bucketBySection( + site: Site, + stats: ReadonlyArray<ChannelStat>, +): { group: ChannelGroup; channels: ChannelStat[] }[] { + const hasGroups = site.groups.length > 0; + const order = new Map<string, number>(); + const groupOf = new Map<string, string>(); + for (const m of site.channels) { + if (typeof m.order === "number") order.set(m.slug, m.order); + groupOf.set(m.slug, resolveGroupIdFor(site, m.groupId, hasGroups)); + } + + const byGroup = new Map<string, ChannelStat[]>(); + for (const c of stats) { + // A stat with no membership row cannot happen for a site-scoped list, but + // fold it onto the default rather than dropping the channel. + const gid = + groupOf.get(c.slug) ?? resolveGroupIdFor(site, undefined, hasGroups); + const bucket = byGroup.get(gid) ?? []; + bucket.push(c); + byGroup.set(gid, bucket); + } + + const ordered = hasGroups ? sortGroups(site.groups) : [FALLBACK_GROUP]; + const knownIds = new Set(ordered.map((g) => g.id)); + const sections = ordered + .filter((g) => (byGroup.get(g.id)?.length ?? 0) > 0) + .map((group) => ({ group, channels: byGroup.get(group.id) ?? [] })); + // Defensive: an id resolveChannelGroupId could not fold onto a real group + // (e.g. a defaultGroupId naming a group that is no longer configured) gets a + // synthetic section rather than having its channels silently disappear. + for (const [gid, channels] of byGroup) { + if (knownIds.has(gid)) continue; + sections.push({ group: { ...FALLBACK_GROUP, id: gid }, channels }); + } + + for (const s of sections) { + s.channels.sort((a, b) => { + const ao = order.get(a.slug) ?? Number.POSITIVE_INFINITY; + const bo = order.get(b.slug) ?? Number.POSITIVE_INFINITY; + if (ao !== bo) return ao - bo; + return a.slug.localeCompare(b.slug); + }); + } + return sections; +} + +function resolveGroupIdFor( + site: Site, + groupId: string | undefined, + hasGroups: boolean, +): string { + return hasGroups + ? resolveChannelGroupId(groupId, site.groups, site.defaultGroupId) + : FALLBACK_GROUP.id; +} + +export function buildChannelGroupSections( + site: Site, + stats: ReadonlyArray<ChannelStat>, + briefs: ReadonlyArray<ChannelBrief>, + settings: SiteSettings, +): ChannelGroupSection[] { + const briefBySlug = new Map(briefs.map((b) => [b.slug, b])); + + return bucketBySection(site, stats).map(({ group, channels }) => { + const members = channels + .map((c) => briefBySlug.get(c.slug)) + .filter((b): b is ChannelBrief => !!b); + + const station = (id: StationId): StationWork => { + const work: StationWork = { eligible: [], total: 0, unknown: [] }; + if (laneOffFor(id, settings)) { + work.laneOff = true; + return work; + } + for (const b of members) { + const w = stationWorkFor(id, b, settings); + if (!w.eligible) continue; + work.eligible.push(b.slug); + if (w.work === null) work.unknown.push(b.slug); + else work.total += w.work; + } + return work; + }; + + return { + group, + channels, + sync: station("sync"), + download: station("download"), + transcribe: station("transcribe"), + digest: station("digest"), + speakers: station("speakers"), + speakersLabel: operationsGroupLabel( + backfillLaneOperations(settings).map((k) => k.id), + ), + }; + }); +} + +// The server-action half of the same bucketing: which slugs are in this group. +// Takes no snapshots, so a group button can never act on a different set than +// the rows it sits above — and re-reading it at click time keeps it correct if +// membership changed since the page rendered. +export function slugsInGroup(site: Site, groupId: string): string[] { + const hasGroups = site.groups.length > 0; + const out: string[] = []; + for (const m of site.channels) { + if (resolveGroupIdFor(site, m.groupId, hasGroups) === groupId) { + out.push(m.slug); + } + } + return out; +} diff --git a/common/views/jobRowView.ts b/common/views/jobRowView.ts @@ -0,0 +1,94 @@ +import type { + JobStatus, + JobProgressMetric, + JobTaskKind, +} from "../jobs/registry"; + +// A ROW IS ONE JOB, whichever of three places knows about it: the registry (a +// live JobRecord), the .jobs directory (a .log + .meta.json sidecar the +// registry has forgotten), or the scheduler (a slot whose record was evicted — +// a phantom, and the one row with no id the registry can answer for). Three +// surfaces used to draw three shapes; every field name the card renderer's row +// type had is kept, so the widget and the dashboard read what they read before. +// TYPES ONLY: this module is imported from "use client" files, and the builder +// that fills it (active/buildActiveJobs.ts) reaches the registry and the +// filesystem. + +export type JobRowStatus = JobStatus | "archived" | "evicted"; + +export type StuckReason = + | "record-evicted" + | "terminal-but-holding-slot" + | "possibly-stalled"; + +export type JobRowTask = { + id: string; + label: string; + // Imported for the same reason as `metric` below: a re-spelled literal here + // would not fail the build when JobTaskKind grew a member. + kind: JobTaskKind; + fraction?: number; + detail?: string; + // Epoch ms when this sub-operation started, for the live "running for" timer. + startedAt: number; + // The worker running this transcription (transcribe tasks only). + workerId?: string; + workerName?: string; + // Audio-integrity probe state (download tasks only). When phase is "probing" + // yt-dlp is paused for an ffmpeg integrity check; the bar fills against the + // estimated probe duration instead of the (frozen) download fraction. + phase?: "downloading" | "probing"; + probeStartedAt?: number; + probeEtaSeconds?: number; +}; + +export type JobRowProgress = { + // Imported, NOT re-spelled: a literal copy here silently drifted from + // JobProgressMetric and would not fail the build when the union grew. + metric: JobProgressMetric; + initial: number; + current: number; + target: number; + pct: number; + // Estimated seconds remaining (remaining tasks × measured avg per task). + // Undefined until the first task completes, or when no work remains. + etaSeconds?: number; +}; + +export type JobRowView = { + id: string; + kind: string; // "" for an old log with no sidecar; the cell renders "—" + status: JobRowStatus; + queueKey?: string; // undefined = unknown (old log); "" = parallel ("(immediate)") + channelSlug?: string; + videoId?: string; + progress?: JobRowProgress; + tasks?: JobRowTask[]; + draining?: boolean; + drainable?: boolean; + // Background work (e.g. an auto-download unit) queues BEHIND a manual job on + // the same platform queue. Shown as an "auto" badge, and — when queued — as a + // hint that a foreground job (a clicked Sync) is being let through first. + background?: boolean; + // Reorder affordances for a QUEUED job: whether it can move toward (up/promote) + // or away from (down) the head of its queue. Both false for running jobs and + // for parallel ("") jobs that aren't serialized. + canMoveUp?: boolean; + canMoveDown?: boolean; + // The history side (listJobs.ts). + queuedAt?: number; + startedAt?: number; + endedAt?: number; + exitCode?: number; + logSize?: number; + inRegistry?: boolean; + replayable?: boolean; + // The slot side (the scheduler's view of the same id). + position?: number; // 0 = running head, 1.. = queued; absent for parallel/history + heldMs?: number; + stuck?: { reason: StuckReason; heldMs: number }; + pid?: number; + lastLogLine?: string; // stuck rows only — see readLastLogLine + // Which adapter built it. Never rendered; tests and the merge read it. + source: "registry" | "archive" | "slot"; +}; diff --git a/common/views/jobRows.test.ts b/common/views/jobRows.test.ts @@ -0,0 +1,342 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { newJobId, type JobRecord } from "../jobs/registry"; +import type { JobListEntry } from "../jobs/listJobs"; +import { createScheduler } from "../jobs/scheduler"; +import { + fromEntry, + fromRecord, + fromSlot, + isLive, + mergeJobRows, + orderLiveRows, + reconcileSlots, + type FromRecordContext, +} from "./jobRows"; + +// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" +// +// The adapters, the order and the merge — no registry, no scheduler, no disk. +// Records are built the way common/jobs/registry.test.ts builds them: a literal +// JobRecord with a fresh id. + +function rec(over: Partial<JobRecord> = {}): JobRecord { + return { + id: newJobId(), + kind: "whisper-all", + queueKey: "q", + status: "running", + queuedAt: 1_000, + logPath: "", + ...over, + }; +} + +const ctx = (over: Partial<FromRecordContext> = {}): FromRecordContext => ({ + now: 10_000, + workerNames: new Map(), + position: -1, + queueMax: 0, + ...over, +}); + +test("fromRecord carries every field the card renderer used to read", () => { + const j = rec({ + channelSlug: "a", + videoId: "v1", + startedAt: 2_000, + background: true, + tasks: [ + { + id: "v1", + label: "One", + kind: "transcribe", + startedAt: 3_000, + workerId: "w1", + }, + ], + }); + const row = fromRecord(j, ctx({ workerNames: new Map([["w1", "Only"]]) })); + assert.equal(row.id, j.id); + assert.equal(row.kind, "whisper-all"); + assert.equal(row.status, "running"); + assert.equal(row.queueKey, "q"); + assert.equal(row.channelSlug, "a"); + assert.equal(row.videoId, "v1"); + assert.equal(row.background, true); + assert.equal(row.draining, false); + assert.equal(row.tasks?.[0].workerName, "Only"); + assert.equal(row.inRegistry, true); + assert.equal(row.source, "registry"); + assert.equal(row.startedAt, 2_000); + assert.equal(row.queuedAt, 1_000); +}); + +test("fromRecord: drainable needs running, a drainable kind and no drain", () => { + assert.equal(fromRecord(rec(), ctx()).drainable, true); + assert.equal(fromRecord(rec({ draining: true }), ctx()).drainable, false); + assert.equal(fromRecord(rec({ status: "queued" }), ctx()).drainable, false); + // check-availability has no jobKinds entry, so it is not drainable. + assert.equal( + fromRecord(rec({ kind: "check-availability" }), ctx()).drainable, + false, + ); +}); + +test("fromRecord: reorder bounds come from position and queueMax", () => { + const queued = (position: number, queueMax: number) => + fromRecord(rec({ status: "queued" }), ctx({ position, queueMax })); + // Position 1 is the first QUEUED job (0 is the running head): nowhere up. + assert.equal(queued(1, 3).canMoveUp, false); + assert.equal(queued(2, 3).canMoveUp, true); + assert.equal(queued(3, 3).canMoveDown, false); + assert.equal(queued(2, 3).canMoveDown, true); + // A parallel ("") job is in no queue at all. + const parallel = fromRecord(rec({ status: "queued", queueKey: "" }), ctx()); + assert.equal(parallel.canMoveUp, false); + assert.equal(parallel.canMoveDown, false); + assert.equal(parallel.position, undefined); +}); + +test("fromRecord: no channel stat means no progress bar, and spec means replayable", () => { + const j = rec({ + channelSlug: "a", + progress: { metric: "transcripts", initial: 0, target: 10 }, + }); + assert.equal(fromRecord(j, ctx()).progress, undefined); + const withStat = fromRecord( + j, + ctx({ + stat: { + config: { name: "A" }, + transcriptCount: 5, + downloadCount: 0, + } as unknown as NonNullable<FromRecordContext["stat"]>, + }), + ); + assert.equal(withStat.progress?.current, 5); + assert.equal(withStat.progress?.pct, 50); + assert.equal(fromRecord(rec(), ctx()).replayable, false); + assert.equal( + fromRecord(rec({ spec: { kind: "whisper-all" } as never }), ctx()) + .replayable, + true, + ); +}); + +test("fromEntry keeps the history fields, archived included", () => { + const e: JobListEntry = { + id: newJobId(), + kind: "sync", + channelSlug: "a", + status: "archived", + queuedAt: 1, + endedAt: 9, + inRegistry: false, + replayable: true, + logPath: "/x.log", + logSize: 4096, + }; + const row = fromEntry(e); + assert.equal(row.status, "archived"); + assert.equal(row.logSize, 4096); + assert.equal(row.inRegistry, false); + assert.equal(row.replayable, true); + assert.equal(row.source, "archive"); + // An old log with no sidecar has no kind at all; the cell renders "—". + assert.equal(fromEntry({ ...e, kind: undefined }).kind, ""); +}); + +test("orderLiveRows: running, then queued IN QUEUE ORDER, then what just ended", () => { + const rows = orderLiveRows([ + { id: "q2", kind: "", status: "queued", queueKey: "q", position: 2, source: "registry" }, + { id: "run", kind: "", status: "running", queuedAt: 5, source: "registry" }, + { id: "q1", kind: "", status: "queued", queueKey: "q", position: 1, source: "registry" }, + { id: "done", kind: "", status: "done", endedAt: 99, source: "registry" }, + ]); + assert.deepEqual( + rows.map((r) => r.id), + ["run", "q1", "q2", "done"], + ); +}); + +test("orderLiveRows: two queues interleave by name, then by position", () => { + const rows = orderLiveRows([ + { id: "b1", kind: "", status: "queued", queueKey: "qB", position: 1, source: "registry" }, + { id: "a2", kind: "", status: "queued", queueKey: "qA", position: 2, source: "registry" }, + { id: "a1", kind: "", status: "queued", queueKey: "qA", position: 1, source: "registry" }, + ]); + assert.deepEqual( + rows.map((r) => r.id), + ["a1", "a2", "b1"], + ); +}); + +test("orderLiveRows: two running jobs keep registry order (newest queued first)", () => { + const rows = orderLiveRows([ + { id: "old", kind: "", status: "running", queuedAt: 1, source: "registry" }, + { id: "new", kind: "", status: "running", queuedAt: 2, source: "registry" }, + ]); + assert.deepEqual( + rows.map((r) => r.id), + ["new", "old"], + ); +}); + +test("mergeJobRows: the live row wins by id, and the head stays first", () => { + const live = [ + { + id: "A", + kind: "sync", + status: "running" as const, + progress: { metric: "transcripts" as const, initial: 0, current: 1, target: 2, pct: 50 }, + source: "registry" as const, + }, + { id: "B", kind: "sync", status: "done" as const, endedAt: 5, source: "registry" as const }, + ]; + const history = [ + { id: "A", kind: "sync", status: "running" as const, logSize: 10, source: "archive" as const }, + { id: "B", kind: "sync", status: "done" as const, logSize: 10, source: "archive" as const }, + { id: "C", kind: "sync", status: "failed" as const, logSize: 10, source: "archive" as const }, + ]; + const rows = mergeJobRows(live, history); + assert.deepEqual( + rows.map((r) => r.id), + ["A", "B", "C"], + ); + // One <tr> per job, and the one that is drawn is the one with progress. + assert.equal(rows[0].source, "registry"); + assert.equal(rows[0].progress?.pct, 50); + // A recent row stays ahead of the history page that also lists it. + assert.equal(rows[1].source, "registry"); +}); + +test("isLive: anything non-terminal, plus a stuck or evicted slot", () => { + assert.equal(isLive({ id: "1", kind: "", status: "running", source: "registry" }), true); + assert.equal(isLive({ id: "1", kind: "", status: "queued", source: "registry" }), true); + assert.equal(isLive({ id: "1", kind: "", status: "evicted", source: "slot" }), true); + assert.equal(isLive({ id: "1", kind: "", status: "done", source: "archive" }), false); + assert.equal( + isLive({ + id: "1", + kind: "", + status: "done", + stuck: { reason: "terminal-but-holding-slot", heldMs: 1 }, + source: "registry", + }), + true, + ); +}); + +// --- The scheduler against the registry ------------------------------------- + +function scheduledQueue(): { + scheduler: ReturnType<typeof createScheduler>; + ids: string[]; +} { + const scheduler = createScheduler(); + const ids = [newJobId(), newJobId(), newJobId()]; + for (const id of ids) { + scheduler.submit({ + id, + queueKey: "qFacts", + tier: "foreground", + concurrency: 1, + start: () => {}, + onCancel: () => {}, + }); + } + return { scheduler, ids }; +} + +test("reconcileSlots: a terminal record holding a running slot is hard-stuck", () => { + const { scheduler, ids } = scheduledQueue(); + const [a, b, c] = ids; + const now = 10_000_000; + const records = new Map<string, JobRecord>([ + [a, rec({ id: a, queueKey: "qFacts", status: "done", startedAt: now - 5_000 })], + [b, rec({ id: b, queueKey: "qFacts", status: "queued" })], + [c, rec({ id: c, queueKey: "qFacts", status: "queued" })], + ]); + const facts = reconcileSlots(scheduler.queues(), (id) => records.get(id), now); + const byId = new Map(facts.map((f) => [f.id, f])); + assert.equal(byId.get(a)?.stuck?.reason, "terminal-but-holding-slot"); + assert.equal(byId.get(a)?.heal, true); + assert.equal(byId.get(b)?.position, 1); + assert.equal(byId.get(c)?.position, 2); + assert.equal(byId.get(b)?.stuck, undefined); + assert.equal(byId.get(c)?.stuck, undefined); + assert.equal(facts.filter((f) => f.heal).length, 1); + + // Healing the hard cases promotes the next in line. + facts.filter((f) => f.heal).forEach((f) => scheduler.complete(f.id)); + assert.deepEqual(scheduler.queues()[0].running, [b]); +}); + +test("reconcileSlots: an evicted record is hard-stuck; a quiet live one is soft", () => { + const now = 10_000_000; + + const evicted = scheduledQueue(); + const gone = reconcileSlots( + evicted.scheduler.queues(), + () => undefined, + now, + ); + assert.equal(gone[0].stuck?.reason, "record-evicted"); + assert.equal(gone[0].heal, true); + + const stalled = scheduledQueue(); + const idA = stalled.ids[0]; + const soft = reconcileSlots( + stalled.scheduler.queues(), + (id) => + id === idA + ? rec({ + id: idA, + queueKey: "qFacts", + status: "running", + startedAt: now - 11 * 60 * 1000, + tasks: [], + }) + : undefined, + now, + ); + assert.equal(soft[0].stuck?.reason, "possibly-stalled"); + // SOFT: surfaced and force-releasable, never auto-healed. + assert.equal(soft[0].heal, false); + + const busy = scheduledQueue(); + const idB = busy.ids[0]; + const fresh = reconcileSlots( + busy.scheduler.queues(), + (id) => + id === idB + ? rec({ + id: idB, + queueKey: "qFacts", + status: "running", + startedAt: now - 60 * 1000, + tasks: [], + }) + : undefined, + now, + ); + assert.equal(fresh[0].stuck, undefined); + assert.equal(fresh[0].heal, false); +}); + +test("fromSlot builds the phantom, and a stuck row sorts above a running one", () => { + const { scheduler } = scheduledQueue(); + const facts = reconcileSlots(scheduler.queues(), () => undefined, 10_000_000); + const phantom = fromSlot(facts[0], "whisper-all"); + assert.equal(phantom.status, "evicted"); + assert.equal(phantom.source, "slot"); + assert.equal(phantom.stuck?.reason, "record-evicted"); + assert.equal(phantom.queueKey, "qFacts"); + + const ordered = orderLiveRows([ + { id: "run", kind: "", status: "running", queuedAt: 9, source: "registry" }, + phantom, + ]); + assert.equal(ordered[0].id, phantom.id); +}); diff --git a/common/views/jobRows.ts b/common/views/jobRows.ts @@ -0,0 +1,331 @@ +import { isDrainableKind } from "../jobs/jobKinds"; +import { jobIdTime } from "../jobs/ulid"; +import type { JobRecord } from "../jobs/registry"; +import type { JobListEntry } from "../jobs/listJobs"; +import type { ChannelStat } from "../controller/channels"; +import type { QueueView } from "../jobs/scheduler"; +import type { JobRowView, StuckReason } from "./jobRowView"; + +// THE ADAPTERS, THE ORDER AND THE MERGE — pure, and directive-free by +// construction so both the server builder and the "use client" table can import +// them. The precedent is components/lanes/laneState.ts: its only value imports +// are things with no imports of their own, so nothing drags the registry, the +// scheduler or `fs` into the client bundle. Here that is isDrainableKind +// (common/jobs/jobKinds.ts, zero imports, already in the client graph via +// jobKindLabels.ts) and jobIdTime (common/jobs/ulid.ts, zero imports). +// Everything else is `import type`. + +// How long a finished job stays in the LIVE head. Long enough for the 5 s pulse +// to re-render the tail with the same row twice over; short enough that the head +// is still "now". +export const RECENT_MS = 30_000; + +// A running slot is treated as "possibly stalled" (the soft case) once a +// running/draining record has held its slot this long with no in-flight tasks. +// Distinct from the hard cases (terminal-but-holding-slot / record-evicted), +// which are always safe to auto-heal; the soft case is only surfaced/force- +// releasable, never auto-healed. +const STUCK_AGE_MS = 10 * 60 * 1000; + +const TERMINAL = new Set(["done", "failed", "cancelled"]); + +export type SlotFact = { + id: string; + queueKey: string; + role: "running" | "queued"; + position: number; + heldMs: number; + stuck?: { reason: StuckReason; heldMs: number }; + heal: boolean; +}; + +// THE SCHEDULER AGAINST THE REGISTRY, as facts. The scheduler is the source of +// truth for SLOTS and still references ids whose record is terminal or gone; +// the registry is the source of truth for STATUS and never sees a slot. Pure: +// it takes the queues snapshot and a lookup, reports what it found, and does +// not heal — the builder heals, AFTER the rows are built, so a stuck row is +// drawn once before it vanishes. The classification is the queue view's, +// unchanged: +// record-evicted and terminal-but-holding-slot are hard (heal); possibly-stalled +// (a live record idle past STUCK_AGE_MS) is soft — surfaced, force-releasable, +// never healed, since it may be a genuinely long operation. +export function reconcileSlots( + views: QueueView[], + get: (id: string) => JobRecord | undefined, + now: number, +): SlotFact[] { + const facts: SlotFact[] = []; + for (const v of views) { + const ids = [...v.running, ...v.queued]; + for (const [position, id] of ids.entries()) { + const role: "running" | "queued" = + position < v.running.length ? "running" : "queued"; + const rec = get(id); + const heldMs = now - (rec?.startedAt ?? rec?.queuedAt ?? now); + let stuck: { reason: StuckReason; heldMs: number } | undefined; + let heal = false; + if (role === "running") { + if (!rec) { + // Scheduler references an id the registry evicted while it still + // holds a running slot — a stuck slot with no live record to + // describe it. + stuck = { reason: "record-evicted", heldMs }; + heal = true; + } else if (TERMINAL.has(rec.status)) { + stuck = { reason: "terminal-but-holding-slot", heldMs }; + heal = true; + } else { + const ageMs = now - (rec.startedAt ?? rec.queuedAt); + const idle = !rec.tasks || rec.tasks.length === 0; + if (idle && ageMs > STUCK_AGE_MS) { + // Soft case: a live running/draining record sitting idle far too + // long. Force-releasable, but deliberately NOT auto-healed. + stuck = { reason: "possibly-stalled", heldMs: ageMs }; + } + } + } + facts.push({ id, queueKey: v.name, role, position, heldMs, stuck, heal }); + } + } + return facts; +} + +// THE PHANTOM: a slot whose record the registry no longer has. It is the one +// row with no id the registry can answer for, so everything it knows comes from +// the slot itself plus the on-disk meta sidecar the caller resolved the kind +// from. +export function fromSlot( + f: SlotFact, + kind: string, + lastLogLine?: string, +): JobRowView { + return { + id: f.id, + kind, + status: "evicted", + queueKey: f.queueKey, + queuedAt: jobIdTime(f.id) || undefined, + position: f.position, + heldMs: f.heldMs, + stuck: f.stuck, + lastLogLine, + source: "slot", + }; +} + +// Estimate seconds remaining as: remaining tasks × average measured task +// duration, converted to wall-clock via the effective concurrency observed so +// far. Returns undefined until at least one task has completed (no average +// yet) or when there's no work left. `now` is injected so the same snapshot is +// used across all jobs in a single payload build. +function computeEtaSeconds( + job: JobRecord, + remaining: number, + now: number, + remainingAudioSeconds?: number, +): number | undefined { + const count = job.completedTaskCount ?? 0; + const totalMs = job.completedTaskMs ?? 0; + if (count < 1 || remaining <= 0 || job.startedAt === undefined) { + return undefined; + } + // Prefer an AUDIO-HOUR estimate where the work is proportional to length. + // Averaging tasks assumes every unit costs about the same, which is true for + // downloads and wildly false for digests: this corpus is ~77k videos and ~77k + // audio-hours, and a channel of 9-hour VODs and a channel of 10-minute clips + // have the same task count and a 50x difference in cost. A task average would + // therefore quote an ETA that is wrong by more than an order of magnitude at + // exactly the moment an operator most needs it — the start of an 80-day run. + const doneAudio = job.completedTaskAudioSeconds ?? 0; + if (doneAudio > 0 && remainingAudioSeconds && remainingAudioSeconds > 0) { + const secondsPerAudioSecond = totalMs / 1000 / doneAudio; + const elapsedMs = Math.max(1, now - job.startedAt); + const concurrency = Math.max(1, totalMs / elapsedMs); + return (remainingAudioSeconds * secondsPerAudioSecond) / concurrency; + } + const avgProcMs = totalMs / count; // measured average per task + const elapsedMs = Math.max(1, now - job.startedAt); + const concurrency = Math.max(1, totalMs / elapsedMs); // effective parallelism + // Round up to whole parallel waves: the final wave runs its tasks + // concurrently, so e.g. 2 tasks left across 4 workers still costs ~one full + // task — not "half a task". Without the ceil the tail is underestimated. + const waves = Math.ceil(remaining / concurrency); + return (waves * avgProcMs) / 1000; +} + +function computeJobProgressView( + job: JobRecord, + stat: ChannelStat | undefined, + now: number, +): JobRowView["progress"] { + const snap = job.progress; + if (!snap || !stat) return undefined; + // `current` is RE-COUNTED from disk (readChannelStat), never reported by the + // runner — which is why the digest metric needed its own on-disk counter + // (digestCount) rather than a number the batch could have just told us. + // A runner-reported `current` WINS. The disk re-count below cannot see a + // regeneration — a regenerated digest is rewritten in place, so the file + // count never moves and the bar sits at 0% for the whole job. Only the runner + // knows it did the work. Downloads and transcripts report nothing and keep + // the disk re-count, unchanged. + const current = + snap.current ?? + (snap.metric === "downloads" + ? stat.downloadCount + : snap.metric === "digests" + ? (stat.digestCount ?? 0) + : stat.transcriptCount); + const range = Math.max(0, snap.target - snap.initial); + const advance = Math.max(0, current - snap.initial); + const pct = + range === 0 ? 100 : Math.max(0, Math.min(100, (advance / range) * 100)); + const remaining = Math.max(0, snap.target - current); + return { + metric: snap.metric, + initial: snap.initial, + current, + target: snap.target, + pct, + etaSeconds: computeEtaSeconds( + job, + remaining, + now, + snap.remainingAudioSeconds, + ), + }; +} + +export type FromRecordContext = { + now: number; + stat?: ChannelStat; + workerNames: ReadonlyMap<string, string>; + // 0 = running head, 1.. = queued; -1 for a parallel ("") job the scheduler + // never tracked. + position: number; + // The last index in this record's queue, for the reorder bounds. + queueMax: number; +}; + +// The live adapter: a registry record as a row. +export function fromRecord(j: JobRecord, ctx: FromRecordContext): JobRowView { + return { + id: j.id, + kind: j.kind, + status: j.status, + queueKey: j.queueKey, + channelSlug: j.channelSlug, + videoId: j.videoId, + progress: + j.status === "running" && j.channelSlug + ? computeJobProgressView(j, ctx.stat, ctx.now) + : undefined, + tasks: j.tasks?.map((t) => ({ + id: t.id, + label: t.label, + kind: t.kind, + fraction: t.fraction, + detail: t.detail, + startedAt: t.startedAt, + workerId: t.workerId, + workerName: t.workerId ? ctx.workerNames.get(t.workerId) : undefined, + phase: t.phase, + probeStartedAt: t.probeStartedAt, + probeEtaSeconds: t.probeEtaSeconds, + })), + draining: j.draining === true, + drainable: + j.status === "running" && isDrainableKind(j.kind) && j.draining !== true, + background: j.background === true, + // A queued job can move up/promote if it isn't the first queued (position > + // 1, since the running head is at 0), and down if it isn't the last in its + // queue. Running and parallel ("") jobs are never reorderable. + canMoveUp: j.status === "queued" && ctx.position > 1, + canMoveDown: + j.status === "queued" && + ctx.position >= 1 && + ctx.position < ctx.queueMax, + queuedAt: j.queuedAt, + startedAt: j.startedAt, + endedAt: j.endedAt, + exitCode: j.exitCode, + inRegistry: true, + replayable: Boolean(j.spec), + position: ctx.position >= 0 ? ctx.position : undefined, + source: "registry", + }; +} + +// The history adapter: a .log + sidecar entry as a row. Field for field — +// listJobs already resolved the registry record where there was one. +export function fromEntry(e: JobListEntry): JobRowView { + return { + id: e.id, + kind: e.kind ?? "", + status: e.status, + queueKey: e.queueKey, + channelSlug: e.channelSlug, + videoId: e.videoId, + queuedAt: e.queuedAt, + startedAt: e.startedAt, + endedAt: e.endedAt, + exitCode: e.exitCode, + logSize: e.logSize, + inRegistry: e.inRegistry, + replayable: e.replayable, + source: "archive", + }; +} + +function liveRank(r: JobRowView): number { + if (r.stuck) return 0; + if (r.status === "running") return 1; + if (r.status === "queued") return 2; + return 3; +} + +// The head's order: what is wrong first, then what is working, then what is +// waiting IN THE ORDER IT WILL RUN, then what just finished. +// +// Queued rows sort by (queue name, position) rather than by recency — finding 8 +// of plans/editor-ia-slice-8c.md. That is a change: the Active Jobs screen +// ordered queued jobs by the registry's queuedAt, which put the job that will +// run next anywhere in the list. "2nd in line" in the Queue cell only means anything if +// the rows are in line. +export function orderLiveRows(rows: JobRowView[]): JobRowView[] { + return [...rows].sort((a, b) => { + const ra = liveRank(a); + const rb = liveRank(b); + if (ra !== rb) return ra - rb; + if (ra === 0) return (b.heldMs ?? 0) - (a.heldMs ?? 0); + // Running: registry order, newest queued first — unchanged. + if (ra === 1) return (b.queuedAt ?? 0) - (a.queuedAt ?? 0); + if (ra === 2) { + const byQueue = (a.queueKey ?? "").localeCompare(b.queueKey ?? ""); + if (byQueue !== 0) return byQueue; + return (a.position ?? 0) - (b.position ?? 0); + } + return (b.endedAt ?? 0) - (a.endedAt ?? 0); + }); +} + +// LIVE WINS BY ID: the same job is in the registry and on disk at once, and its +// live row is the one with progress. The head keeps its order; the tail follows +// in the order the directory listing gave it, minus anything already above. +export function mergeJobRows( + live: JobRowView[], + history: JobRowView[], +): JobRowView[] { + const seen = new Set(live.map((r) => r.id)); + return [...live, ...history.filter((r) => !seen.has(r.id))]; +} + +// Is this row still moving? Drives the 1 s poll: a page with nothing live makes +// no requests at all. +export function isLive(r: JobRowView): boolean { + return ( + r.status === "running" || + r.status === "queued" || + r.status === "evicted" || + Boolean(r.stuck) + ); +} diff --git a/editor/app/components/lanes/laneState.test.ts b/common/views/laneState.test.ts diff --git a/common/views/laneState.ts b/common/views/laneState.ts @@ -0,0 +1,79 @@ +import { + STATION_DOT, + STATION_TEXT, + formatCount, +} from "./pipeline/tone"; + +// A lane's state, and the one derivation every surface reads it from. +// +// THE POINT OF THIS TYPE IS THAT A LANE HAS TWO SWITCHES, NOT ONE. There is the +// RUNNER — is anything dispatching at all — and the GATE — the pause that +// decides whether the lane may consume what it picks. They are independent, and +// the state nobody had a word for is the combination "runner up, gate shut": +// the lane is not stopped and it is not working. That is `holding`, and it is +// the whole reason this is four states rather than a boolean. +// +// Every lane's gate reaches the wire as `held`, computed once by isGateHeld +// (common/lib/pauseGates.ts); no reader inverts. +// +// THE `feedRunning` AXIS RETIRED WITH THE SWEEPS in slice 1.3. It meant "a +// corpus-wide sweep is armed", which was a fact only two of the four lanes had; +// all four are dispatched by a runner now, so a caller with no work in flight is +// `idle` on the same terms auto-transcribe always was. +export type LaneState = "running" | "holding" | "idle" | "unavailable"; + +export function deriveLaneState({ + available = true, + gateHeld, + activeCount = 0, +}: { + // The lane exists at all. `false` is not "idle": with no operation registered + // there is nothing to hold, and an idle-looking lane would read as "all caught + // up" when the truth is "switched off". + available?: boolean; + gateHeld: boolean; + // Work in flight right now. + activeCount?: number; +}): LaneState { + if (!available) return "unavailable"; + // The gate decides first. A running lane behind a shut gate is HOLDING, not + // running — its limit() returns 0 and the pool idle-waits, which looks exactly + // like wedged unless something says otherwise. + if (gateHeld) return "holding"; + if (activeCount > 0) return "running"; + return "idle"; +} + +// No new palette. These map onto the station tones the channel line already +// uses (see flow/tone.ts): four theme families × light/dark means a bespoke hue +// here would be wrong in eight palettes at once. Deliberately NOT a second copy +// of those maps — flow/OverviewPanel already made one, and three would be a +// guarantee they drift. +export const LANE_DOT: Record<LaneState, string> = { + // The travelling pulse, paired with motion-reduce as everything animated here + // is. + running: `${STATION_DOT.running} motion-reduce:animate-none`, + holding: STATION_DOT.attention, + idle: STATION_DOT.neutral, + unavailable: STATION_DOT.neutral, +}; + +export const LANE_TEXT: Record<LaneState, string> = { + running: STATION_TEXT.running, + holding: STATION_TEXT.attention, + idle: STATION_TEXT.neutral, + unavailable: STATION_TEXT.neutral, +}; + +// The word beside the dot. This — not the rail — is what assistive tech reads, +// so it has to stand on its own. +export const LANE_WORD: Record<LaneState, string> = { + running: "Running", + holding: "Holding", + idle: "Idle", + unavailable: "Off", +}; + +// Re-exported so a lane figure formats its counts the same way a station does, +// including the "—, never 0" rule: a zero would claim a measurement nobody took. +export { formatCount }; diff --git a/editor/app/api/widget/presets/route.ts b/editor/app/api/widget/presets/route.ts @@ -7,7 +7,7 @@ import { import { BUILT_IN_PRESETS, type BuiltInPreset, -} from "../../../widget/lib/builtInPresets"; +} from "yt-dlp-transcript-common/views/builtInPresets"; export const dynamic = "force-dynamic"; 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 "../lib/channelGroupSections"; +import type { ChannelGroupSection } 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 diff --git a/editor/app/channels/components/ChannelGroupLine.tsx b/editor/app/channels/components/ChannelGroupLine.tsx @@ -6,7 +6,7 @@ import type { ChannelGroupSection, StationId, StationWork, -} from "../lib/channelGroupSections"; +} from "yt-dlp-transcript-common/views/channelGroupSections"; import { digestChannelGroupAction, downloadChannelGroupAction, diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -11,7 +11,7 @@ import { BandLegend, StateBand, } from "../../components/pipelines/StateBand"; -import type { ChannelGroupSection } from "../lib/channelGroupSections"; +import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections"; import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow"; import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; import { ChannelBuildToggle } from "./ChannelBuildToggle"; diff --git a/editor/app/channels/groupActions.ts b/editor/app/channels/groupActions.ts @@ -14,7 +14,7 @@ import { slugsInGroup, stationWorkFor, type StationId, -} from "./lib/channelGroupSections"; +} from "yt-dlp-transcript-common/views/channelGroupSections"; import { queueForSlugs } from "./lib/queueForSlugs"; import { downloadMissingAction, syncAction } from "./[slug]/pipelineActions"; import { transcribeMissingAction } from "./[slug]/whisperActions"; diff --git a/editor/app/channels/lib/channelGroupSections.test.ts b/editor/app/channels/lib/channelGroupSections.test.ts @@ -1,408 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import type { - ChannelBrief, - ChannelStat, -} from "yt-dlp-transcript-common/controller/channels"; -import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; -import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; -import type { OperationSnapshotEntry } from "yt-dlp-transcript-common/lib/operations"; -import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; -import type { Site } from "yt-dlp-transcript-common/lib/site"; -import { normalizeBuckets } from "yt-dlp-transcript-common/views/pipeline/stageStatus"; -import { - buildChannelGroupSections, - slugsInGroup, - type ChannelGroupSection, -} from "./channelGroupSections"; - -// Run from this directory: -// cd editor/app/channels/lib && ../../../../node_modules/.bin/tsx --test channelGroupSections.test.ts - -// Snapshot shape as WRITTEN TO DISK — same trick as channelFlow.test.ts. -function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot { - return { - generatedAt: "2026-08-01T00:00:00.000Z", - totals: { videos: 100, transcribed: 40, downloaded: 60 }, - buckets: normalizeBuckets(undefined), - undownloadedIds: [], - ...patch, - }; -} - -function entry(patch: Partial<OperationSnapshotEntry>): OperationSnapshotEntry { - return { - ids: [], - missing: 0, - stale: 0, - missingInput: 0, - ...patch, - } as OperationSnapshotEntry; -} - -function channel( - slug: string, - config: Partial<ChannelConfig> = {}, - snapshot: ChannelSnapshot | null = snapshotOf(), -): { stat: ChannelStat; brief: ChannelBrief } { - const merged: ChannelConfig = { - handling: "transcribe", - url: `https://example.test/${slug}`, - ...config, - }; - return { - stat: { - slug, - config: merged, - playlistCount: 0, - videoCount: 0, - transcriptCount: 0, - downloadCount: 0, - }, - brief: { slug, config: merged, snapshot }, - }; -} - -function siteOf(patch: Partial<Site> = {}): Site { - return { - siteId: "alpha", - siteTitle: "Alpha", - siteDescription: "", - headerTitle: "Alpha", - homeTagline: "", - groups: [], - defaultGroupId: "default", - channels: [], - ...patch, - }; -} - -// backfillLaneOperations / allOperations only read these two branches, so a -// partial cast exercises the real registry predicates. -function settingsOf(opts: { - diarization?: boolean; - attribution?: boolean; -}): SiteSettings { - return { - diarization: { - enabled: opts.diarization === true, - segModel: "seg.onnx", - embModel: "emb.onnx", - }, - attribution: { - enabled: opts.attribution === true, - textOnlyEnabled: opts.attribution === true, - diarizedEnabled: opts.attribution === true, - }, - } as unknown as SiteSettings; -} - -const LANE_ON = settingsOf({ diarization: true }); -const LANE_OFF = settingsOf({}); - -function build( - site: Site, - channels: { stat: ChannelStat; brief: ChannelBrief }[], - settings: SiteSettings = LANE_ON, -): ChannelGroupSection[] { - return buildChannelGroupSections( - site, - channels.map((c) => c.stat), - channels.map((c) => c.brief), - settings, - ); -} - -const GROUPS = [ - { id: "extended", name: "Extended Universe", selectedByDefault: false, order: 2 }, - { id: "archives", name: "Archives", selectedByDefault: true, order: 1 }, -]; - -test("groups render in sortGroups order, and empty groups are omitted", () => { - const a = channel("arch-a"); - const b = channel("ext-b"); - const sections = build( - siteOf({ - groups: [ - ...GROUPS, - { id: "guests", name: "Guest Appearances", selectedByDefault: true, order: 3 }, - ], - defaultGroupId: "archives", - channels: [ - { slug: "arch-a", groupId: "archives" }, - { slug: "ext-b", groupId: "extended" }, - ], - }), - [a, b], - ); - assert.deepEqual( - sections.map((s) => s.group.id), - ["archives", "extended"], - ); - assert.deepEqual(sections[0].channels.map((c) => c.slug), ["arch-a"]); -}); - -test("an unknown groupId folds onto the site's default group", () => { - const sections = build( - siteOf({ - groups: GROUPS, - defaultGroupId: "archives", - channels: [ - { slug: "stray", groupId: "no-such-group" }, - { slug: "ext-b", groupId: "extended" }, - ], - }), - [channel("stray"), channel("ext-b")], - ); - assert.deepEqual( - sections.map((s) => s.group.id), - ["archives", "extended"], - ); - assert.deepEqual(sections[0].channels.map((c) => c.slug), ["stray"]); -}); - -test("a site with no configured groups gets the one fallback section", () => { - const sections = build( - siteOf({ channels: [{ slug: "only" }] }), - [channel("only")], - ); - assert.equal(sections.length, 1); - assert.equal(sections[0].group.id, "default"); - assert.equal(sections[0].group.name, "All channels"); -}); - -test("within a group, membership order wins and absent order sorts last", () => { - const sections = build( - siteOf({ - channels: [ - { slug: "no-order" }, - { slug: "second", order: 2 }, - { slug: "first", order: 1 }, - ], - }), - [channel("no-order"), channel("second"), channel("first")], - ); - assert.deepEqual(sections[0].channels.map((c) => c.slug), [ - "first", - "second", - "no-order", - ]); -}); - -test("a channel with no snapshot lands in `unknown`, so the total is a floor", () => { - const sections = build( - siteOf({ channels: [{ slug: "reported" }, { slug: "silent" }] }), - [ - channel("reported", {}, snapshotOf({ undownloadedIds: ["v1", "v2"] })), - channel("silent", {}, null), - ], - ); - const { download } = sections[0]; - assert.deepEqual(download.eligible.sort(), ["reported", "silent"]); - assert.equal(download.total, 2); - assert.deepEqual(download.unknown, ["silent"]); -}); - -test("download excludes members-only/deleted/private ids", () => { - const sections = build( - siteOf({ channels: [{ slug: "c" }] }), - [ - channel( - "c", - {}, - snapshotOf({ - undownloadedIds: ["keep", "gone"], - excludedFromDownload: { - membersOnly: [], - deleted: ["gone"], - private: [], - }, - }), - ), - ], - ); - assert.equal(sections[0].download.total, 1); -}); - -test("a youtube-handling channel is not transcribe-eligible but is download-eligible", () => { - const sections = build( - siteOf({ channels: [{ slug: "yt" }, { slug: "whisper" }] }), - [ - channel("yt", { handling: "youtube" }, snapshotOf({ - undownloadedIds: ["a"], - buckets: normalizeBuckets({ downloadedNoTranscript: ["x", "y"] }), - })), - channel("whisper", { handling: "transcribe" }, snapshotOf({ - undownloadedIds: ["b"], - buckets: normalizeBuckets({ downloadedNoTranscript: ["z"] }), - })), - ], - ); - const { download, transcribe } = sections[0]; - assert.deepEqual(download.eligible.sort(), ["whisper", "yt"]); - assert.equal(download.total, 2); - assert.deepEqual(transcribe.eligible, ["whisper"]); - // The youtube channel's two awaiting-whisper videos are NOT in the figure — - // whisper will never run on them. - assert.equal(transcribe.total, 1); -}); - -test("a social channel is eligible for sync only", () => { - const sections = build( - siteOf({ channels: [{ slug: "posts" }] }), - [channel("posts", { sourceKind: "social", handling: "transcribe" })], - ); - const s = sections[0]; - assert.deepEqual(s.sync.eligible, ["posts"]); - assert.deepEqual(s.download.eligible, []); - assert.deepEqual(s.transcribe.eligible, []); - assert.deepEqual(s.digest.eligible, []); - assert.deepEqual(s.speakers.eligible, []); -}); - -test("sync skips a channel with no url", () => { - const sections = build( - siteOf({ channels: [{ slug: "ok" }, { slug: "nourl" }] }), - [channel("ok"), channel("nourl", { url: undefined })], - ); - assert.deepEqual(sections[0].sync.eligible, ["ok"]); - // Download needs a url too. - assert.deepEqual(sections[0].download.eligible.sort(), ["ok"]); -}); - -// The priority document answers the same question the flag does, for the `sync` -// operation specifically — which is what makes the migration lossless: a channel -// paused for sync alone is still drawn by every other station. -test("sync skips a channel paused for the sync operation, and only sync does", () => { - const settings = { - ...LANE_ON, - channelPriority: { - focus: { kind: "none" as const }, - channels: { - pinned: { tier: "normal" as const, overrides: { sync: "paused" as const } }, - off: { tier: "paused" as const }, - }, - }, - } as SiteSettings; - const sections = build( - siteOf({ - channels: [{ slug: "ok" }, { slug: "pinned" }, { slug: "off" }], - }), - [channel("ok"), channel("pinned"), channel("off")], - settings, - ); - assert.deepEqual(sections[0].sync.eligible, ["ok"]); - // A `sync` pin moves nothing else: both are still download candidates. - assert.deepEqual(sections[0].download.eligible.sort(), ["off", "ok", "pinned"]); -}); - -test("speakers reports `off`, not 0, when no operation on the backfill lane is enabled", () => { - const withWork = channel( - "c", - {}, - snapshotOf({ - backfill: { diarization: entry({ missing: 7 }) }, - }), - ); - const on = build(siteOf({ channels: [{ slug: "c" }] }), [withWork], LANE_ON); - assert.equal(on[0].speakers.laneOff, undefined); - assert.equal(on[0].speakers.total, 7); - - const off = build(siteOf({ channels: [{ slug: "c" }] }), [withWork], LANE_OFF); - // The snapshot still records 7 — an off lane is not a finished one, and the - // UI must be able to tell the difference. - assert.equal(off[0].speakers.laneOff, true); - assert.equal(off[0].speakers.total, 0); - assert.deepEqual(off[0].speakers.eligible, []); -}); - -test("digest and speakers totals are disjoint", () => { - const sections = build( - siteOf({ channels: [{ slug: "c" }] }), - [ - channel( - "c", - {}, - snapshotOf({ - backfill: { - // The per-kind map carries EVERY catalog operation, digest included. - digest: entry({ missing: 1200, stale: 4 }), - diarization: entry({ missing: 80, partial: 8 }), - }, - }), - ), - ], - ); - assert.equal(sections[0].digest.total, 1204); - assert.equal(sections[0].speakers.total, 88); -}); - -test("digest counts reachable work from the registry entry", () => { - const sections = build( - siteOf({ channels: [{ slug: "some-work" }] }), - [ - channel( - "some-work", - {}, - // The per-kind entry is the work list. `blocked` and `deferred` are work - // the lane cannot do today and are deliberately not in the total. - snapshotOf({ - backfill: { - digest: entry({ - missing: 2, - stale: 1, - blocked: 4, - deferred: 5, - ids: ["a", "b", "c"], - eligible: 3, - }), - }, - }), - ), - ], - ); - assert.equal(sections[0].digest.total, 3); -}); - -test("digest skips a channel whose report says it has no transcripts", () => { - const sections = build( - siteOf({ channels: [{ slug: "none" }, { slug: "some" }, { slug: "silent" }] }), - [ - channel("none", {}, snapshotOf({ - totals: { videos: 10, transcribed: 0, downloaded: 10 }, - })), - channel("some", {}, snapshotOf({ - totals: { videos: 10, transcribed: 5, downloaded: 10 }, - backfill: { digest: entry({ missing: 5 }) }, - })), - // No report at all cannot claim "no transcripts" — it stays eligible. - channel("silent", {}, null), - ], - ); - const { digest } = sections[0]; - assert.deepEqual(digest.eligible.sort(), ["silent", "some"]); - assert.equal(digest.total, 5); - assert.deepEqual(digest.unknown, ["silent"]); -}); - -test("slugsInGroup buckets the same way the sections do", () => { - const site = siteOf({ - groups: GROUPS, - defaultGroupId: "archives", - channels: [ - { slug: "a", groupId: "archives" }, - { slug: "b", groupId: "extended" }, - { slug: "c", groupId: "no-such-group" }, - { slug: "d" }, - ], - }); - assert.deepEqual(slugsInGroup(site, "archives"), ["a", "c", "d"]); - assert.deepEqual(slugsInGroup(site, "extended"), ["b"]); - assert.deepEqual(slugsInGroup(site, "guests"), []); -}); - -test("slugsInGroup uses the fallback id when the site configures no groups", () => { - const site = siteOf({ channels: [{ slug: "a" }, { slug: "b" }] }); - assert.deepEqual(slugsInGroup(site, "default"), ["a", "b"]); - assert.deepEqual(slugsInGroup(site, "archives"), []); -}); diff --git a/editor/app/channels/lib/channelGroupSections.ts b/editor/app/channels/lib/channelGroupSections.ts @@ -1,321 +0,0 @@ -import type { ChannelBrief, ChannelStat } from "yt-dlp-transcript-common/controller/channels"; -import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; -import { - FALLBACK_GROUP, - resolveChannelGroupId, - sortGroups, - type ChannelGroup, -} from "yt-dlp-transcript-common/lib/channelGroups"; -import { - digestWorkOf, - excludedDownloadIdSet, -} from "yt-dlp-transcript-common/controller/channelSnapshot"; -import { - DIGEST_OPERATION_ID, - allOperations, - backfillLaneOperations, - backfillLaneEntriesOf, - operationsGroupLabel, - reachableOperationWork, -} from "yt-dlp-transcript-common/lib/operations"; -import { - defaultChannelPriority, - isChannelPaused, -} from "yt-dlp-transcript-common/lib/channelPriority"; -import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; -import type { Site } from "yt-dlp-transcript-common/lib/site"; -import { normalizeBuckets } from "yt-dlp-transcript-common/views/pipeline/stageStatus"; - -// Groups a site's channels into the sections /channels renders, and totals each -// section's pipeline work off the SAME snapshot readers the channel page's -// transit line uses — so a group figure can never disagree with the per-channel -// one. -// -// No "use server", no node:fs: imported by both the page (server) and the group -// actions, and unit-tested with plain tsx --test. The actions reuse -// stationWorkFor below, which is what keeps a button from acting on a different -// set than the number printed on it. - -// "speakers" is the backfill LANE's station: every operation sharing -// BACKFILL_QUEUE, run over a group by the lane runner. The id names what the -// station is about (the operations), not the queue — the queue key and the job -// kind behind it still say backfill, on purpose. -export type StationId = "sync" | "download" | "transcribe" | "digest" | "speakers"; - -export type StationWork = { - // Slugs this operation applies to at all. A channel whose report says it has - // nothing to do IS in here — it is skipped at click time, where the read is - // fresh, and dropping it here would make the "+" floor unreadable. - eligible: string[]; - // The sum of ONE reader across `eligible`. - // - // NEVER summed with another station's total. Five separate figures on five - // separate controls, for the reason channelSnapshot.ts documents at length: a - // single "remaining" number puts every channel permanently at the top of - // every list. - total: number; - // Eligible channels with no snapshot at all. `total` is a FLOOR while this is - // non-empty, which the UI marks with a trailing "+". - unknown: string[]; - // digest/speakers only: the lane is switched off in settings. A snapshot's - // counts outlive the feature being switched off, so "off" is rendered instead - // of 0 (which reads as finished) or — (which reads as unknown). - laneOff?: boolean; -}; - -export type ChannelGroupSection = { - group: ChannelGroup; - // Membership order asc (absent sorts last), then slug. - channels: ChannelStat[]; - sync: StationWork; - download: StationWork; - transcribe: StationWork; - digest: StationWork; - speakers: StationWork; - // What the speakers station is called, derived from the operations enabled on - // the backfill lane exactly as the channel page's stage title is — "Speakers" - // once a speaker operation is on, "Derived data" when none is (the default - // test settings), so the button never claims work its lane is not doing. - speakersLabel: string; -}; - -// Whether a whole station is unrunnable because its lane is switched off. -// -// The digest kind declares `enabled: () => true` — it has no master switch, and -// its pause is honoured at DISPATCH precisely so a paused lane still reports -// what is outstanding. So digest is never "off" today; the question is asked of -// the registry rather than hardcoded so that if digest ever gains a real switch, -// this figure stops lying on its own. -export function laneOffFor( - station: StationId, - settings: SiteSettings, -): boolean { - if (station === "digest") { - return !allOperations(settings).some((k) => k.id === DIGEST_OPERATION_ID); - } - if (station === "speakers") return backfillLaneOperations(settings).length === 0; - return false; -} - -export type StationChannelWork = { - // Whether the operation applies to this channel at all. - eligible: boolean; - // How much work its report says there is, or null when it cannot say (no - // snapshot). Null is NOT zero: the figures above it are floors, and a click - // must not skip a channel that never reported. - work: number | null; - // Why the channel is not eligible, for the skip readout. - reason?: string; -}; - -// The single per-channel reader every group figure and every group button goes -// through. One derivation, so the label and the fan-out can never disagree. -export function stationWorkFor( - station: StationId, - // The slug is part of the question now: whether an operation applies to a - // channel is answered by the corpus-wide priority document as well as by the - // channel's own config, and that document is keyed by slug. - brief: Pick<ChannelBrief, "slug" | "config" | "snapshot">, - settings: SiteSettings, -): StationChannelWork { - const { slug, config, snapshot } = brief; - if (laneOffFor(station, settings)) { - return { eligible: false, work: 0, reason: "the lane is switched off" }; - } - const social = isSocialChannel(config); - - if (station === "sync") { - // The same predicate "Sync every channel" applies. No figure: syncAction - // decides per channel whether it is due, so there is no count to promise. - if (!config.url) return { eligible: false, work: 0, reason: "no url" }; - // THE PAUSED SECTION. The tier document is asked for the `sync` OPERATION - // — `isChannelPaused(model, slug, "sync")` — which is precisely what the - // deleted `excludeFromSync` flag meant, read the other way round, and is - // now the only thing asked: S5 deleted the flag and migrated the 15 - // channels that carried it. - // `?? defaultChannelPriority()` for the same reason `isGateHeld` reaches - // its key with optional chaining: this function is handed partial settings - // objects by unit tests and by any caller that has not been through - // `getSettings`, and an absent document means today's behaviour. - const priority = settings.channelPriority ?? defaultChannelPriority(); - if (isChannelPaused(priority, slug, "sync")) { - return { eligible: false, work: 0, reason: "paused for sync" }; - } - return { eligible: true, work: 0 }; - } - - if (social) { - return { eligible: false, work: 0, reason: "social account" }; - } - - if (station === "download") { - if (!config.url) return { eligible: false, work: 0, reason: "no url" }; - if (!snapshot) return { eligible: true, work: null }; - const excluded = excludedDownloadIdSet(snapshot); - return { - eligible: true, - work: (snapshot.undownloadedIds ?? []).filter((id) => !excluded.has(id)) - .length, - }; - } - - if (station === "transcribe") { - // A `youtube`-handling channel never runs whisper, so counting it would - // inflate the figure on a button that would skip it anyway. - if (config.handling !== "transcribe") { - return { eligible: false, work: 0, reason: "not set to transcribe" }; - } - if (!snapshot) return { eligible: true, work: null }; - const excluded = excludedDownloadIdSet(snapshot); - return { - eligible: true, - work: normalizeBuckets(snapshot.buckets).downloadedNoTranscript.filter( - (id) => !excluded.has(id), - ).length, - }; - } - - if (station === "digest") { - // Nothing to digest without transcripts — but a channel that has never - // reported cannot claim it has none, so it stays eligible and unknown. - if (!snapshot) return { eligible: true, work: null }; - if (snapshot.totals.transcribed <= 0) { - return { eligible: false, work: 0, reason: "no transcripts yet" }; - } - // digestWorkOf — the operation registry's entry is the one definition of - // "digested". The `noDigest` bucket it replaced had no cues-staleness gate - // and no transcript gate, so it called deferred and blocked videos done. - return { eligible: true, work: digestWorkOf(snapshot).reachable }; - } - - // Speakers — the backfill lane. backfillLaneEntriesOf, NEVER Object.values: the per-kind map carries - // every catalog operation including digest, which has its own station right - // beside this one. The lane filter is what keeps the two figures disjoint. - if (!snapshot) return { eligible: true, work: null }; - return { - eligible: true, - work: backfillLaneEntriesOf(snapshot.backfill).reduce( - (n, e) => n + reachableOperationWork(e), - 0, - ), - }; -} - -// The groups a site's channels actually fall into, in render order. Mirrors the -// bucketing MCP's list_channels does (handleListChannels), including its -// stray-bucket fallback, so the two never disagree about where a channel lives. -function bucketBySection( - site: Site, - stats: ReadonlyArray<ChannelStat>, -): { group: ChannelGroup; channels: ChannelStat[] }[] { - const hasGroups = site.groups.length > 0; - const order = new Map<string, number>(); - const groupOf = new Map<string, string>(); - for (const m of site.channels) { - if (typeof m.order === "number") order.set(m.slug, m.order); - groupOf.set(m.slug, resolveGroupIdFor(site, m.groupId, hasGroups)); - } - - const byGroup = new Map<string, ChannelStat[]>(); - for (const c of stats) { - // A stat with no membership row cannot happen for a site-scoped list, but - // fold it onto the default rather than dropping the channel. - const gid = - groupOf.get(c.slug) ?? resolveGroupIdFor(site, undefined, hasGroups); - const bucket = byGroup.get(gid) ?? []; - bucket.push(c); - byGroup.set(gid, bucket); - } - - const ordered = hasGroups ? sortGroups(site.groups) : [FALLBACK_GROUP]; - const knownIds = new Set(ordered.map((g) => g.id)); - const sections = ordered - .filter((g) => (byGroup.get(g.id)?.length ?? 0) > 0) - .map((group) => ({ group, channels: byGroup.get(group.id) ?? [] })); - // Defensive: an id resolveChannelGroupId could not fold onto a real group - // (e.g. a defaultGroupId naming a group that is no longer configured) gets a - // synthetic section rather than having its channels silently disappear. - for (const [gid, channels] of byGroup) { - if (knownIds.has(gid)) continue; - sections.push({ group: { ...FALLBACK_GROUP, id: gid }, channels }); - } - - for (const s of sections) { - s.channels.sort((a, b) => { - const ao = order.get(a.slug) ?? Number.POSITIVE_INFINITY; - const bo = order.get(b.slug) ?? Number.POSITIVE_INFINITY; - if (ao !== bo) return ao - bo; - return a.slug.localeCompare(b.slug); - }); - } - return sections; -} - -function resolveGroupIdFor( - site: Site, - groupId: string | undefined, - hasGroups: boolean, -): string { - return hasGroups - ? resolveChannelGroupId(groupId, site.groups, site.defaultGroupId) - : FALLBACK_GROUP.id; -} - -export function buildChannelGroupSections( - site: Site, - stats: ReadonlyArray<ChannelStat>, - briefs: ReadonlyArray<ChannelBrief>, - settings: SiteSettings, -): ChannelGroupSection[] { - const briefBySlug = new Map(briefs.map((b) => [b.slug, b])); - - return bucketBySection(site, stats).map(({ group, channels }) => { - const members = channels - .map((c) => briefBySlug.get(c.slug)) - .filter((b): b is ChannelBrief => !!b); - - const station = (id: StationId): StationWork => { - const work: StationWork = { eligible: [], total: 0, unknown: [] }; - if (laneOffFor(id, settings)) { - work.laneOff = true; - return work; - } - for (const b of members) { - const w = stationWorkFor(id, b, settings); - if (!w.eligible) continue; - work.eligible.push(b.slug); - if (w.work === null) work.unknown.push(b.slug); - else work.total += w.work; - } - return work; - }; - - return { - group, - channels, - sync: station("sync"), - download: station("download"), - transcribe: station("transcribe"), - digest: station("digest"), - speakers: station("speakers"), - speakersLabel: operationsGroupLabel( - backfillLaneOperations(settings).map((k) => k.id), - ), - }; - }); -} - -// The server-action half of the same bucketing: which slugs are in this group. -// Takes no snapshots, so a group button can never act on a different set than -// the rows it sits above — and re-reading it at click time keeps it correct if -// membership changed since the page rendered. -export function slugsInGroup(site: Site, groupId: string): string[] { - const hasGroups = site.groups.length > 0; - const out: string[] = []; - for (const m of site.channels) { - if (resolveGroupIdFor(site, m.groupId, hasGroups) === groupId) { - out.push(m.slug); - } - } - return out; -} diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -37,7 +37,7 @@ import { type ChannelRow, type PipelineColumn, } from "./components/ChannelsTable"; -import { buildChannelGroupSections } from "./lib/channelGroupSections"; +import { buildChannelGroupSections } from "yt-dlp-transcript-common/views/channelGroupSections"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton"; import { reportStateOf } from "../lib/actionable/loadActionable"; diff --git a/editor/app/components/lanes/LaneCard.tsx b/editor/app/components/lanes/LaneCard.tsx @@ -8,7 +8,7 @@ import { LANE_TEXT, LANE_WORD, type LaneState, -} from "./laneState"; +} from "yt-dlp-transcript-common/views/laneState"; import { LaneActionButton, type LaneActionResult } from "./LaneActionButton"; // One lane: its name, its state, its figure and BOTH its switches, in one place. diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx @@ -8,7 +8,7 @@ import type { WorkersPayload } from "../../workers/components/WorkersView"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { useSectionFrame } from "../../widget/components/WidgetSection"; import { LaneCard, type LaneControl } from "./LaneCard"; -import { deriveLaneState, formatCount } from "./laneState"; +import { deriveLaneState, formatCount } from "yt-dlp-transcript-common/views/laneState"; import { pauseLaneControl } from "./pauseControl"; import { armLaneAction, disarmLaneAction } from "../../jobs/actions"; diff --git a/editor/app/components/lanes/LaneRail.tsx b/editor/app/components/lanes/LaneRail.tsx @@ -1,4 +1,4 @@ -import type { LaneState } from "./laneState"; +import type { LaneState } from "yt-dlp-transcript-common/views/laneState"; // The signature element: a hairline that reads left → right as FEED → GATE → // LANE. It exists to say the thing the status word cannot — *where* the flow diff --git a/editor/app/components/lanes/laneState.ts b/editor/app/components/lanes/laneState.ts @@ -1,79 +0,0 @@ -import { - STATION_DOT, - STATION_TEXT, - formatCount, -} from "yt-dlp-transcript-common/views/pipeline/tone"; - -// A lane's state, and the one derivation every surface reads it from. -// -// THE POINT OF THIS TYPE IS THAT A LANE HAS TWO SWITCHES, NOT ONE. There is the -// RUNNER — is anything dispatching at all — and the GATE — the pause that -// decides whether the lane may consume what it picks. They are independent, and -// the state nobody had a word for is the combination "runner up, gate shut": -// the lane is not stopped and it is not working. That is `holding`, and it is -// the whole reason this is four states rather than a boolean. -// -// Every lane's gate reaches the wire as `held`, computed once by isGateHeld -// (common/lib/pauseGates.ts); no reader inverts. -// -// THE `feedRunning` AXIS RETIRED WITH THE SWEEPS in slice 1.3. It meant "a -// corpus-wide sweep is armed", which was a fact only two of the four lanes had; -// all four are dispatched by a runner now, so a caller with no work in flight is -// `idle` on the same terms auto-transcribe always was. -export type LaneState = "running" | "holding" | "idle" | "unavailable"; - -export function deriveLaneState({ - available = true, - gateHeld, - activeCount = 0, -}: { - // The lane exists at all. `false` is not "idle": with no operation registered - // there is nothing to hold, and an idle-looking lane would read as "all caught - // up" when the truth is "switched off". - available?: boolean; - gateHeld: boolean; - // Work in flight right now. - activeCount?: number; -}): LaneState { - if (!available) return "unavailable"; - // The gate decides first. A running lane behind a shut gate is HOLDING, not - // running — its limit() returns 0 and the pool idle-waits, which looks exactly - // like wedged unless something says otherwise. - if (gateHeld) return "holding"; - if (activeCount > 0) return "running"; - return "idle"; -} - -// No new palette. These map onto the station tones the channel line already -// uses (see flow/tone.ts): four theme families × light/dark means a bespoke hue -// here would be wrong in eight palettes at once. Deliberately NOT a second copy -// of those maps — flow/OverviewPanel already made one, and three would be a -// guarantee they drift. -export const LANE_DOT: Record<LaneState, string> = { - // The travelling pulse, paired with motion-reduce as everything animated here - // is. - running: `${STATION_DOT.running} motion-reduce:animate-none`, - holding: STATION_DOT.attention, - idle: STATION_DOT.neutral, - unavailable: STATION_DOT.neutral, -}; - -export const LANE_TEXT: Record<LaneState, string> = { - running: STATION_TEXT.running, - holding: STATION_TEXT.attention, - idle: STATION_TEXT.neutral, - unavailable: STATION_TEXT.neutral, -}; - -// The word beside the dot. This — not the rail — is what assistive tech reads, -// so it has to stand on its own. -export const LANE_WORD: Record<LaneState, string> = { - running: "Running", - holding: "Holding", - idle: "Idle", - unavailable: "Off", -}; - -// Re-exported so a lane figure formats its counts the same way a station does, -// including the "—, never 0" rule: a zero would claim a measurement nobody took. -export { formatCount }; diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts @@ -24,7 +24,7 @@ import { diskGate, type DiskGateReason, } from "yt-dlp-transcript-common/lib/diskSpace"; -import type { JobRowView } from "../jobRowView"; +import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { fromEntry, fromRecord, @@ -32,11 +32,11 @@ import { orderLiveRows, reconcileSlots, RECENT_MS, -} from "../jobRows"; +} from "yt-dlp-transcript-common/views/jobRows"; // Directive-free and value-import-free by construction (laneState.ts imports // only tone maps and a type), so a server module can read the same derivation // the client rail does. -import { deriveLaneState } from "../../components/lanes/laneState"; +import { deriveLaneState } from "yt-dlp-transcript-common/views/laneState"; import { autoRunnerJobKind, getAutoRunnerStatus, diff --git a/editor/app/jobs/components/JobProgressBars.tsx b/editor/app/jobs/components/JobProgressBars.tsx @@ -6,7 +6,7 @@ import type { JobProgressMetric, JobTaskKind, } from "yt-dlp-transcript-common/jobs/registry"; -import type { JobRowTask, JobRowView } from "../jobRowView"; +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 diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx @@ -3,8 +3,8 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import type { ActiveJobsPayload } from "../active/buildActiveJobs"; -import type { JobRowView } from "../jobRowView"; -import { isLive, mergeJobRows } from "../jobRows"; +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"; diff --git a/editor/app/jobs/components/LaneStrip.tsx b/editor/app/jobs/components/LaneStrip.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import type { ActiveLaneView } from "../active/buildActiveJobs"; -import type { JobRowView } from "../jobRowView"; -import { LANE_DOT, LANE_TEXT, LANE_WORD } from "../../components/lanes/laneState"; +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"; diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useState } from "react"; -import type { JobRowView } from "../jobRowView"; +import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { JobLogTail } from "../[id]/components/JobLogTail"; import { jobKindLabel } from "../jobKindLabels"; import { DrainJobButton } from "./DrainJobButton"; diff --git a/editor/app/jobs/jobRowView.ts b/editor/app/jobs/jobRowView.ts @@ -1,94 +0,0 @@ -import type { - JobStatus, - JobProgressMetric, - JobTaskKind, -} from "yt-dlp-transcript-common/jobs/registry"; - -// A ROW IS ONE JOB, whichever of three places knows about it: the registry (a -// live JobRecord), the .jobs directory (a .log + .meta.json sidecar the -// registry has forgotten), or the scheduler (a slot whose record was evicted — -// a phantom, and the one row with no id the registry can answer for). Three -// surfaces used to draw three shapes; every field name the card renderer's row -// type had is kept, so the widget and the dashboard read what they read before. -// TYPES ONLY: this module is imported from "use client" files, and the builder -// that fills it (active/buildActiveJobs.ts) reaches the registry and the -// filesystem. - -export type JobRowStatus = JobStatus | "archived" | "evicted"; - -export type StuckReason = - | "record-evicted" - | "terminal-but-holding-slot" - | "possibly-stalled"; - -export type JobRowTask = { - id: string; - label: string; - // Imported for the same reason as `metric` below: a re-spelled literal here - // would not fail the build when JobTaskKind grew a member. - kind: JobTaskKind; - fraction?: number; - detail?: string; - // Epoch ms when this sub-operation started, for the live "running for" timer. - startedAt: number; - // The worker running this transcription (transcribe tasks only). - workerId?: string; - workerName?: string; - // Audio-integrity probe state (download tasks only). When phase is "probing" - // yt-dlp is paused for an ffmpeg integrity check; the bar fills against the - // estimated probe duration instead of the (frozen) download fraction. - phase?: "downloading" | "probing"; - probeStartedAt?: number; - probeEtaSeconds?: number; -}; - -export type JobRowProgress = { - // Imported, NOT re-spelled: a literal copy here silently drifted from - // JobProgressMetric and would not fail the build when the union grew. - metric: JobProgressMetric; - initial: number; - current: number; - target: number; - pct: number; - // Estimated seconds remaining (remaining tasks × measured avg per task). - // Undefined until the first task completes, or when no work remains. - etaSeconds?: number; -}; - -export type JobRowView = { - id: string; - kind: string; // "" for an old log with no sidecar; the cell renders "—" - status: JobRowStatus; - queueKey?: string; // undefined = unknown (old log); "" = parallel ("(immediate)") - channelSlug?: string; - videoId?: string; - progress?: JobRowProgress; - tasks?: JobRowTask[]; - draining?: boolean; - drainable?: boolean; - // Background work (e.g. an auto-download unit) queues BEHIND a manual job on - // the same platform queue. Shown as an "auto" badge, and — when queued — as a - // hint that a foreground job (a clicked Sync) is being let through first. - background?: boolean; - // Reorder affordances for a QUEUED job: whether it can move toward (up/promote) - // or away from (down) the head of its queue. Both false for running jobs and - // for parallel ("") jobs that aren't serialized. - canMoveUp?: boolean; - canMoveDown?: boolean; - // The history side (listJobs.ts). - queuedAt?: number; - startedAt?: number; - endedAt?: number; - exitCode?: number; - logSize?: number; - inRegistry?: boolean; - replayable?: boolean; - // The slot side (the scheduler's view of the same id). - position?: number; // 0 = running head, 1.. = queued; absent for parallel/history - heldMs?: number; - stuck?: { reason: StuckReason; heldMs: number }; - pid?: number; - lastLogLine?: string; // stuck rows only — see readLastLogLine - // Which adapter built it. Never rendered; tests and the merge read it. - source: "registry" | "archive" | "slot"; -}; diff --git a/editor/app/jobs/jobRows.test.ts b/editor/app/jobs/jobRows.test.ts @@ -1,342 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { newJobId, type JobRecord } from "yt-dlp-transcript-common/jobs/registry"; -import type { JobListEntry } from "yt-dlp-transcript-common/jobs/listJobs"; -import { createScheduler } from "yt-dlp-transcript-common/jobs/scheduler"; -import { - fromEntry, - fromRecord, - fromSlot, - isLive, - mergeJobRows, - orderLiveRows, - reconcileSlots, - type FromRecordContext, -} from "./jobRows"; - -// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" -// -// The adapters, the order and the merge — no registry, no scheduler, no disk. -// Records are built the way common/jobs/registry.test.ts builds them: a literal -// JobRecord with a fresh id. - -function rec(over: Partial<JobRecord> = {}): JobRecord { - return { - id: newJobId(), - kind: "whisper-all", - queueKey: "q", - status: "running", - queuedAt: 1_000, - logPath: "", - ...over, - }; -} - -const ctx = (over: Partial<FromRecordContext> = {}): FromRecordContext => ({ - now: 10_000, - workerNames: new Map(), - position: -1, - queueMax: 0, - ...over, -}); - -test("fromRecord carries every field the card renderer used to read", () => { - const j = rec({ - channelSlug: "a", - videoId: "v1", - startedAt: 2_000, - background: true, - tasks: [ - { - id: "v1", - label: "One", - kind: "transcribe", - startedAt: 3_000, - workerId: "w1", - }, - ], - }); - const row = fromRecord(j, ctx({ workerNames: new Map([["w1", "Only"]]) })); - assert.equal(row.id, j.id); - assert.equal(row.kind, "whisper-all"); - assert.equal(row.status, "running"); - assert.equal(row.queueKey, "q"); - assert.equal(row.channelSlug, "a"); - assert.equal(row.videoId, "v1"); - assert.equal(row.background, true); - assert.equal(row.draining, false); - assert.equal(row.tasks?.[0].workerName, "Only"); - assert.equal(row.inRegistry, true); - assert.equal(row.source, "registry"); - assert.equal(row.startedAt, 2_000); - assert.equal(row.queuedAt, 1_000); -}); - -test("fromRecord: drainable needs running, a drainable kind and no drain", () => { - assert.equal(fromRecord(rec(), ctx()).drainable, true); - assert.equal(fromRecord(rec({ draining: true }), ctx()).drainable, false); - assert.equal(fromRecord(rec({ status: "queued" }), ctx()).drainable, false); - // check-availability has no jobKinds entry, so it is not drainable. - assert.equal( - fromRecord(rec({ kind: "check-availability" }), ctx()).drainable, - false, - ); -}); - -test("fromRecord: reorder bounds come from position and queueMax", () => { - const queued = (position: number, queueMax: number) => - fromRecord(rec({ status: "queued" }), ctx({ position, queueMax })); - // Position 1 is the first QUEUED job (0 is the running head): nowhere up. - assert.equal(queued(1, 3).canMoveUp, false); - assert.equal(queued(2, 3).canMoveUp, true); - assert.equal(queued(3, 3).canMoveDown, false); - assert.equal(queued(2, 3).canMoveDown, true); - // A parallel ("") job is in no queue at all. - const parallel = fromRecord(rec({ status: "queued", queueKey: "" }), ctx()); - assert.equal(parallel.canMoveUp, false); - assert.equal(parallel.canMoveDown, false); - assert.equal(parallel.position, undefined); -}); - -test("fromRecord: no channel stat means no progress bar, and spec means replayable", () => { - const j = rec({ - channelSlug: "a", - progress: { metric: "transcripts", initial: 0, target: 10 }, - }); - assert.equal(fromRecord(j, ctx()).progress, undefined); - const withStat = fromRecord( - j, - ctx({ - stat: { - config: { name: "A" }, - transcriptCount: 5, - downloadCount: 0, - } as unknown as NonNullable<FromRecordContext["stat"]>, - }), - ); - assert.equal(withStat.progress?.current, 5); - assert.equal(withStat.progress?.pct, 50); - assert.equal(fromRecord(rec(), ctx()).replayable, false); - assert.equal( - fromRecord(rec({ spec: { kind: "whisper-all" } as never }), ctx()) - .replayable, - true, - ); -}); - -test("fromEntry keeps the history fields, archived included", () => { - const e: JobListEntry = { - id: newJobId(), - kind: "sync", - channelSlug: "a", - status: "archived", - queuedAt: 1, - endedAt: 9, - inRegistry: false, - replayable: true, - logPath: "/x.log", - logSize: 4096, - }; - const row = fromEntry(e); - assert.equal(row.status, "archived"); - assert.equal(row.logSize, 4096); - assert.equal(row.inRegistry, false); - assert.equal(row.replayable, true); - assert.equal(row.source, "archive"); - // An old log with no sidecar has no kind at all; the cell renders "—". - assert.equal(fromEntry({ ...e, kind: undefined }).kind, ""); -}); - -test("orderLiveRows: running, then queued IN QUEUE ORDER, then what just ended", () => { - const rows = orderLiveRows([ - { id: "q2", kind: "", status: "queued", queueKey: "q", position: 2, source: "registry" }, - { id: "run", kind: "", status: "running", queuedAt: 5, source: "registry" }, - { id: "q1", kind: "", status: "queued", queueKey: "q", position: 1, source: "registry" }, - { id: "done", kind: "", status: "done", endedAt: 99, source: "registry" }, - ]); - assert.deepEqual( - rows.map((r) => r.id), - ["run", "q1", "q2", "done"], - ); -}); - -test("orderLiveRows: two queues interleave by name, then by position", () => { - const rows = orderLiveRows([ - { id: "b1", kind: "", status: "queued", queueKey: "qB", position: 1, source: "registry" }, - { id: "a2", kind: "", status: "queued", queueKey: "qA", position: 2, source: "registry" }, - { id: "a1", kind: "", status: "queued", queueKey: "qA", position: 1, source: "registry" }, - ]); - assert.deepEqual( - rows.map((r) => r.id), - ["a1", "a2", "b1"], - ); -}); - -test("orderLiveRows: two running jobs keep registry order (newest queued first)", () => { - const rows = orderLiveRows([ - { id: "old", kind: "", status: "running", queuedAt: 1, source: "registry" }, - { id: "new", kind: "", status: "running", queuedAt: 2, source: "registry" }, - ]); - assert.deepEqual( - rows.map((r) => r.id), - ["new", "old"], - ); -}); - -test("mergeJobRows: the live row wins by id, and the head stays first", () => { - const live = [ - { - id: "A", - kind: "sync", - status: "running" as const, - progress: { metric: "transcripts" as const, initial: 0, current: 1, target: 2, pct: 50 }, - source: "registry" as const, - }, - { id: "B", kind: "sync", status: "done" as const, endedAt: 5, source: "registry" as const }, - ]; - const history = [ - { id: "A", kind: "sync", status: "running" as const, logSize: 10, source: "archive" as const }, - { id: "B", kind: "sync", status: "done" as const, logSize: 10, source: "archive" as const }, - { id: "C", kind: "sync", status: "failed" as const, logSize: 10, source: "archive" as const }, - ]; - const rows = mergeJobRows(live, history); - assert.deepEqual( - rows.map((r) => r.id), - ["A", "B", "C"], - ); - // One <tr> per job, and the one that is drawn is the one with progress. - assert.equal(rows[0].source, "registry"); - assert.equal(rows[0].progress?.pct, 50); - // A recent row stays ahead of the history page that also lists it. - assert.equal(rows[1].source, "registry"); -}); - -test("isLive: anything non-terminal, plus a stuck or evicted slot", () => { - assert.equal(isLive({ id: "1", kind: "", status: "running", source: "registry" }), true); - assert.equal(isLive({ id: "1", kind: "", status: "queued", source: "registry" }), true); - assert.equal(isLive({ id: "1", kind: "", status: "evicted", source: "slot" }), true); - assert.equal(isLive({ id: "1", kind: "", status: "done", source: "archive" }), false); - assert.equal( - isLive({ - id: "1", - kind: "", - status: "done", - stuck: { reason: "terminal-but-holding-slot", heldMs: 1 }, - source: "registry", - }), - true, - ); -}); - -// --- The scheduler against the registry ------------------------------------- - -function scheduledQueue(): { - scheduler: ReturnType<typeof createScheduler>; - ids: string[]; -} { - const scheduler = createScheduler(); - const ids = [newJobId(), newJobId(), newJobId()]; - for (const id of ids) { - scheduler.submit({ - id, - queueKey: "qFacts", - tier: "foreground", - concurrency: 1, - start: () => {}, - onCancel: () => {}, - }); - } - return { scheduler, ids }; -} - -test("reconcileSlots: a terminal record holding a running slot is hard-stuck", () => { - const { scheduler, ids } = scheduledQueue(); - const [a, b, c] = ids; - const now = 10_000_000; - const records = new Map<string, JobRecord>([ - [a, rec({ id: a, queueKey: "qFacts", status: "done", startedAt: now - 5_000 })], - [b, rec({ id: b, queueKey: "qFacts", status: "queued" })], - [c, rec({ id: c, queueKey: "qFacts", status: "queued" })], - ]); - const facts = reconcileSlots(scheduler.queues(), (id) => records.get(id), now); - const byId = new Map(facts.map((f) => [f.id, f])); - assert.equal(byId.get(a)?.stuck?.reason, "terminal-but-holding-slot"); - assert.equal(byId.get(a)?.heal, true); - assert.equal(byId.get(b)?.position, 1); - assert.equal(byId.get(c)?.position, 2); - assert.equal(byId.get(b)?.stuck, undefined); - assert.equal(byId.get(c)?.stuck, undefined); - assert.equal(facts.filter((f) => f.heal).length, 1); - - // Healing the hard cases promotes the next in line. - facts.filter((f) => f.heal).forEach((f) => scheduler.complete(f.id)); - assert.deepEqual(scheduler.queues()[0].running, [b]); -}); - -test("reconcileSlots: an evicted record is hard-stuck; a quiet live one is soft", () => { - const now = 10_000_000; - - const evicted = scheduledQueue(); - const gone = reconcileSlots( - evicted.scheduler.queues(), - () => undefined, - now, - ); - assert.equal(gone[0].stuck?.reason, "record-evicted"); - assert.equal(gone[0].heal, true); - - const stalled = scheduledQueue(); - const idA = stalled.ids[0]; - const soft = reconcileSlots( - stalled.scheduler.queues(), - (id) => - id === idA - ? rec({ - id: idA, - queueKey: "qFacts", - status: "running", - startedAt: now - 11 * 60 * 1000, - tasks: [], - }) - : undefined, - now, - ); - assert.equal(soft[0].stuck?.reason, "possibly-stalled"); - // SOFT: surfaced and force-releasable, never auto-healed. - assert.equal(soft[0].heal, false); - - const busy = scheduledQueue(); - const idB = busy.ids[0]; - const fresh = reconcileSlots( - busy.scheduler.queues(), - (id) => - id === idB - ? rec({ - id: idB, - queueKey: "qFacts", - status: "running", - startedAt: now - 60 * 1000, - tasks: [], - }) - : undefined, - now, - ); - assert.equal(fresh[0].stuck, undefined); - assert.equal(fresh[0].heal, false); -}); - -test("fromSlot builds the phantom, and a stuck row sorts above a running one", () => { - const { scheduler } = scheduledQueue(); - const facts = reconcileSlots(scheduler.queues(), () => undefined, 10_000_000); - const phantom = fromSlot(facts[0], "whisper-all"); - assert.equal(phantom.status, "evicted"); - assert.equal(phantom.source, "slot"); - assert.equal(phantom.stuck?.reason, "record-evicted"); - assert.equal(phantom.queueKey, "qFacts"); - - const ordered = orderLiveRows([ - { id: "run", kind: "", status: "running", queuedAt: 9, source: "registry" }, - phantom, - ]); - assert.equal(ordered[0].id, phantom.id); -}); diff --git a/editor/app/jobs/jobRows.ts b/editor/app/jobs/jobRows.ts @@ -1,331 +0,0 @@ -import { isDrainableKind } from "yt-dlp-transcript-common/jobs/jobKinds"; -import { jobIdTime } from "yt-dlp-transcript-common/jobs/ulid"; -import type { JobRecord } from "yt-dlp-transcript-common/jobs/registry"; -import type { JobListEntry } from "yt-dlp-transcript-common/jobs/listJobs"; -import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels"; -import type { QueueView } from "yt-dlp-transcript-common/jobs/scheduler"; -import type { JobRowView, StuckReason } from "./jobRowView"; - -// THE ADAPTERS, THE ORDER AND THE MERGE — pure, and directive-free by -// construction so both the server builder and the "use client" table can import -// them. The precedent is components/lanes/laneState.ts: its only value imports -// are things with no imports of their own, so nothing drags the registry, the -// scheduler or `fs` into the client bundle. Here that is isDrainableKind -// (common/jobs/jobKinds.ts, zero imports, already in the client graph via -// jobKindLabels.ts) and jobIdTime (common/jobs/ulid.ts, zero imports). -// Everything else is `import type`. - -// How long a finished job stays in the LIVE head. Long enough for the 5 s pulse -// to re-render the tail with the same row twice over; short enough that the head -// is still "now". -export const RECENT_MS = 30_000; - -// A running slot is treated as "possibly stalled" (the soft case) once a -// running/draining record has held its slot this long with no in-flight tasks. -// Distinct from the hard cases (terminal-but-holding-slot / record-evicted), -// which are always safe to auto-heal; the soft case is only surfaced/force- -// releasable, never auto-healed. -const STUCK_AGE_MS = 10 * 60 * 1000; - -const TERMINAL = new Set(["done", "failed", "cancelled"]); - -export type SlotFact = { - id: string; - queueKey: string; - role: "running" | "queued"; - position: number; - heldMs: number; - stuck?: { reason: StuckReason; heldMs: number }; - heal: boolean; -}; - -// THE SCHEDULER AGAINST THE REGISTRY, as facts. The scheduler is the source of -// truth for SLOTS and still references ids whose record is terminal or gone; -// the registry is the source of truth for STATUS and never sees a slot. Pure: -// it takes the queues snapshot and a lookup, reports what it found, and does -// not heal — the builder heals, AFTER the rows are built, so a stuck row is -// drawn once before it vanishes. The classification is the queue view's, -// unchanged: -// record-evicted and terminal-but-holding-slot are hard (heal); possibly-stalled -// (a live record idle past STUCK_AGE_MS) is soft — surfaced, force-releasable, -// never healed, since it may be a genuinely long operation. -export function reconcileSlots( - views: QueueView[], - get: (id: string) => JobRecord | undefined, - now: number, -): SlotFact[] { - const facts: SlotFact[] = []; - for (const v of views) { - const ids = [...v.running, ...v.queued]; - for (const [position, id] of ids.entries()) { - const role: "running" | "queued" = - position < v.running.length ? "running" : "queued"; - const rec = get(id); - const heldMs = now - (rec?.startedAt ?? rec?.queuedAt ?? now); - let stuck: { reason: StuckReason; heldMs: number } | undefined; - let heal = false; - if (role === "running") { - if (!rec) { - // Scheduler references an id the registry evicted while it still - // holds a running slot — a stuck slot with no live record to - // describe it. - stuck = { reason: "record-evicted", heldMs }; - heal = true; - } else if (TERMINAL.has(rec.status)) { - stuck = { reason: "terminal-but-holding-slot", heldMs }; - heal = true; - } else { - const ageMs = now - (rec.startedAt ?? rec.queuedAt); - const idle = !rec.tasks || rec.tasks.length === 0; - if (idle && ageMs > STUCK_AGE_MS) { - // Soft case: a live running/draining record sitting idle far too - // long. Force-releasable, but deliberately NOT auto-healed. - stuck = { reason: "possibly-stalled", heldMs: ageMs }; - } - } - } - facts.push({ id, queueKey: v.name, role, position, heldMs, stuck, heal }); - } - } - return facts; -} - -// THE PHANTOM: a slot whose record the registry no longer has. It is the one -// row with no id the registry can answer for, so everything it knows comes from -// the slot itself plus the on-disk meta sidecar the caller resolved the kind -// from. -export function fromSlot( - f: SlotFact, - kind: string, - lastLogLine?: string, -): JobRowView { - return { - id: f.id, - kind, - status: "evicted", - queueKey: f.queueKey, - queuedAt: jobIdTime(f.id) || undefined, - position: f.position, - heldMs: f.heldMs, - stuck: f.stuck, - lastLogLine, - source: "slot", - }; -} - -// Estimate seconds remaining as: remaining tasks × average measured task -// duration, converted to wall-clock via the effective concurrency observed so -// far. Returns undefined until at least one task has completed (no average -// yet) or when there's no work left. `now` is injected so the same snapshot is -// used across all jobs in a single payload build. -function computeEtaSeconds( - job: JobRecord, - remaining: number, - now: number, - remainingAudioSeconds?: number, -): number | undefined { - const count = job.completedTaskCount ?? 0; - const totalMs = job.completedTaskMs ?? 0; - if (count < 1 || remaining <= 0 || job.startedAt === undefined) { - return undefined; - } - // Prefer an AUDIO-HOUR estimate where the work is proportional to length. - // Averaging tasks assumes every unit costs about the same, which is true for - // downloads and wildly false for digests: this corpus is ~77k videos and ~77k - // audio-hours, and a channel of 9-hour VODs and a channel of 10-minute clips - // have the same task count and a 50x difference in cost. A task average would - // therefore quote an ETA that is wrong by more than an order of magnitude at - // exactly the moment an operator most needs it — the start of an 80-day run. - const doneAudio = job.completedTaskAudioSeconds ?? 0; - if (doneAudio > 0 && remainingAudioSeconds && remainingAudioSeconds > 0) { - const secondsPerAudioSecond = totalMs / 1000 / doneAudio; - const elapsedMs = Math.max(1, now - job.startedAt); - const concurrency = Math.max(1, totalMs / elapsedMs); - return (remainingAudioSeconds * secondsPerAudioSecond) / concurrency; - } - const avgProcMs = totalMs / count; // measured average per task - const elapsedMs = Math.max(1, now - job.startedAt); - const concurrency = Math.max(1, totalMs / elapsedMs); // effective parallelism - // Round up to whole parallel waves: the final wave runs its tasks - // concurrently, so e.g. 2 tasks left across 4 workers still costs ~one full - // task — not "half a task". Without the ceil the tail is underestimated. - const waves = Math.ceil(remaining / concurrency); - return (waves * avgProcMs) / 1000; -} - -function computeJobProgressView( - job: JobRecord, - stat: ChannelStat | undefined, - now: number, -): JobRowView["progress"] { - const snap = job.progress; - if (!snap || !stat) return undefined; - // `current` is RE-COUNTED from disk (readChannelStat), never reported by the - // runner — which is why the digest metric needed its own on-disk counter - // (digestCount) rather than a number the batch could have just told us. - // A runner-reported `current` WINS. The disk re-count below cannot see a - // regeneration — a regenerated digest is rewritten in place, so the file - // count never moves and the bar sits at 0% for the whole job. Only the runner - // knows it did the work. Downloads and transcripts report nothing and keep - // the disk re-count, unchanged. - const current = - snap.current ?? - (snap.metric === "downloads" - ? stat.downloadCount - : snap.metric === "digests" - ? (stat.digestCount ?? 0) - : stat.transcriptCount); - const range = Math.max(0, snap.target - snap.initial); - const advance = Math.max(0, current - snap.initial); - const pct = - range === 0 ? 100 : Math.max(0, Math.min(100, (advance / range) * 100)); - const remaining = Math.max(0, snap.target - current); - return { - metric: snap.metric, - initial: snap.initial, - current, - target: snap.target, - pct, - etaSeconds: computeEtaSeconds( - job, - remaining, - now, - snap.remainingAudioSeconds, - ), - }; -} - -export type FromRecordContext = { - now: number; - stat?: ChannelStat; - workerNames: ReadonlyMap<string, string>; - // 0 = running head, 1.. = queued; -1 for a parallel ("") job the scheduler - // never tracked. - position: number; - // The last index in this record's queue, for the reorder bounds. - queueMax: number; -}; - -// The live adapter: a registry record as a row. -export function fromRecord(j: JobRecord, ctx: FromRecordContext): JobRowView { - return { - id: j.id, - kind: j.kind, - status: j.status, - queueKey: j.queueKey, - channelSlug: j.channelSlug, - videoId: j.videoId, - progress: - j.status === "running" && j.channelSlug - ? computeJobProgressView(j, ctx.stat, ctx.now) - : undefined, - tasks: j.tasks?.map((t) => ({ - id: t.id, - label: t.label, - kind: t.kind, - fraction: t.fraction, - detail: t.detail, - startedAt: t.startedAt, - workerId: t.workerId, - workerName: t.workerId ? ctx.workerNames.get(t.workerId) : undefined, - phase: t.phase, - probeStartedAt: t.probeStartedAt, - probeEtaSeconds: t.probeEtaSeconds, - })), - draining: j.draining === true, - drainable: - j.status === "running" && isDrainableKind(j.kind) && j.draining !== true, - background: j.background === true, - // A queued job can move up/promote if it isn't the first queued (position > - // 1, since the running head is at 0), and down if it isn't the last in its - // queue. Running and parallel ("") jobs are never reorderable. - canMoveUp: j.status === "queued" && ctx.position > 1, - canMoveDown: - j.status === "queued" && - ctx.position >= 1 && - ctx.position < ctx.queueMax, - queuedAt: j.queuedAt, - startedAt: j.startedAt, - endedAt: j.endedAt, - exitCode: j.exitCode, - inRegistry: true, - replayable: Boolean(j.spec), - position: ctx.position >= 0 ? ctx.position : undefined, - source: "registry", - }; -} - -// The history adapter: a .log + sidecar entry as a row. Field for field — -// listJobs already resolved the registry record where there was one. -export function fromEntry(e: JobListEntry): JobRowView { - return { - id: e.id, - kind: e.kind ?? "", - status: e.status, - queueKey: e.queueKey, - channelSlug: e.channelSlug, - videoId: e.videoId, - queuedAt: e.queuedAt, - startedAt: e.startedAt, - endedAt: e.endedAt, - exitCode: e.exitCode, - logSize: e.logSize, - inRegistry: e.inRegistry, - replayable: e.replayable, - source: "archive", - }; -} - -function liveRank(r: JobRowView): number { - if (r.stuck) return 0; - if (r.status === "running") return 1; - if (r.status === "queued") return 2; - return 3; -} - -// The head's order: what is wrong first, then what is working, then what is -// waiting IN THE ORDER IT WILL RUN, then what just finished. -// -// Queued rows sort by (queue name, position) rather than by recency — finding 8 -// of plans/editor-ia-slice-8c.md. That is a change: the Active Jobs screen -// ordered queued jobs by the registry's queuedAt, which put the job that will -// run next anywhere in the list. "2nd in line" in the Queue cell only means anything if -// the rows are in line. -export function orderLiveRows(rows: JobRowView[]): JobRowView[] { - return [...rows].sort((a, b) => { - const ra = liveRank(a); - const rb = liveRank(b); - if (ra !== rb) return ra - rb; - if (ra === 0) return (b.heldMs ?? 0) - (a.heldMs ?? 0); - // Running: registry order, newest queued first — unchanged. - if (ra === 1) return (b.queuedAt ?? 0) - (a.queuedAt ?? 0); - if (ra === 2) { - const byQueue = (a.queueKey ?? "").localeCompare(b.queueKey ?? ""); - if (byQueue !== 0) return byQueue; - return (a.position ?? 0) - (b.position ?? 0); - } - return (b.endedAt ?? 0) - (a.endedAt ?? 0); - }); -} - -// LIVE WINS BY ID: the same job is in the registry and on disk at once, and its -// live row is the one with progress. The head keeps its order; the tail follows -// in the order the directory listing gave it, minus anything already above. -export function mergeJobRows( - live: JobRowView[], - history: JobRowView[], -): JobRowView[] { - const seen = new Set(live.map((r) => r.id)); - return [...live, ...history.filter((r) => !seen.has(r.id))]; -} - -// Is this row still moving? Drives the 1 s poll: a page with nothing live makes -// no requests at all. -export function isLive(r: JobRowView): boolean { - return ( - r.status === "running" || - r.status === "queued" || - r.status === "evicted" || - Boolean(r.stuck) - ); -} diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx @@ -15,7 +15,7 @@ import { useHydrated, useOperationsStatus } from "./useOperationsStatus"; import { RUNNER_TITLE, type Channel } from "./dispatch"; import { StateBand } from "../../components/pipelines/StateBand"; import { RunningJobsList } from "../../jobs/components/RunningJobsList"; -import type { JobRowView } from "../../jobs/jobRowView"; +import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; // ONE OPERATION, IN FULL. // diff --git a/editor/app/operations/components/OperationRail.tsx b/editor/app/operations/components/OperationRail.tsx @@ -10,8 +10,8 @@ import { BandLegend, StateBand, } from "../../components/pipelines/StateBand"; -import type { LaneState } from "../../components/lanes/laneState"; -import { LANE_DOT, LANE_TEXT, LANE_WORD } from "../../components/lanes/laneState"; +import type { LaneState } from "yt-dlp-transcript-common/views/laneState"; +import { LANE_DOT, LANE_TEXT, LANE_WORD } from "yt-dlp-transcript-common/views/laneState"; // A TYPE IMPORT AND IT HAS TO STAY ONE: syncRow.ts is a server module (it // reaches the job registry and the scheduler status payload). The row's own // label rides in on the value. diff --git a/editor/app/operations/components/railStates.ts b/editor/app/operations/components/railStates.ts @@ -1,6 +1,6 @@ import type { AutoQueueStatusPayload } from "../status"; import { LANES, type AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes"; -import { deriveLaneState } from "../../components/lanes/laneState"; +import { deriveLaneState } from "yt-dlp-transcript-common/views/laneState"; import type { RailLaneState } from "./OperationRail"; import { idleReasonText } from "./dispatch"; diff --git a/editor/app/widget/builder/page.tsx b/editor/app/widget/builder/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { readWidgetPresets } from "yt-dlp-transcript-common/lib/widgetPresets"; -import { BUILT_IN_PRESETS } from "../lib/builtInPresets"; +import { BUILT_IN_PRESETS } from "yt-dlp-transcript-common/views/builtInPresets"; import { WidgetBuilder } from "./components/WidgetBuilder"; export const metadata: Metadata = { title: "Monitor widget" }; diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -8,7 +8,7 @@ import type { ActiveJobsPayload, DiskStatusView, } from "../../jobs/active/buildActiveJobs"; -import type { JobRowView } from "../../jobs/jobRowView"; +import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import type { JobProgressMetric, JobTaskKind, diff --git a/editor/app/widget/components/PresetsRow.tsx b/editor/app/widget/components/PresetsRow.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import type { WidgetPreset } from "yt-dlp-transcript-common/lib/widgetPresets"; -import { BUILT_IN_PRESETS, type BuiltInPreset } from "../lib/builtInPresets"; +import { BUILT_IN_PRESETS, type BuiltInPreset } from "yt-dlp-transcript-common/views/builtInPresets"; import { deleteWidgetPresetAction, renameWidgetPresetAction, diff --git a/editor/app/widget/lib/builtInPresets.test.ts b/editor/app/widget/lib/builtInPresets.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { BUILT_IN_PRESETS, builtInPresetById } from "./builtInPresets"; +import { BUILT_IN_PRESETS, builtInPresetById } from "yt-dlp-transcript-common/views/builtInPresets"; import { buildWidgetQuery, parseWidgetConfig } from "./config"; // Run from the repo root with: diff --git a/editor/app/widget/page.tsx b/editor/app/widget/page.tsx @@ -6,7 +6,7 @@ import { } from "yt-dlp-transcript-common/lib/widgetPresets"; import { buildActiveJobsPayload } from "../jobs/active/buildActiveJobs"; import { buildWorkersPayload } from "../workers/buildWorkers"; -import { BUILT_IN_PRESETS } from "./lib/builtInPresets"; +import { BUILT_IN_PRESETS } from "yt-dlp-transcript-common/views/builtInPresets"; import { MonitorWidget } from "./components/MonitorWidget"; import { parseWidgetConfig } from "./lib/config";