commit 9027b4de17d962b3d07e3d675687a087155be426 parent f145478813e669a98b2727e2764575a889a5191d Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Tue, 11 Aug 2026 00:35:30 -0400 Merge branch 'main' into feat/mcp-stateless Diffstat:
37 files changed, 166 insertions(+), 1290 deletions(-)
diff --git a/PLAN.md b/PLAN.md @@ -563,7 +563,7 @@ only party who will ever notice — the detector already believes the two are th Ingestion is an editor action accepting pasted JSON. Treat submissions as **untrusted input rendered in an admin UI**: escape it, never feed it into a prompt -unreviewed. Store under `transcripts/.feedback/`, a sibling of `.jobs` and `.bookmarks`, +unreviewed. Store under `transcripts/.feedback/`, a sibling of `.jobs` and `.scheduler`, outside the build trees. **Why that category is not boilerplate:** the smoke test summarized allegation-heavy content diff --git a/common/controller/renameChannel.test.ts b/common/controller/renameChannel.test.ts @@ -21,7 +21,6 @@ import { readSchedulerState, writeSchedulerState, } from "../jobs/syncSchedulerState"; -import { readBookmarks } from "../jobs/bookmarks"; import { renameChannel } from "./renameChannel"; // Run with: @@ -34,7 +33,6 @@ async function withPaths(fn: (paths: Paths) => Promise<void>): Promise<void> { savedVideosDir: path.join(dir, "saved"), sitesDir: path.join(dir, "sites"), schedulerStateFile: path.join(dir, ".scheduler", "state.json"), - bookmarksFile: path.join(dir, ".bookmarks", "bookmarks.json"), } as Paths; try { await fn(paths); @@ -46,8 +44,7 @@ async function withPaths(fn: (paths: Paths) => Promise<void>): Promise<void> { const config: ChannelConfig = { handling: "youtube", name: "Old Name" }; // Seed a channel "old" with one video that has a persisted saved source, plus a -// site membership, a scheduler backoff entry, and a bookmark — all keyed by the -// old slug. +// site membership and a scheduler backoff entry — all keyed by the old slug. async function seedOld(paths: Paths): Promise<void> { await writeChannelConfig(paths, "old", config); const videoDir = path.join(paths.channelsDir, "old", "data", "vid1"); @@ -73,22 +70,6 @@ async function seedOld(paths: Paths): Promise<void> { const state = await readSchedulerState(paths); state.channels["old"] = { ...emptyChannelSyncState(), consecutiveFailures: 5 }; await writeSchedulerState(paths, state); - - await mkdir(path.dirname(paths.bookmarksFile), { recursive: true }); - await writeFile( - paths.bookmarksFile, - JSON.stringify({ - v: 1, - bookmarks: [ - { - id: "bm1", - name: "whisper-all · old", - spec: { kind: "whisper-all", slug: "old" }, - createdAt: 1, - }, - ], - }), - ); } test("renameChannel migrates every slug-keyed store", async () => { @@ -123,11 +104,6 @@ test("renameChannel migrates every slug-keyed store", async () => { const state = await readSchedulerState(paths); assert.equal(state.channels["old"], undefined); assert.equal(state.channels["new"]?.consecutiveFailures, 5); - - // Bookmark retargeted (auto-name regenerated). - const bookmarks = await readBookmarks(paths); - assert.equal(bookmarks[0]?.spec.slug, "new"); - assert.equal(bookmarks[0]?.name, "whisper-all · new"); }); }); diff --git a/common/controller/renameChannel.ts b/common/controller/renameChannel.ts @@ -10,7 +10,6 @@ import { readSchedulerState, writeSchedulerState, } from "../jobs/syncSchedulerState"; -import { renameChannelInBookmarks } from "../jobs/bookmarks"; // Rename a channel's slug. Because the slug IS the on-disk directory name // (transcripts/channels/<slug>/), this moves the channel directory AND migrates @@ -18,7 +17,6 @@ import { renameChannelInBookmarks } from "../jobs/bookmarks"; // - the saved-video store dir + each saved-video.json pointer's absolute `dir` // - site.json memberships across all sites // - the sync scheduler's per-channel backoff state -// - job bookmarks whose spec targets this channel // // The two filesystem moves (channel dir, then store dir) run first and roll back // on failure so a channel is never left half-renamed. The metadata updates that @@ -159,12 +157,5 @@ export async function renameChannel( ); } - // 6. Retarget job bookmarks. - try { - await renameChannelInBookmarks(paths, oldSlug, newSlug); - } catch (err) { - warnings.push(`Bookmark migration failed: ${(err as Error).message}`); - } - return { warnings }; } diff --git a/common/jobs/bookmarks.ts b/common/jobs/bookmarks.ts @@ -1,175 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { Paths } from "../lib/paths"; -import { parseJobSpec, type JobSpec } from "./jobSpec"; - -// Persisted job bookmarks. A bookmark captures a JobSpec (see jobSpec.ts) plus a -// human label, so an operator can re-launch the same job kind on the same -// channel with one button. Re-running re-derives work from the channel's current -// state — the bookmark never stores a frozen video-id list. -// -// Modeled on common/jobs/workerDefaults.ts: a small JSON file outside the -// in-memory registry, written atomically (tmp + rename), with tolerant reads -// that coerce a missing/corrupt file to an empty list rather than crashing. - -export type JobBookmark = { - id: string; - name: string; - spec: JobSpec; - createdAt: number; - lastRunAt?: number; -}; - -type BookmarksFile = { v: 1; bookmarks: JobBookmark[] }; - -// Stable label derived from the spec, e.g. "whisper-all · HasanAbiVODs3" or -// "retry-bucket (partialDownloads) · cornbreadman". -export function defaultBookmarkName(spec: JobSpec): string { - const scope = spec.bucket ? `${spec.kind} (${spec.bucket})` : spec.kind; - return `${scope} · ${spec.slug}`; -} - -function newBookmarkId(): string { - return `bm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -// Two specs are "the same" job to avoid duplicate bookmarks when the operator -// bookmarks the same recurring job twice. params order is stable because each -// action builds them the same way. -function sameSpec(a: JobSpec, b: JobSpec): boolean { - return ( - a.kind === b.kind && - a.slug === b.slug && - a.bucket === b.bucket && - JSON.stringify(a.params ?? {}) === JSON.stringify(b.params ?? {}) - ); -} - -export async function readBookmarks(paths: Paths): Promise<JobBookmark[]> { - let raw: unknown; - try { - raw = JSON.parse(await fs.promises.readFile(paths.bookmarksFile, "utf8")); - } catch { - return []; - } - if (!raw || typeof raw !== "object") return []; - const arr = (raw as Record<string, unknown>).bookmarks; - if (!Array.isArray(arr)) return []; - const out: JobBookmark[] = []; - for (const item of arr) { - if (!item || typeof item !== "object") continue; - const b = item as Record<string, unknown>; - if (typeof b.id !== "string" || !b.id) continue; - const spec = parseJobSpec(b.spec); - if (!spec) continue; - out.push({ - id: b.id, - name: - typeof b.name === "string" && b.name - ? b.name - : defaultBookmarkName(spec), - spec, - createdAt: typeof b.createdAt === "number" ? b.createdAt : 0, - lastRunAt: typeof b.lastRunAt === "number" ? b.lastRunAt : undefined, - }); - } - return out; -} - -async function writeBookmarks( - paths: Paths, - bookmarks: JobBookmark[], -): Promise<void> { - const out: BookmarksFile = { v: 1, bookmarks }; - await fs.promises.mkdir(path.dirname(paths.bookmarksFile), { - recursive: true, - }); - const tmp = `${paths.bookmarksFile}.tmp-${process.pid}`; - await fs.promises.writeFile(tmp, JSON.stringify(out, null, 2) + "\n"); - await fs.promises.rename(tmp, paths.bookmarksFile); -} - -// Add a bookmark for `spec`, returning the new (or existing duplicate) entry. -// Idempotent: re-bookmarking an identical job returns the existing bookmark -// without creating a second one. -export async function addBookmark( - paths: Paths, - spec: JobSpec, -): Promise<JobBookmark> { - const existing = await readBookmarks(paths); - const dup = existing.find((b) => sameSpec(b.spec, spec)); - if (dup) return dup; - const bookmark: JobBookmark = { - id: newBookmarkId(), - name: defaultBookmarkName(spec), - spec, - createdAt: Date.now(), - }; - await writeBookmarks(paths, [bookmark, ...existing]); - return bookmark; -} - -export async function removeBookmark( - paths: Paths, - id: string, -): Promise<void> { - const existing = await readBookmarks(paths); - const next = existing.filter((b) => b.id !== id); - if (next.length !== existing.length) await writeBookmarks(paths, next); -} - -// Rewrite every bookmark whose spec targets `oldSlug` to target `newSlug`. Used -// when a channel is renamed so its bookmarked jobs keep working. An auto-derived -// name (still equal to the old spec's default) is regenerated for the new slug; -// a custom name the operator set is left untouched. Returns the number changed. -export async function renameChannelInBookmarks( - paths: Paths, - oldSlug: string, - newSlug: string, -): Promise<number> { - const existing = await readBookmarks(paths); - let changed = 0; - const next = existing.map((b) => { - if (b.spec.slug !== oldSlug) return b; - changed++; - const spec = { ...b.spec, slug: newSlug }; - const name = - b.name === defaultBookmarkName(b.spec) ? defaultBookmarkName(spec) : b.name; - return { ...b, spec, name }; - }); - if (changed > 0) await writeBookmarks(paths, next); - return changed; -} - -// Move a bookmark one slot up (dir -1) or down (dir +1) by swapping it with its -// neighbour. Order is the array order — the same order both the management page -// and the compact menu render — so this is the only place reordering lives. -// No-ops at the bounds or for an unknown id. -export async function moveBookmark( - paths: Paths, - id: string, - dir: -1 | 1, -): Promise<void> { - const existing = await readBookmarks(paths); - const i = existing.findIndex((b) => b.id === id); - const j = i + dir; - if (i < 0 || j < 0 || j >= existing.length) return; - const next = existing.slice(); - [next[i], next[j]] = [next[j], next[i]]; - await writeBookmarks(paths, next); -} - -export async function updateBookmark( - paths: Paths, - id: string, - patch: Partial<Pick<JobBookmark, "name" | "lastRunAt">>, -): Promise<void> { - const existing = await readBookmarks(paths); - let changed = false; - const next = existing.map((b) => { - if (b.id !== id) return b; - changed = true; - return { ...b, ...patch }; - }); - if (changed) await writeBookmarks(paths, next); -} diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts @@ -1,12 +1,12 @@ // The single source of truth for what a job `kind` IS. Before this table, the // same per-kind knowledge was scattered across four places: the DRAINABLE_KINDS // set (registry.ts), the JOB_KIND_LABELS map (editor jobKindLabels.ts), the -// switch in editor runJobSpec.ts, and the spec-presence bookmark check. Phase 1 +// switch in editor runJobSpec.ts, and the spec-presence replay check. Phase 1 // of the queue refactor consolidates the drainable + label data here; later -// phases consume `defaultTier` (scheduler) and lean on `bookmarkable`. +// phases consume `defaultTier` (scheduler) and lean on `replayable`. // // Adding a new job kind should mean adding ONE entry here (plus its replay -// handler in editor/app/jobs/jobReplayRegistry.ts if it is bookmarkable). +// handler in editor/app/jobs/jobReplayRegistry.ts if it is replayable). // How a kind picks its registry queueKey. Descriptive only — the real key is // still computed by the action that creates the job (channelQueueKey / @@ -29,9 +29,9 @@ export type JobKindMeta = { // starting new sub-operations, lets in-flight ones finish). drainable: boolean; // Whether this kind's action attaches a replayable JobSpec (and thus has a - // replay handler). Actual bookmark-ability still keys off spec PRESENCE on the - // record at runtime — this flag just says the kind CAN be bookmarked/retried. - bookmarkable: boolean; + // replay handler). Actual replayability still keys off spec PRESENCE on the + // record at runtime — this flag just says the kind CAN be retried. + replayable: boolean; // Descriptive: how the action derives its queueKey. Not consumed as logic. queueKeyStrategy: QueueKeyStrategy; // Fallback scheduler tier when a record is not explicitly background. Left @@ -46,68 +46,68 @@ const JOB_KINDS: Record<string, JobKindMeta> = { kind: "auto-transcribe", label: "Auto-transcribe", drainable: true, - bookmarkable: false, + replayable: false, queueKeyStrategy: "parallel", }, "auto-download": { kind: "auto-download", label: "Auto-download runner", drainable: true, - bookmarkable: false, + replayable: false, queueKeyStrategy: "parallel", }, "auto-download-unit": { kind: "auto-download-unit", label: "Auto-download", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "platform", }, "whisper-all": { kind: "whisper-all", label: "Transcribe all", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "whisper-bucket-downloaded-no-transcript": { kind: "whisper-bucket-downloaded-no-transcript", label: "Transcribe downloaded audio", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // Replace-auto-captions lane, transcribe half: whisper over videos whose only // transcript is a YouTube ASR VTT (the downloadedAutoSubsOnly bucket). Same // batch machinery as whisper-bucket-downloaded-no-transcript — drainable and - // bookmarkable, re-deriving the bucket's current members on replay. + // replayable, re-deriving the bucket's current members on replay. "whisper-bucket-auto-subs": { kind: "whisper-bucket-auto-subs", label: "Replace auto-captions", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // Delete the superseded English ASR VTTs kept as backups next to a finished // whisper transcript. Manual only — never auto-queued — and the single // irreversible step in the lane, so it is deliberately NOT drainable (it is a - // fast file sweep) but IS bookmarkable. + // fast file sweep) but IS replayable. "purge-superseded-auto-subs": { kind: "purge-superseded-auto-subs", label: "Purge superseded auto-captions", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // AI digest sweep, local (ollama) lane — the one that carries the corpus. Both // digest kinds are drainable (the batch honors the drain signal: it stops - // pulling new videos and lets the in-flight one finish) and bookmarkable, since + // pulling new videos and lets the in-flight one finish) and replayable, since // a channel-scoped sweep is exactly the kind of thing an operator re-launches. "digest-channel-local": { kind: "digest-channel-local", label: "Digest channel (local)", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // Same batch, metered lane. Off unless settings.digest.remoteEnabled is true, @@ -117,17 +117,17 @@ const JOB_KINDS: Record<string, JobKindMeta> = { kind: "digest-channel-remote", label: "Digest channel (metered)", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // Copy a duplicate cluster's canonical digest onto its aligned mirrors. A fast // file operation gated by the timestamp-alignment check, so it is not drainable - // but is bookmarkable. + // but is replayable. "digest-share-cluster": { kind: "digest-share-cluster", label: "Share cluster digest", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "parallel", }, // Write the compact transcript.cues.json sidecar next to every raw transcript @@ -138,69 +138,69 @@ const JOB_KINDS: Record<string, JobKindMeta> = { // Not drainable: the walk honours the cancel signal (which is what stops it) // but has no drain-aware inner loop, and the unit of work is a single file // write, so "let the in-flight one finish" is already how it behaves. Not - // bookmarkable either — that would need a JobSpec and a jobReplayRegistry + // replayable either — that would need a JobSpec and a jobReplayRegistry // handler, and the button is one click from the card that reports the count. "normalize-transcripts": { kind: "normalize-transcripts", label: "Normalize transcripts", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "custom", }, "redownload-incomplete-bucket": { kind: "redownload-incomplete-bucket", label: "Re-download truncated transcripts", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "download-from-playlist": { kind: "download-from-playlist", label: "Download from playlist", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "platform", }, "download-missing": { kind: "download-missing", label: "Download missing", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "platform", }, "download-missing-subs": { kind: "download-missing-subs", label: "Download missing subs", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "platform", }, "import-one": { kind: "import-one", label: "Import video", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "custom", }, "redownload-archive": { kind: "redownload-archive", label: "Archive source video", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "custom", }, "retry-bucket": { kind: "retry-bucket", label: "Retry", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "platform", }, "clean-audio-transcribed": { kind: "clean-audio-transcribed", label: "Clean audio", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // Speaker-diarization backfill over a channel's retained audio. The capture @@ -208,36 +208,36 @@ const JOB_KINDS: Record<string, JobKindMeta> = { // (videos transcribed before the feature, or with the inline hook off — which // is the recommended way to run a large batch). Drainable, because it is // CPU-hours of work an operator will want to stop without losing what it has - // already written, and bookmarkable, because it re-derives its work-list from + // already written, and replayable, because it re-derives its work-list from // disk on every run and so replays correctly with nothing remembered. "diarize-channel": { kind: "diarize-channel", label: "Diarize speakers", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // The corpus-wide backfill sweep's orchestrator. Drainable, because a "stop" // has to reach the channel job it is waiting on rather than meaning "after the - // current channel", and NOT bookmarkable: the sweep is armed through a + // current channel", and NOT replayable: the sweep is armed through a // persisted settings flag, so replaying a record would be a second way to // start the same singleton. "backfill-sweep": { kind: "backfill-sweep", label: "Backfill sweep", drainable: true, - bookmarkable: false, + replayable: false, queueKeyStrategy: "parallel", }, // One channel through the backfill lane. Drainable (the batch stops taking new - // videos and lets the in-flight one finish) and bookmarkable, because it + // videos and lets the in-flight one finish) and replayable, because it // re-derives its work-list from disk on every run and so replays correctly // with nothing remembered — the same contract diarize-channel has. "backfill-channel": { kind: "backfill-channel", label: "Backfill channel", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, // The corpus-wide corrupt-media scan and its per-channel twin. Registered @@ -249,7 +249,7 @@ const JOB_KINDS: Record<string, JobKindMeta> = { label: "Scan media for corruption", // Reports only; there is nothing in flight to let finish. drainable: false, - bookmarkable: false, + replayable: false, // Local disk work with nothing to serialize against — see the queueKey "" // escape hatch refresh-report and detect-duplicates use. queueKeyStrategy: "parallel", @@ -259,7 +259,7 @@ const JOB_KINDS: Record<string, JobKindMeta> = { kind: "scan-media-channel", label: "Scan channel media", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "parallel", defaultTier: "background", }, @@ -267,40 +267,40 @@ const JOB_KINDS: Record<string, JobKindMeta> = { kind: "check-kept-deleted", label: "Check kept videos", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "persist-kept": { kind: "persist-kept", label: "Persist kept videos", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "backup-saved-videos": { kind: "backup-saved-videos", label: "Back up saved videos", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "custom", }, "verify-saved-video-backup": { kind: "verify-saved-video-backup", label: "Verify saved-video backup", drainable: false, - bookmarkable: false, + replayable: false, queueKeyStrategy: "custom", }, // Social-post ingest for a `sourceKind: "social"` channel. Drainable (the // fetcher stops paging on the drain signal and keeps what it already has) and - // bookmarkable. queueKeyForUrl() routes x.com / bsky.app to + // replayable. queueKeyForUrl() routes x.com / bsky.app to // `platform:x.com` / `platform:bsky.app`, so per-platform serialization and // the existing 429 backoff come free. "fetch-posts": { kind: "fetch-posts", label: "Fetch posts", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "platform", }, // The posts analogue of the video availability check: which archived posts @@ -309,14 +309,14 @@ const JOB_KINDS: Record<string, JobKindMeta> = { kind: "check-post-availability", label: "Check deleted posts", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "platform", }, sync: { kind: "sync", label: "Sync", drainable: true, - bookmarkable: true, + replayable: true, queueKeyStrategy: "platform", }, // Replayable kinds that never had a JOB_KIND_LABELS entry: label omitted so @@ -324,49 +324,49 @@ const JOB_KINDS: Record<string, JobKindMeta> = { "store-playlist": { kind: "store-playlist", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "clear-failed-transcriptions": { kind: "clear-failed-transcriptions", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "transcode-failures": { kind: "transcode-failures", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "transcode-untranscoded": { kind: "transcode-untranscoded", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "remove-failed-transcodings": { kind: "remove-failed-transcodings", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "clear-failed-transcodings": { kind: "clear-failed-transcodings", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "clean-extra-audio-formats": { kind: "clean-extra-audio-formats", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, "remove-wrong-format-audio": { kind: "remove-wrong-format-audio", drainable: false, - bookmarkable: true, + replayable: true, queueKeyStrategy: "custom", }, }; @@ -384,7 +384,3 @@ export function jobKindLabel(kind: string): string { export function isDrainableKind(kind: string): boolean { return JOB_KINDS[kind]?.drainable ?? false; } - -export function isBookmarkableKind(kind: string): boolean { - return JOB_KINDS[kind]?.bookmarkable ?? false; -} diff --git a/common/jobs/jobMeta.ts b/common/jobs/jobMeta.ts @@ -22,8 +22,8 @@ export type JobMeta = { startedAt?: number; endedAt?: number; exitCode?: number; - // Replay descriptor for bookmarkable jobs, preserved so an archived (evicted - // or post-restart) job can still be bookmarked from disk. See jobSpec.ts. + // Replay descriptor for replayable jobs, preserved so an archived (evicted + // or post-restart) job can still be retried from disk. See jobSpec.ts. spec?: JobSpec; }; @@ -77,7 +77,7 @@ export async function readJobMeta( if (typeof m.id !== "string" || typeof m.kind !== "string") return null; const meta = parsed as JobMeta; // Sanitize the replay descriptor: drop it if malformed so a stale sidecar - // can't surface an un-runnable bookmark. + // can't surface an un-runnable Retry. meta.spec = m.spec === undefined ? undefined : (parseJobSpec(m.spec) ?? undefined); return meta; } catch { diff --git a/common/jobs/jobSpec.ts b/common/jobs/jobSpec.ts @@ -1,9 +1,10 @@ -// A serializable description of a job, sufficient to re-launch ("bookmark") it. +// A serializable description of a job, sufficient to re-launch (replay) it. // // It is captured at job-creation time (stored on the JobRecord and in the -// <id>.meta.json sidecar) and copied into a bookmark when the user bookmarks a -// job. The dispatcher in editor/app/jobs/runJobSpec.ts maps a spec back to the -// server action that runs it. +// <id>.meta.json sidecar), which is what lets Retry re-run a job — including one +// that has been evicted from the in-memory registry. The dispatcher in +// editor/app/jobs/runJobSpec.ts maps a spec back to the server action that runs +// it. // // Re-running RE-DERIVES the work from the channel's CURRENT state rather than // replaying a frozen list: bucket jobs store only the snapshot bucket category @@ -12,7 +13,7 @@ // now. Flag-style parameters (queue, audio format, abort-on-error…) are captured // verbatim in `params`. -// The snapshot bucket categories whose current members a bookmarked job can be +// The snapshot bucket categories whose current members a replayed job can be // re-derived from. A subset of ChannelSnapshot["buckets"] — only the buckets the // bucket-style actions (retry-bucket / whisper-bucket) actually operate on. export type ReplayBucket = @@ -52,8 +53,8 @@ const REPLAY_BUCKETS: ReadonlySet<string> = new Set<ReplayBucket>([ "supersededAutoSubs", ]); -// Defensive parse for a spec read back from JSON (a sidecar or the bookmarks -// file). Returns null on anything malformed so a hand-edited or stale file can't +// Defensive parse for a spec read back from JSON (the <id>.meta.json sidecar). +// Returns null on anything malformed so a hand-edited or stale file can't // crash a reader. Mirrors the tolerance of readJobMeta / readWorkerDefaults. export function parseJobSpec(raw: unknown): JobSpec | null { if (!raw || typeof raw !== "object") return null; diff --git a/common/jobs/listJobs.ts b/common/jobs/listJobs.ts @@ -21,9 +21,9 @@ export type JobListEntry = { endedAt?: number; exitCode?: number; inRegistry: boolean; - // True when the job carries a replay descriptor (JobSpec) and can be - // bookmarked / re-run. See common/jobs/jobSpec.ts. - bookmarkable: boolean; + // True when the job carries a replay descriptor (JobSpec) and can therefore + // be re-run from it (Retry). See common/jobs/jobSpec.ts. + replayable: boolean; logPath: string; logSize: number; }; @@ -103,7 +103,7 @@ async function buildEntry( endedAt: live_.endedAt, exitCode: live_.exitCode, inRegistry: true, - bookmarkable: Boolean(live_.spec), + replayable: Boolean(live_.spec), logPath, logSize, }; @@ -128,7 +128,7 @@ async function buildEntry( endedAt: meta.endedAt, exitCode: meta.exitCode, inRegistry: false, - bookmarkable: Boolean(meta.spec), + replayable: Boolean(meta.spec), logPath, logSize, }; @@ -140,7 +140,7 @@ async function buildEntry( startedAt: mtime, endedAt: mtime, inRegistry: false, - bookmarkable: false, + replayable: false, logPath, logSize, }; diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts @@ -78,8 +78,8 @@ export type JobRecord = { queueKey: string; channelSlug?: string; videoId?: string; - // A serializable replay descriptor, set for bookmarkable job kinds. Its - // presence is what makes a job "bookmarkable" in the UI. See jobSpec.ts. + // A serializable replay descriptor, set for replayable job kinds. Its + // presence is what offers Retry on a job in the UI. See jobSpec.ts. spec?: JobSpec; // Background jobs queue BEHIND foreground (default) jobs on the same queueKey // (see enqueue). The auto-download runner marks its per-video units background diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts @@ -58,7 +58,7 @@ type CommonOpts = { channelSlug?: string; videoId?: string; // When set, recorded on the job (and its meta sidecar) so the job can be - // bookmarked and re-launched later. See common/jobs/jobSpec.ts. + // re-launched (retried) later. See common/jobs/jobSpec.ts. spec?: JobSpec; // Background jobs queue BEHIND any foreground (default) job on the same // queueKey: a manually-triggered job (sync, manual download) jumps ahead of diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -44,10 +44,6 @@ export type Paths = { // disabled). Written by the Workers page "Set as default" button; applied by // the worker pool on first use. See common/jobs/workerDefaults.ts. workerDefaultsFile: string; - // Persisted job bookmarks: saved (kind, channel, params) specs an operator can - // re-launch with one button. Survives restarts, unlike the in-memory job - // registry. See common/jobs/bookmarks.ts. - bookmarksFile: string; // Saved monitor-widget presets: named /widget query strings the builder and // the in-widget menu both load from. A preset stores the LINK, not a parsed // config, so a preset and a shared link are literally the same artifact. See @@ -180,7 +176,6 @@ export function getPaths(): Paths { schedulerStateFile: path.join(transcriptsDir, ".scheduler", "state.json"), autoQueueStateFile: path.join(transcriptsDir, ".auto-queue", "state.json"), workerDefaultsFile: path.join(transcriptsDir, ".workers", "defaults.json"), - bookmarksFile: path.join(transcriptsDir, ".bookmarks", "bookmarks.json"), widgetPresetsFile: path.join(transcriptsDir, ".widget", "presets.json"), lmdbPath: path.join(transcriptsDir, "index.mdb"), exportDir, diff --git a/common/lib/widgetPresets.ts b/common/lib/widgetPresets.ts @@ -16,9 +16,9 @@ import type { Paths } from "./paths"; // - nothing in this file has to know what a section or a row IS, so adding one // never touches the store. // -// Modeled on common/jobs/bookmarks.ts: a small JSON file outside any in-memory -// registry, written atomically (tmp + rename), with tolerant reads that coerce a -// missing/corrupt file to an empty list rather than crashing. +// The store is a small JSON file living outside any in-memory registry, written +// atomically (tmp + rename), with tolerant reads that coerce a missing/corrupt +// file to an empty list rather than crashing. export type WidgetPreset = { id: string; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Command bookmarks are gone.** Saving a job under a label and re-launching it from the strip above **Jobs** / **Active jobs**, and the `/jobs/bookmarks` page that managed those labels, have all been removed. The feature was used for three days after it shipped and never again: every job it could re-launch is still one click away from its own channel-page control, and **Retry** — which re-runs a finished job straight from the descriptor stored with it — covers re-running something you have already run. Retry and **Retry all failed** are untouched and keep working exactly as before, including on jobs old enough to have been evicted from memory. Bucket jobs still re-derive their work from the channel's current state on every re-run rather than replaying a stale list. Your existing `transcripts/.bookmarks/bookmarks.json` is left on disk as a record; nothing reads it any more, and you can delete it whenever you like. - **The monitor widget lays out on a real grid now: rows, full-width sections, and a rail of totals.** The board could only ever split the widget into columns of stacked strips, so three things people kept asking for were impossible: a Controls row across the whole top, four one-line totals reading as one instrument cluster instead of four separate boxes, and a section that takes the leftover height. The floorplan now has rows as well as columns — drag the edge of a cell to make it span two tracks, and flip a cell to a **rail** to put its strips on one line with shortened labels (`synced 3m`, `412 GB free`, `1.2k backfill`). Every widget link written before this keeps working and still copies out in exactly the same short form; only an arrangement that genuinely needs rows writes the newer parameter. - **A row can now take the leftover height, and the sections in it scroll under their own headings.** The rail down the left of the board sets each row to *content height* (what every row was before) or *fill*. A widget with a fill row becomes exactly as tall as the window or iframe it is in, and the sections sharing that row each get half of it and scroll inside — with the section title pinned at the top so you always know what you are looking at. Those lists also drop the six-row cap while they are scrollable, because a list you can scroll that still says "+14 more channels" is not much of a list. - **Widget arrangements can be saved and named.** There was no way to keep one except saving the URL. Presets now sit above the board on the builder page and inside the widget's own settings, with **Save / Save as… / Rename / Delete**, and five ready-made ones: **Glance** (one dense rail for a 320×120 corner pin), **Jobs** (the default), **Cockpit** (controls, totals, then the scheduler beside two scrolling worklists), **Cleanup** (a full-height needs-cleaning list), and **Wall** (three columns for a spare monitor). A preset is just a widget link under a name, so `/widget?preset=cockpit` works too, and anything you spell out in the URL still wins over the preset. diff --git a/editor/app/api/pulse/route.ts b/editor/app/api/pulse/route.ts @@ -41,7 +41,7 @@ export type PulsePayload = { // background, because /api/test/invalidate-cache clears exactly these globals // between e2e specs — a poll landing a moment later silently rebuilt them, // re-seeding a worker pool from settings mid-reset. The symptom was ~16 -// unrelated specs (branding, build, bookmarks, downloads) failing in +// unrelated specs (branding, build, jobs, downloads) failing in // non-deterministic combinations while each passed in isolation. // // So read the globals directly and treat "not created yet" as "nothing to diff --git a/editor/app/channels/[slug]/components/RetryBucketControl.tsx b/editor/app/channels/[slug]/components/RetryBucketControl.tsx @@ -15,7 +15,7 @@ type Props = { defaultQueueKey: string; existingQueues: string[]; // The snapshot bucket these ids represent. When set, the launched retry job - // is bookmarkable and re-runs against the current bucket members. + // is replayable and re-runs against the current bucket members. bucketKey?: ReplayBucket; // Needs-cookies bucket: force cookie mode "always" for the run and label the // button "Download with cookies" so the manual cookie path is explicit. diff --git a/editor/app/channels/[slug]/incompleteTranscriptActions.ts b/editor/app/channels/[slug]/incompleteTranscriptActions.ts @@ -56,7 +56,7 @@ export async function enableAutoRunners(): Promise<void> { // One-shot batch re-fix: queue a single managed job that removes the truncated // audio, re-downloads, and re-transcribes each flagged video in place (the bulk // version of the per-video redownloadIncompleteTranscriptAction). ids default to -// the channel's current incompleteTranscript bucket; bookmarkable so a re-run +// the channel's current incompleteTranscript bucket; replayable so a re-run // re-derives the live bucket. export async function redownloadIncompleteBucketAction( slug: string, @@ -118,7 +118,7 @@ export async function redownloadIncompleteBucketAction( // Re-download the channel's short-audio bucket (downloads the duration guard // flagged as truncated at the source). Reuses the same per-video fixer and the -// same bucket job kind, parameterized with bucket "shortAudio" so a bookmarked +// same bucket job kind, parameterized with bucket "shortAudio" so a replayed // re-run re-derives the live members. The re-download deletes the kept stub and // re-fetches with the per-source default format (Original for Odysee), which is // what actually recovers the full audio. diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -90,7 +90,7 @@ async function runPipelineAction( // sync only: force the full sweep (whole-listing re-read) regardless of the // configured cadence. Undefined leaves the decision to the cadence gate. forceFullSweep?: boolean; - // Replay descriptor, forwarded onto the job record so it can be bookmarked. + // Replay descriptor, forwarded onto the job record so it can be replayed (Retry). spec?: JobSpec; }, ): Promise<StreamActionResult> { @@ -334,9 +334,9 @@ export async function retryBucketAction( abortOnError?: boolean, handlingOverride?: string, // The snapshot bucket these ids came from. Passed by the named bucket controls - // (partial downloads / no-transcript) so the job can be bookmarked and re-run + // (partial downloads / no-transcript) so the job can be replayed and re-run // against the CURRENT bucket; omitted by ad-hoc checkbox selections, which are - // therefore not bookmarkable. + // therefore not replayable. bucketKey?: ReplayBucket, // Needs-cookies bucket only: run with cookie mode forced to "always". forceCookies?: boolean, diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts @@ -110,7 +110,7 @@ export async function transcribeBucketAction( audioFormat?: AudioFormat, strictAudioFormat?: boolean, // The snapshot bucket these ids came from (downloadedNoTranscript). Passed by - // the named bucket control so the job is bookmarkable; omitted by ad-hoc + // the named bucket control so the job is replayable; omitted by ad-hoc // selections. bucketKey?: ReplayBucket, ): Promise<StreamActionResult> { diff --git a/editor/app/jobs/[id]/page.tsx b/editor/app/jobs/[id]/page.tsx @@ -5,7 +5,6 @@ import { notFound } from "next/navigation"; import { getJobEntry } from "yt-dlp-transcript-common/jobs/listJobs"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { JobLogTail } from "./components/JobLogTail"; -import { BookmarkJobButton } from "../components/BookmarkJobButton"; import { jobKindLabel } from "../jobKindLabels"; export const dynamic = "force-dynamic"; @@ -52,7 +51,6 @@ export default async function JobDetailPage({ <h1 className="text-2xl font-semibold" title={job.kind ?? undefined}> {job.kind ? jobKindLabel(job.kind) : "job"} </h1> - {job.bookmarkable && <BookmarkJobButton jobId={id} />} </div> {job.channelSlug && ( <p className="text-sm"> diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts @@ -107,8 +107,8 @@ async function resolveSpec(id: string): Promise<JobSpec | null> { return meta?.spec ?? null; } -// One-click retry: re-run a (typically failed) job from its stored spec. Like a -// bookmark re-run, bucket jobs re-derive from the channel's CURRENT state. The +// One-click retry: re-run a (typically failed) job from its stored spec. As on +// any replay, bucket jobs re-derive from the channel's CURRENT state. The // re-run jumps ahead of other queued work (run next) by reusing the same // promote the reorder buttons use — a no-op if it starts immediately. export async function retryJobAction(id: string): Promise<StreamActionResult> { diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts @@ -200,7 +200,6 @@ export async function buildActiveJobsPayload(): Promise<ActiveJobsPayload> { draining: j.draining === true, drainable: j.status === "running" && isDrainableKind(j.kind) && j.draining !== true, - bookmarkable: Boolean(j.spec), 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 diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx @@ -6,17 +6,14 @@ import { buildQueueView } from "../queue/buildQueueView"; import { ActiveJobsLive } from "../components/ActiveJobsLive"; import { DrainAllButton } from "../components/DrainAllButton"; import { PauseTranscriptionsButton } from "../components/PauseTranscriptionsButton"; -import { BookmarksMenu } from "../components/BookmarksMenu"; -import { loadBookmarksView } from "../loadBookmarks"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Active jobs" }; export default async function ActiveJobsPage() { - const [initial, { bookmarks, missingSlugs }, queueView] = await Promise.all([ + const [initial, queueView] = await Promise.all([ buildActiveJobsPayload(), - loadBookmarksView(), buildQueueView(), ]); const paused = getWorkerPool().isPaused(); @@ -41,7 +38,6 @@ export default async function ActiveJobsPage() { <DrainAllButton /> </div> </div> - <BookmarksMenu bookmarks={bookmarks} missingSlugs={missingSlugs} /> <ActiveJobsLive initial={initial} /> </div> ); diff --git a/editor/app/jobs/bookmarkActions.ts b/editor/app/jobs/bookmarkActions.ts @@ -1,106 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; -import { readJobMeta } from "yt-dlp-transcript-common/jobs/jobMeta"; -import { - addBookmark, - defaultBookmarkName, - moveBookmark, - readBookmarks, - removeBookmark, - updateBookmark, -} from "yt-dlp-transcript-common/jobs/bookmarks"; -import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; -import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; -import { runJobSpec } from "./runJobSpec"; - -export type BookmarkActionResult = { ok: true } | { ok: false; error: string }; - -function refreshJobsViews(): void { - revalidatePath("/jobs"); - revalidatePath("/jobs/active"); -} - -// Bookmark a job by copying its replay descriptor (from the live registry, or -// the on-disk sidecar for an archived job) into the bookmarks store. Jobs with -// no spec — ad-hoc selections, single imports — can't be bookmarked. -export async function bookmarkJobAction( - jobId: string, -): Promise<BookmarkActionResult> { - const paths = getPaths(); - let spec = getRegistry().get(jobId)?.spec; - if (!spec) { - const meta = await readJobMeta(paths, jobId); - spec = meta?.spec; - } - if (!spec) return { ok: false, error: "This job can't be bookmarked." }; - await addBookmark(paths, spec); - refreshJobsViews(); - return { ok: true }; -} - -export async function deleteBookmarkAction( - id: string, -): Promise<BookmarkActionResult> { - await removeBookmark(getPaths(), id); - refreshJobsViews(); - return { ok: true }; -} - -// Reorder a bookmark by swapping it with its neighbour. Both the management page -// and the compact /jobs menu render in array order, so persisting the new order -// (and revalidating) updates both views. -export async function moveBookmarkAction( - id: string, - dir: -1 | 1, -): Promise<BookmarkActionResult> { - await moveBookmark(getPaths(), id, dir); - refreshJobsViews(); - return { ok: true }; -} - -// Rename a bookmark. An empty/blank name resets it to the auto-generated default -// (kind · channel) so a bookmark always has a usable label. -export async function renameBookmarkAction( - id: string, - name: string, -): Promise<BookmarkActionResult> { - const paths = getPaths(); - const trimmed = name.trim().slice(0, 200); - if (trimmed) { - await updateBookmark(paths, id, { name: trimmed }); - } else { - const bookmark = (await readBookmarks(paths)).find((b) => b.id === id); - if (bookmark) { - await updateBookmark(paths, id, { - name: defaultBookmarkName(bookmark.spec), - }); - } - } - refreshJobsViews(); - return { ok: true }; -} - -// Re-launch a bookmarked job. Returns a StreamActionResult so the caller can -// stream the new job's log via StreamActionLog, exactly like the original -// stage-control buttons. -export async function runBookmarkAction( - id: string, -): Promise<StreamActionResult> { - const paths = getPaths(); - const bookmark = (await readBookmarks(paths)).find((b) => b.id === id); - if (!bookmark) return { ok: false, error: "Bookmark not found." }; - // Guard against a bookmark whose channel was deleted: re-running would scan a - // non-existent dir and do nothing useful. - if (!(await readChannelConfig(paths, bookmark.spec.slug))) { - return { - ok: false, - error: `Channel "${bookmark.spec.slug}" no longer exists.`, - }; - } - const result = await runJobSpec(bookmark.spec); - if (result.ok) await updateBookmark(paths, id, { lastRunAt: Date.now() }); - return result; -} diff --git a/editor/app/jobs/bookmarks/page.tsx b/editor/app/jobs/bookmarks/page.tsx @@ -1,37 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { BookmarksList } from "../components/BookmarksList"; -import { loadBookmarksView } from "../loadBookmarks"; - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { title: "Bookmarks" }; - -// Full bookmark management: rename, delete, created / last-run metadata, and the -// streaming "Run again" button (with its inline log). The compact menu on /jobs -// and /jobs/active links here via "Manage". -export default async function BookmarksPage() { - const { bookmarks, missingSlugs } = await loadBookmarksView(); - return ( - <div className="flex flex-col gap-4"> - <div className="flex items-center gap-2 text-sm text-muted-foreground"> - <Link - href="/jobs" - className="underline hover:text-foreground" - > - Jobs - </Link> - <span>/</span> - <span>Bookmarks</span> - </div> - <h1 className="text-2xl font-semibold">Bookmarks</h1> - {bookmarks.length === 0 ? ( - <p className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"> - No bookmarks yet. - </p> - ) : ( - <BookmarksList bookmarks={bookmarks} missingSlugs={missingSlugs} /> - )} - </div> - ); -} diff --git a/editor/app/jobs/components/BookmarkJobButton.tsx b/editor/app/jobs/components/BookmarkJobButton.tsx @@ -1,50 +0,0 @@ -"use client"; - -import { useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { bookmarkJobAction } from "../bookmarkActions"; - -// Small inline button shown on a job (jobs table row, active-jobs row, job -// detail) when it carries a replay descriptor. Saves a bookmark, then shows a -// transient "Bookmarked ✓" state. Errors are surfaced as the button title. -export function BookmarkJobButton({ - jobId, - className, -}: { - jobId: string; - className?: string; -}) { - const router = useRouter(); - const [pending, startTransition] = useTransition(); - const [done, setDone] = useState(false); - const [error, setError] = useState<string | null>(null); - - function onClick() { - setError(null); - startTransition(async () => { - const r = await bookmarkJobAction(jobId); - if (r.ok) { - setDone(true); - router.refresh(); - } else { - setError(r.error); - } - }); - } - - return ( - <button - type="button" - onClick={onClick} - disabled={pending || done} - aria-label={`bookmark job ${jobId}`} - title={error ?? undefined} - className={ - className ?? - "px-2 py-1 rounded-md bg-muted text-foreground text-xs font-medium hover:opacity-90 disabled:opacity-50" - } - > - {done ? "Bookmarked ✓" : pending ? "Bookmarking…" : "Bookmark"} - </button> - ); -} diff --git a/editor/app/jobs/components/BookmarkRunButton.tsx b/editor/app/jobs/components/BookmarkRunButton.tsx @@ -1,94 +0,0 @@ -"use client"; - -import { useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import type { JobBookmark } from "yt-dlp-transcript-common/jobs/bookmarks"; -import { runBookmarkAction } from "../bookmarkActions"; - -// One quick-access button in the compact bookmarks menu (BookmarksMenu). Unlike -// the full management UI on /jobs/bookmarks, this launches the job -// fire-and-forget: it never mounts StreamActionLog / an inline shell log. The -// re-launched job runs to completion server-side (streamCommand keeps the child -// and on-disk log alive even when no consumer reads the stream) and shows up in -// the live job list below. Errors and the neutral "nothing to do" (info) case -// are surfaced as a brief status line + the button title — no expanding log box. -export function BookmarkRunButton({ - bookmark, - channelMissing, -}: { - bookmark: JobBookmark; - channelMissing: boolean; -}) { - const router = useRouter(); - const [pending, startTransition] = useTransition(); - const [launched, setLaunched] = useState(false); - const [status, setStatus] = useState< - { kind: "error" | "info"; text: string } | null - >(null); - const { spec } = bookmark; - - const detail = [ - spec.kind, - spec.bucket ? `bucket ${spec.bucket}` : null, - spec.slug, - ] - .filter(Boolean) - .join(" · "); - - function onClick() { - if (channelMissing) return; - setStatus(null); - setLaunched(false); - startTransition(async () => { - const r = await runBookmarkAction(bookmark.id); - if (r.ok) { - // Release the stream reader; the job keeps running server-side. The - // refresh surfaces it in the live job list below. - void r.stream.cancel().catch(() => {}); - setLaunched(true); - router.refresh(); - } else { - setStatus({ kind: r.info ? "info" : "error", text: r.error }); - } - }); - } - - const label = channelMissing - ? bookmark.name - : launched - ? "Launched ✓" - : pending - ? "Launching…" - : bookmark.name; - - return ( - <div className="flex flex-col gap-1 max-w-full"> - <button - type="button" - onClick={onClick} - disabled={pending || channelMissing} - aria-label={`run bookmark ${bookmark.name}`} - title={status?.text ?? detail} - className="max-w-[16rem] truncate px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed" - > - {label} - </button> - {channelMissing ? ( - <span className="text-[11px] text-warning"> - channel missing - </span> - ) : status ? ( - <span - role={status.kind === "error" ? "alert" : "status"} - className={ - status.kind === "error" - ? "text-[11px] text-destructive" - : "text-[11px] text-muted-foreground" - } - > - {status.text} - </span> - ) : null} - </div> - ); -} diff --git a/editor/app/jobs/components/BookmarksList.tsx b/editor/app/jobs/components/BookmarksList.tsx @@ -1,239 +0,0 @@ -"use client"; - -import { useState, useTransition } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; -import type { JobBookmark } from "yt-dlp-transcript-common/jobs/bookmarks"; -import { cancelJobAction } from "../actions"; -import { - deleteBookmarkAction, - moveBookmarkAction, - renameBookmarkAction, - runBookmarkAction, -} from "../bookmarkActions"; -import { jobKindLabel } from "../jobKindLabels"; - -// The saved-jobs panel rendered on /jobs and /jobs/active. Each bookmark has a -// "Run again" button (streams the re-launched job's log), an inline rename, a -// confirm-gated delete, and created / last-run metadata. Bookmarks whose channel -// was deleted are flagged and can't be re-run. -export function BookmarksList({ - bookmarks, - missingSlugs = [], -}: { - bookmarks: JobBookmark[]; - missingSlugs?: string[]; -}) { - if (bookmarks.length === 0) return null; - const missing = new Set(missingSlugs); - return ( - <section - aria-label="Bookmarked jobs" - className="flex flex-col gap-3 border border-border rounded-md p-3 bg-card" - > - <h2 className="text-sm font-medium text-muted-foreground"> - Bookmarks - </h2> - <ul className="flex flex-col gap-3"> - {bookmarks.map((b, i) => ( - <BookmarkRow - key={b.id} - bookmark={b} - channelMissing={missing.has(b.spec.slug)} - index={i} - total={bookmarks.length} - /> - ))} - </ul> - </section> - ); -} - -function fmtDate(ms?: number): string { - if (!ms) return "—"; - return new Date(ms).toLocaleString(); -} - -function BookmarkRow({ - bookmark, - channelMissing, - index, - total, -}: { - bookmark: JobBookmark; - channelMissing: boolean; - index: number; - total: number; -}) { - const router = useRouter(); - const [pending, startTransition] = useTransition(); - const [confirmingDelete, setConfirmingDelete] = useState(false); - const [editing, setEditing] = useState(false); - const [draftName, setDraftName] = useState(bookmark.name); - const { spec } = bookmark; - - function onDelete() { - startTransition(async () => { - await deleteBookmarkAction(bookmark.id); - router.refresh(); - }); - } - - function onSaveName() { - setEditing(false); - startTransition(async () => { - await renameBookmarkAction(bookmark.id, draftName); - router.refresh(); - }); - } - - function onMove(dir: -1 | 1) { - startTransition(async () => { - await moveBookmarkAction(bookmark.id, dir); - router.refresh(); - }); - } - - return ( - <li - aria-label={`bookmark ${bookmark.name}`} - className="flex flex-col gap-2 border-t border-border pt-3 first:border-t-0 first:pt-0" - > - <div className="flex flex-wrap items-center gap-2 text-sm"> - {editing ? ( - <> - <input - type="text" - value={draftName} - onChange={(e) => setDraftName(e.target.value)} - aria-label={`new name for ${bookmark.name}`} - className="rounded border border-border bg-card px-2 py-0.5 text-sm" - /> - <button - type="button" - onClick={onSaveName} - className="px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90" - > - Save - </button> - <button - type="button" - onClick={() => { - setEditing(false); - setDraftName(bookmark.name); - }} - className="px-2 py-1 rounded-md border border-border text-xs font-medium hover:bg-muted" - > - Cancel - </button> - </> - ) : ( - <> - <span className="font-medium">{bookmark.name}</span> - <button - type="button" - onClick={() => { - setDraftName(bookmark.name); - setEditing(true); - }} - aria-label={`rename bookmark ${bookmark.name}`} - className="px-2 py-0.5 rounded border border-border text-xs text-muted-foreground hover:bg-muted" - > - Rename - </button> - </> - )} - <span className="text-xs text-muted-foreground" title={spec.kind}> - {jobKindLabel(spec.kind)} - </span> - {spec.bucket && ( - <span className="font-mono text-xs text-muted-foreground"> - bucket {spec.bucket} - </span> - )} - <Link - href={`/channels/${spec.slug}`} - className="font-mono text-xs underline hover:text-foreground" - > - {spec.slug} - </Link> - {channelMissing && ( - <span - aria-label={`channel missing for ${bookmark.name}`} - className="text-xs px-2 py-0.5 rounded bg-warning-soft text-warning" - > - channel missing - </span> - )} - <div className="ml-auto flex items-center gap-2"> - <button - type="button" - onClick={() => onMove(-1)} - disabled={pending || index === 0} - aria-label={`move bookmark ${bookmark.name} up`} - className="px-2 py-0.5 rounded border border-border text-xs disabled:opacity-40" - > - ↑ - </button> - <button - type="button" - onClick={() => onMove(1)} - disabled={pending || index === total - 1} - aria-label={`move bookmark ${bookmark.name} down`} - className="px-2 py-0.5 rounded border border-border text-xs disabled:opacity-40" - > - ↓ - </button> - {confirmingDelete ? ( - <> - <button - type="button" - onClick={onDelete} - disabled={pending} - aria-label={`confirm delete bookmark ${bookmark.name}`} - className="px-2 py-1 rounded-md bg-destructive text-destructive-foreground text-xs font-medium hover:bg-destructive/90 disabled:opacity-50" - > - {pending ? "Deleting…" : "Confirm"} - </button> - <button - type="button" - onClick={() => setConfirmingDelete(false)} - className="px-2 py-1 rounded-md border border-border text-xs font-medium hover:bg-muted" - > - Cancel - </button> - </> - ) : ( - <button - type="button" - onClick={() => setConfirmingDelete(true)} - aria-label={`delete bookmark ${bookmark.name}`} - className="px-2 py-1 rounded-md border border-destructive/30 text-xs font-medium text-destructive hover:bg-destructive-soft" - > - Delete - </button> - )} - </div> - </div> - <p className="text-xs text-muted-foreground" suppressHydrationWarning> - Created {fmtDate(bookmark.createdAt)} · Last run{" "} - {bookmark.lastRunAt ? fmtDate(bookmark.lastRunAt) : "never"} - </p> - {channelMissing ? ( - <p className="text-xs text-warning"> - This channel no longer exists — delete the bookmark or recreate the - channel to run it again. - </p> - ) : ( - <StreamActionLog - trigger={() => runBookmarkAction(bookmark.id)} - cancelAction={cancelJobAction} - buttonLabel="Run again" - runningLabel="Running…" - label={`Run ${bookmark.name}`} - /> - )} - </li> - ); -} diff --git a/editor/app/jobs/components/BookmarksMenu.tsx b/editor/app/jobs/components/BookmarksMenu.tsx @@ -1,45 +0,0 @@ -import Link from "next/link"; -import type { JobBookmark } from "yt-dlp-transcript-common/jobs/bookmarks"; -import { BookmarkRunButton } from "./BookmarkRunButton"; - -// Compact bookmarks panel rendered on /jobs and /jobs/active: a quick-access -// row of run buttons (one per bookmark), each showing just enough to identify -// it. Launching is fire-and-forget — the inline shell log and the heavier -// rename/delete/metadata controls live on the linked /jobs/bookmarks page. -export function BookmarksMenu({ - bookmarks, - missingSlugs = [], -}: { - bookmarks: JobBookmark[]; - missingSlugs?: string[]; -}) { - if (bookmarks.length === 0) return null; - const missing = new Set(missingSlugs); - return ( - <section - aria-label="Bookmarked jobs" - className="flex flex-col gap-3 border border-border rounded-md p-3 bg-card" - > - <div className="flex items-center justify-between gap-2"> - <h2 className="text-sm font-medium text-muted-foreground"> - Bookmarks - </h2> - <Link - href="/jobs/bookmarks" - className="text-xs underline text-muted-foreground hover:text-foreground" - > - Manage - </Link> - </div> - <div className="flex flex-wrap items-start gap-2"> - {bookmarks.map((b) => ( - <BookmarkRunButton - key={b.id} - bookmark={b} - channelMissing={missing.has(b.spec.slug)} - /> - ))} - </div> - </section> - ); -} diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import type { JobListEntry } from "yt-dlp-transcript-common/jobs/listJobs"; import { CancelJobButton } from "./CancelJobButton"; -import { BookmarkJobButton } from "./BookmarkJobButton"; import { RetryJobButton } from "./RetryJobButton"; import { jobKindLabel } from "../jobKindLabels"; import { @@ -286,10 +285,9 @@ export function JobsTable({ jobs }: { jobs: JobListEntry[] }) { </td> <td className="px-3 py-2 text-right"> <div className="flex items-center justify-end gap-2"> - {j.status === "failed" && j.bookmarkable && ( + {j.status === "failed" && j.replayable && ( <RetryJobButton jobId={j.id} /> )} - {j.bookmarkable && <BookmarkJobButton jobId={j.id} />} {(j.status === "running" || j.status === "queued") && ( <CancelJobButton jobId={j.id} /> )} diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx @@ -12,7 +12,6 @@ import { jobKindLabel } from "../jobKindLabels"; import { DrainJobButton } from "./DrainJobButton"; import { CancelJobButton } from "./CancelJobButton"; import { ForceReleaseJobButton } from "./ForceReleaseJobButton"; -import { BookmarkJobButton } from "./BookmarkJobButton"; import { ReorderJobButtons } from "./ReorderJobButtons"; export type RunningJobsTask = { @@ -58,8 +57,6 @@ export type RunningJobsListItem = { tasks?: RunningJobsTask[]; draining?: boolean; drainable?: boolean; - // True when the job carries a replay descriptor and can be bookmarked. - bookmarkable?: 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. @@ -163,7 +160,6 @@ function JobRow({ </span> )} <div className="ml-auto flex items-center gap-2"> - {job.bookmarkable && <BookmarkJobButton jobId={job.id} />} {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && ( <ReorderJobButtons jobId={job.id} diff --git a/editor/app/jobs/jobReplayRegistry.ts b/editor/app/jobs/jobReplayRegistry.ts @@ -1,12 +1,12 @@ // The data-driven replay table: maps a stored JobSpec's `kind` to the server // action that re-runs it. Previously this was a hard-coded switch in -// runJobSpec.ts; collapsing it to a lookup means adding a bookmarkable kind is +// runJobSpec.ts; collapsing it to a lookup means adding a replayable kind is // one colocated entry here (plus its jobKinds.ts metadata) instead of a new -// switch case. runJobSpec.ts is now a thin dispatcher over this table, and the -// retry actions (Phase 8) reuse the same seam. +// switch case. runJobSpec.ts is now a thin dispatcher over this table, which the +// retry actions (Retry / Retry all failed) drive. // -// Server-only: reached through bookmarkActions ("use server"), so it is never -// bundled to the client. +// Server-only: reached through the retry actions in actions.ts ("use server"), +// so it is never bundled to the client. import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { readChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; import type { JobSpec, ReplayBucket } from "yt-dlp-transcript-common/jobs/jobSpec"; @@ -83,7 +83,7 @@ function params(spec: JobSpec): { export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = { // Both digest lanes replay through one action; the lane comes from params so a - // bookmarked metered run stays metered (and is refused if the lane has since + // replayed metered run stays metered (and is refused if the lane has since // been turned off, rather than quietly falling back to local). "digest-channel-local": (spec) => { const { p, queueKey } = params(spec); @@ -121,7 +121,7 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = { }, "whisper-bucket-downloaded-no-transcript": async (spec) => { const { p, queueKey } = params(spec); - if (!spec.bucket) return { ok: false, error: "Bookmark is missing its bucket." }; + if (!spec.bucket) return { ok: false, error: "Job spec is missing its bucket." }; const ids = await idsForBucket(spec.slug, spec.bucket); if (ids.length === 0) { return { ok: false, error: "Nothing to transcribe right now.", info: true }; @@ -137,7 +137,7 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = { }, "redownload-incomplete-bucket": async (spec) => { const { queueKey } = params(spec); - if (!spec.bucket) return { ok: false, error: "Bookmark is missing its bucket." }; + if (!spec.bucket) return { ok: false, error: "Job spec is missing its bucket." }; const ids = await idsForBucket(spec.slug, spec.bucket); if (ids.length === 0) { return { ok: false, error: "Nothing to re-download right now.", info: true }; @@ -150,7 +150,7 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = { }, "retry-bucket": async (spec) => { const { p, queueKey } = params(spec); - if (!spec.bucket) return { ok: false, error: "Bookmark is missing its bucket." }; + if (!spec.bucket) return { ok: false, error: "Job spec is missing its bucket." }; const ids = await idsForBucket(spec.slug, spec.bucket); if (ids.length === 0) { return { ok: false, error: "Nothing to retry right now.", info: true }; @@ -243,7 +243,7 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = { }, "whisper-bucket-auto-subs": async (spec) => { const { p, queueKey } = params(spec); - if (!spec.bucket) return { ok: false, error: "Bookmark is missing its bucket." }; + if (!spec.bucket) return { ok: false, error: "Job spec is missing its bucket." }; const ids = await idsForBucket(spec.slug, spec.bucket); if (ids.length === 0) { return { @@ -275,7 +275,7 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = { // Replays correctly with nothing remembered: the batch re-derives its whole // work-list from disk, so a replay does what is missing NOW rather than what // was missing when the record was written. The kind scope is carried through - // so a bookmarked single-kind run stays single-kind. + // so a replayed single-kind run stays single-kind. "backfill-channel": (spec) => { const { p, queueKey } = params(spec); const kindIds = Array.isArray(p.kindIds) diff --git a/editor/app/jobs/loadBookmarks.ts b/editor/app/jobs/loadBookmarks.ts @@ -1,22 +0,0 @@ -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { readBookmarks, type JobBookmark } from "yt-dlp-transcript-common/jobs/bookmarks"; -import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; - -// Load bookmarks for the /jobs and /jobs/active pages, plus the set of channel -// slugs that no longer exist — so the UI can flag bookmarks whose channel was -// deleted and disable their Run-again button. -export async function loadBookmarksView(): Promise<{ - bookmarks: JobBookmark[]; - missingSlugs: string[]; -}> { - const paths = getPaths(); - const bookmarks = await readBookmarks(paths); - const slugs = Array.from(new Set(bookmarks.map((b) => b.spec.slug))); - const checks = await Promise.all( - slugs.map( - async (s) => [s, Boolean(await readChannelConfig(paths, s))] as const, - ), - ); - const missingSlugs = checks.filter(([, ok]) => !ok).map(([s]) => s); - return { bookmarks, missingSlugs }; -} diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx @@ -9,8 +9,6 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { ClearLogsMenu } from "./components/ClearLogsMenu"; import { JobsTable } from "./components/JobsTable"; import { RetryAllFailedButton } from "./components/RetryAllFailedButton"; -import { BookmarksMenu } from "./components/BookmarksMenu"; -import { loadBookmarksView } from "./loadBookmarks"; export const dynamic = "force-dynamic"; @@ -32,8 +30,7 @@ export default async function JobsPage({ searchParams: Promise<{ limit?: string | string[] }>; }) { const limit = parseLimit((await searchParams).limit); - const [{ entries, hasMore, total }, { bookmarks, missingSlugs }] = - await Promise.all([listAllJobs(getPaths(), { limit }), loadBookmarksView()]); + const { entries, hasMore, total } = await listAllJobs(getPaths(), { limit }); // Base the Retry-all affordance on the registry (what retryAllFailedAction // actually acts on), not just the loaded page. const hasRetryableFailed = getRegistry() @@ -48,7 +45,6 @@ export default async function JobsPage({ <ClearLogsMenu /> </div> </div> - <BookmarksMenu bookmarks={bookmarks} missingSlugs={missingSlugs} /> {total === 0 ? ( <p className="text-sm text-muted-foreground border border-dashed border-border rounded p-4"> No jobs have run yet. diff --git a/editor/app/jobs/runJobSpec.ts b/editor/app/jobs/runJobSpec.ts @@ -1,5 +1,5 @@ -// Server-only dispatcher: reached only through bookmarkActions ("use server"), -// so it is never bundled to the client. +// Server-only dispatcher: reached only through the retry actions in actions.ts +// ("use server"), so it is never bundled to the client. // // The per-kind replay logic lives in jobReplayRegistry.ts (a data-driven // lookup); this is just the dispatch over it. Re-running RE-DERIVES the work diff --git a/editor/app/widget/presetActions.ts b/editor/app/widget/presetActions.ts @@ -9,8 +9,7 @@ import { renameWidgetPreset, } from "yt-dlp-transcript-common/lib/widgetPresets"; -// Server actions behind the preset row, in the same shape and with the same -// revalidation as editor/app/jobs/bookmarkActions.ts. The in-widget menu reads +// Server actions behind the preset row. The in-widget menu reads // back through /api/widget/presets (it is a client component with no server // parent to revalidate into); the builder page SSR-seeds and this // revalidatePath refreshes it. diff --git a/editor/e2e/bookmarks.spec.ts b/editor/e2e/bookmarks.spec.ts @@ -1,355 +0,0 @@ -// Bookmark a running/finished job and re-launch it later. The key behavior is -// that re-running RE-DERIVES the work from the channel's current state rather -// than replaying a frozen video-id list: -// - whisper-all re-scans the disk for audio-without-transcript; -// - bucket jobs (retry partial downloads) read the CURRENT snapshot bucket. -// Both proofs change what the channel needs between the original run and the -// re-run, then assert the re-run acts on the new set. -// -// The bookmarks UI is split: a compact quick-access menu on /jobs and -// /jobs/active (one fire-and-forget run button per bookmark, NO inline log), -// and a full management page at /jobs/bookmarks (rename, delete, metadata, and -// the streaming "Run again" with its inline log). The behavioral re-derive -// proofs run against the management page's streaming log. - -import { mkdir, rename, rm, writeFile } from "node:fs/promises"; -import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, generateReport } from "./helpers"; - -const CHANNEL = "test-transcribe"; -const ROOT = `test-transcripts/channels/${CHANNEL}`; - -// Run "Transcribe missing" on the audio fixture and bookmark the resulting -// whisper-all job from the jobs table (newest bookmarkable row). Ends on /jobs, -// where the compact bookmarks menu is now shown. -async function transcribeAndBookmark(page: import("@playwright/test").Page) { - await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); - await page.getByRole("button", { name: "Transcribe missing" }).click(); - await expect(page.getByLabel("Transcribe missing output")).toContainText( - "3 succeeded", - { timeout: 30_000 }, - ); - await page.goto("/jobs"); - await page - .getByRole("button", { name: /bookmark job/i }) - .first() - .click(); - await expect(page.getByRole("heading", { name: "Bookmarks" })).toBeVisible({ - timeout: 15_000, - }); -} - -test("compact menu: one-click run is fire-and-forget with no inline log, and links to Manage", async ({ - page, -}) => { - test.setTimeout(60_000); - await resetData("one-transcribe-channel-with-audio"); - await transcribeAndBookmark(page); - - const menu = page.getByRole("region", { name: "Bookmarked jobs" }); - // "Manage" links to the full management page. - await expect(menu.getByRole("link", { name: "Manage" })).toHaveAttribute( - "href", - "/jobs/bookmarks", - ); - // The menu is buttons only — no inline shell / expanding log. - await expect(menu.getByRole("log")).toHaveCount(0); - - const runBtn = menu.getByRole("button", { - name: "run bookmark whisper-all · test-transcribe", - }); - await expect(runBtn).toBeVisible(); - await runBtn.click(); - - // Fire-and-forget: the button confirms the launch and still no log appears. - await expect(runBtn).toHaveText("Launched ✓", { timeout: 15_000 }); - await expect(menu.getByRole("log")).toHaveCount(0); -}); - -test("management page: Run again re-derives the current missing set", async ({ - page, -}) => { - test.setTimeout(60_000); - await resetData("one-transcribe-channel-with-audio"); - await transcribeAndBookmark(page); - await page.goto("/jobs/bookmarks"); - - const row = page.getByLabel("bookmark whisper-all · test-transcribe", { - exact: true, - }); - await expect(row).toBeVisible(); - - // Change the channel's state: remove one transcript so exactly one video now - // needs transcribing again. A frozen-id replay would re-attempt all three; a - // re-derive does only the one that's currently missing. - await rm(resolvePath(`${ROOT}/data/vidA/transcript.json`)); - - await row.getByRole("button", { name: "Run again" }).click(); - await expect( - page.getByRole("log", { name: /Run whisper-all/ }), - ).toContainText("1 succeeded", { timeout: 30_000 }); - expect(await pathExists(`${ROOT}/data/vidA/transcript.json`)).toBe(true); -}); - -test("management page: rename a bookmark and see created / last-run metadata", async ({ - page, -}) => { - test.setTimeout(60_000); - await resetData("one-transcribe-channel-with-audio"); - await transcribeAndBookmark(page); - await page.goto("/jobs/bookmarks"); - - const row = page.getByLabel("bookmark whisper-all · test-transcribe", { - exact: true, - }); - await expect(row).toContainText("Created"); - await expect(row).toContainText("Last run never"); - - // Rename. - await row - .getByRole("button", { name: "rename bookmark whisper-all · test-transcribe" }) - .click(); - await row - .getByLabel("new name for whisper-all · test-transcribe") - .fill("Nightly transcribe"); - await row.getByRole("button", { name: "Save" }).click(); - - const renamed = page.getByLabel("bookmark Nightly transcribe", { - exact: true, - }); - await expect(renamed).toBeVisible({ timeout: 15_000 }); - - // Run again updates the last-run timestamp (away from "never"). - await renamed.getByRole("button", { name: "Run again" }).click(); - await expect( - page.getByRole("log", { name: /Run Nightly transcribe/ }), - ).toContainText("Whisper batch:", { timeout: 30_000 }); - await expect( - page.getByLabel("bookmark Nightly transcribe", { exact: true }), - ).not.toContainText("Last run never", { timeout: 15_000 }); -}); - -test("management page: Run again retries whatever is partial now, then delete it", async ({ - page, -}) => { - test.setTimeout(60_000); - await resetData("one-transcribe-channel-with-audio"); - // The retry flow reads the playlist to resolve each id's URL. Slug-style URLs - // keep extractVideoId() === the id without needing platform claim ids. - await writeFile( - resolvePath(`${ROOT}/playlist`), - "https://www.youtube.com/watch?v=vidA\nhttps://www.youtube.com/watch?v=vidB\n", - ); - - // Strand vidA mid-download so it surfaces in the partial-downloads bucket. - await rename( - resolvePath(`${ROOT}/data/vidA/audio.m4a`), - resolvePath(`${ROOT}/data/vidA/audio.m4a.part`), - ); - - // Original run: resume the partial download (vidA). - await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); - await page - .getByLabel("retry resume partial downloads bucket") - .getByRole("button", { name: /^Retry \(1\)$/ }) - .click(); - await expect( - page.getByLabel("Retry resume partial downloads output"), - ).toContainText("Managed download complete", { timeout: 30_000 }); - expect(await pathExists(`${ROOT}/data/vidA/audio.m4a`)).toBe(true); - - // Bookmark the retry-bucket job, then manage it on /jobs/bookmarks. - await page.goto("/jobs"); - await page - .getByRole("button", { name: /bookmark job/i }) - .first() - .click(); - await expect(page.getByRole("heading", { name: "Bookmarks" })).toBeVisible({ - timeout: 15_000, - }); - await page.goto("/jobs/bookmarks"); - const row = page.getByLabel( - "bookmark retry-bucket (partialDownloads) · test-transcribe", - { exact: true }, - ); - await expect(row).toBeVisible({ timeout: 15_000 }); - - // Now make a DIFFERENT video partial (vidB) and force a fresh snapshot, so the - // partial-downloads bucket currently holds vidB, not the original vidA. - await rename( - resolvePath(`${ROOT}/data/vidB/audio.m4a`), - resolvePath(`${ROOT}/data/vidB/audio.m4a.part`), - ); - await rm(resolvePath(`${ROOT}/snapshot.json`), { force: true }); - await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); // regenerates snapshot when absent - await expect( - page.getByRole("heading", { name: /Partial downloads \(1\)/ }), - ).toBeVisible(); - - // Run again: re-derives the current bucket → downloads vidB (NOT the original - // vidA). This is the proof that re-running is not a frozen-id replay. - await page.goto("/jobs/bookmarks"); - await row.getByRole("button", { name: "Run again" }).click(); - await expect( - page.getByRole("log", { name: /Run retry-bucket/ }), - ).toContainText("Managed download complete", { timeout: 30_000 }); - expect(await pathExists(`${ROOT}/data/vidB/audio.m4a`)).toBe(true); - - // Delete the bookmark (confirm-gated). - await row - .getByRole("button", { - name: "delete bookmark retry-bucket (partialDownloads) · test-transcribe", - exact: true, - }) - .click(); - await row - .getByRole("button", { - name: "confirm delete bookmark retry-bucket (partialDownloads) · test-transcribe", - exact: true, - }) - .click(); - await expect(row).toHaveCount(0, { timeout: 15_000 }); -}); - -test("management page: reorder bookmarks with ↑/↓, persisted and reflected in the compact menu", async ({ - page, -}) => { - test.setTimeout(60_000); - await resetData("one-transcribe-channel-with-audio"); - - // Seed two distinct bookmarks for the existing channel directly into the - // store. Reordering only depends on the persisted array order, so this avoids - // running two real jobs (the re-derive behavior is covered by other tests). - // Initial array order is [whisper-all, retry-bucket]. - await mkdir(resolvePath("test-transcripts/.bookmarks"), { recursive: true }); - await writeFile( - resolvePath("test-transcripts/.bookmarks/bookmarks.json"), - JSON.stringify( - { - v: 1, - bookmarks: [ - { - id: "bm-whisper", - name: "whisper-all · test-transcribe", - spec: { kind: "whisper-all", slug: CHANNEL }, - createdAt: 1, - }, - { - id: "bm-retry", - name: "retry-bucket (partialDownloads) · test-transcribe", - spec: { - kind: "retry-bucket", - slug: CHANNEL, - bucket: "partialDownloads", - }, - createdAt: 2, - }, - ], - }, - null, - 2, - ) + "\n", - ); - - const WHISPER = "bookmark whisper-all · test-transcribe"; - const RETRY = "bookmark retry-bucket (partialDownloads) · test-transcribe"; - - await page.goto("/jobs/bookmarks"); - const items = page - .getByRole("region", { name: "Bookmarked jobs" }) - .getByRole("listitem"); - await expect(items).toHaveCount(2); - await expect(items.nth(0)).toHaveAttribute("aria-label", WHISPER); - await expect(items.nth(1)).toHaveAttribute("aria-label", RETRY); - - // Move the top bookmark (whisper-all) down → [retry-bucket, whisper-all]. - await page.getByRole("button", { name: `move ${WHISPER} down` }).click(); - await expect(items.nth(0)).toHaveAttribute("aria-label", RETRY); - await expect(items.nth(1)).toHaveAttribute("aria-label", WHISPER); - - // Arrows are disabled at the bounds. - await expect( - page.getByRole("button", { name: `move ${RETRY} up` }), - ).toBeDisabled(); - await expect( - page.getByRole("button", { name: `move ${WHISPER} down` }), - ).toBeDisabled(); - - // The new order survives a reload (it was written to disk). - await page.reload(); - await expect(items.nth(0)).toHaveAttribute("aria-label", RETRY); - await expect(items.nth(1)).toHaveAttribute("aria-label", WHISPER); - - // …and the compact menu on /jobs reflects the same order. - await page.goto("/jobs"); - await expect( - page - .getByRole("region", { name: "Bookmarked jobs" }) - .getByRole("button", { name: /^run bookmark/ }) - .nth(0), - ).toHaveAccessibleName( - "run bookmark retry-bucket (partialDownloads) · test-transcribe", - ); -}); - -test("management page: an empty re-derived bucket reads as a neutral notice, not an error", async ({ - page, -}) => { - test.setTimeout(60_000); - await resetData("one-transcribe-channel-with-audio"); - await writeFile( - resolvePath(`${ROOT}/playlist`), - "https://www.youtube.com/watch?v=vidA\n", - ); - await rename( - resolvePath(`${ROOT}/data/vidA/audio.m4a`), - resolvePath(`${ROOT}/data/vidA/audio.m4a.part`), - ); - - await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); - await page - .getByLabel("retry resume partial downloads bucket") - .getByRole("button", { name: /^Retry \(1\)$/ }) - .click(); - await expect( - page.getByLabel("Retry resume partial downloads output"), - ).toContainText("Managed download complete", { timeout: 30_000 }); - - await page.goto("/jobs"); - await page - .getByRole("button", { name: /bookmark job/i }) - .first() - .click(); - await expect(page.getByRole("heading", { name: "Bookmarks" })).toBeVisible({ - timeout: 15_000, - }); - await page.goto("/jobs/bookmarks"); - const row = page.getByLabel( - "bookmark retry-bucket (partialDownloads) · test-transcribe", - { exact: true }, - ); - await expect(row).toBeVisible({ timeout: 15_000 }); - - // vidA is fully downloaded now, so the partial-downloads bucket is empty. - // Refresh the snapshot to reflect that. - await rm(resolvePath(`${ROOT}/snapshot.json`), { force: true }); - await generateReport(page, CHANNEL); - await page.goto(`/channels/${CHANNEL}`); - await expect( - page.getByRole("heading", { name: /Partial downloads/ }), - ).toHaveCount(0); - - await page.goto("/jobs/bookmarks"); - await row.getByRole("button", { name: "Run again" }).click(); - // Neutral status, not a red alert. - await expect( - page.getByRole("status", { name: /Run retry-bucket.*notice/ }), - ).toContainText("Nothing to retry right now", { timeout: 30_000 }); - await expect( - page.getByRole("alert", { name: /Run retry-bucket/ }), - ).toHaveCount(0); -}); diff --git a/editor/e2e/jobs-retry.spec.ts b/editor/e2e/jobs-retry.spec.ts @@ -4,9 +4,9 @@ // no Retry. Failed jobs are staged as sidecar + log files so the scenario is // deterministic and exercises the sidecar-fallback path directly. -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { resetData, resolvePath } from "./helpers"; +import { generateReport, pathExists, resetData, resolvePath } from "./helpers"; import { baseUrl } from "./baseUrl"; async function invalidateCache() { @@ -88,3 +88,60 @@ test("Retry re-runs a failed job from its spec; spec-less failures offer none", .filter({ hasText: "running" }); await expect(runningSync).toBeVisible({ timeout: 15_000 }); }); + +// A second archived job of a DIFFERENT kind, so the replay table is driven for +// more than `sync`. A bucket spec stores only the bucket CATEGORY — never a +// frozen video-id list — so retrying it has to re-derive the bucket's current +// members. The proof: the video that was partial when the job originally ran is +// not the video that is partial now, and the retry acts on the current one. +test("Retry on an archived bucket job re-derives the bucket's current members", async ({ + page, +}) => { + test.setTimeout(90_000); + const CHANNEL = "test-transcribe"; + const ROOT = `test-transcripts/channels/${CHANNEL}`; + await resetData("one-transcribe-channel-with-audio"); + // The retry flow reads the playlist to resolve each id's URL. Slug-style URLs + // keep extractVideoId() === the id without needing platform claim ids. + await writeFile( + resolvePath(`${ROOT}/playlist`), + "https://www.youtube.com/watch?v=vidA\nhttps://www.youtube.com/watch?v=vidB\n", + ); + // Strand vidB — NOT the vidA the archived job would have run on — so the + // partial-downloads bucket currently holds exactly vidB. + await rename( + resolvePath(`${ROOT}/data/vidB/audio.m4a`), + resolvePath(`${ROOT}/data/vidB/audio.m4a.part`), + ); + await generateReport(page, CHANNEL); + await page.goto(`/channels/${CHANNEL}`); // regenerates the snapshot when absent + await expect( + page.getByRole("heading", { name: /Partial downloads \(1\)/ }), + ).toBeVisible(); + + const ts = 1_700_000_000_000; + await writeArchivedJob("failedbucket1", { + kind: "retry-bucket", + channelSlug: CHANNEL, + queueKey: "platform:youtube", + status: "failed", + queuedAt: ts, + startedAt: ts, + endedAt: ts + 1000, + exitCode: 1, + spec: { kind: "retry-bucket", slug: CHANNEL, bucket: "partialDownloads" }, + }); + await invalidateCache(); + + await page.goto("/jobs"); + const bucketRow = page.getByRole("row").filter({ hasText: "failedbucket1" }); + await expect(bucketRow.getByRole("button", { name: "Retry" })).toBeVisible(); + await bucketRow.getByRole("button", { name: "Retry" }).click(); + + // vidB (the CURRENT bucket member) gets its download resumed. + await expect + .poll(() => pathExists(`${ROOT}/data/vidB/audio.m4a`), { + timeout: 45_000, + }) + .toBe(true); +});