Archilyzer · Source

archilyzer

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

commit 54e830d68b71ed1bff78798fd20fafe0a7c41266
parent a5ef229dee7e3fc60a489d7de29066c5549f4d0f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 10 Aug 2026 22:54:10 -0400

Remove command bookmarks, and keep the replay seam Retry stands on

Bookmarks let an operator save a job's replay descriptor under a label and
re-launch it from a strip above /jobs and /jobs/active, plus a /jobs/bookmarks
management page. They were used for three days after landing and never again:
every kind they could re-launch is still one click from its own channel-page
control, and Retry already re-runs a job from the descriptor stored with it.

The care needed here is that bookmarks introduced the JobSpec + replay-registry
seam that Retry and Retry-all-failed now depend on. This removes the bookmark
layer (store, actions, UI, route) and leaves the replay layer alone, so
runJobSpec, jobReplayRegistry and every spec attachment site stay exactly as
they were.

Rename the DTO flag bookmarkable -> replayable on listJobs and jobKinds so no
concept is left named after a feature that no longer exists, and drop
isBookmarkableKind(), which had no callers. Comments across the replay seam
described themselves in terms of bookmarks — including jobSpec.ts defining a
spec as what you need to "bookmark" a job, and two headers naming the now
deleted bookmarkActions as their server-only entry point — so those are
reworded to describe Retry. Four user-facing replay errors that read "Bookmark
is missing its bucket." now say "Job spec".

Rename migration drops its fourth store: renameChannel no longer retargets
bookmarks, and paths.ts no longer carries bookmarksFile. The existing
transcripts/.bookmarks/bookmarks.json is left on disk as a record and simply
goes unread.

bookmarks.spec.ts was the only test driving the replay table for more than the
sync kind, so jobs-retry.spec.ts gains an archived retry-bucket job whose spec
names only a bucket category. Retrying it has to re-derive the bucket's current
members, which keeps that path covered behaviorally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Diffstat:
MPLAN.md | 2+-
Mcommon/controller/renameChannel.test.ts | 26+-------------------------
Mcommon/controller/renameChannel.ts | 9---------
Dcommon/jobs/bookmarks.ts | 175-------------------------------------------------------------------------------
Mcommon/jobs/jobKinds.ts | 112++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcommon/jobs/jobMeta.ts | 6+++---
Mcommon/jobs/jobSpec.ts | 15++++++++-------
Mcommon/jobs/listJobs.ts | 12++++++------
Mcommon/jobs/registry.ts | 4++--
Mcommon/jobs/streamCommand.ts | 2+-
Mcommon/lib/paths.ts | 5-----
Meditor/CHANGELOG.md | 1+
Meditor/app/api/pulse/route.ts | 2+-
Meditor/app/channels/[slug]/components/RetryBucketControl.tsx | 2+-
Meditor/app/channels/[slug]/incompleteTranscriptActions.ts | 4++--
Meditor/app/channels/[slug]/pipelineActions.ts | 6+++---
Meditor/app/channels/[slug]/whisperActions.ts | 2+-
Meditor/app/jobs/[id]/page.tsx | 2--
Meditor/app/jobs/actions.ts | 4++--
Meditor/app/jobs/active/buildActiveJobs.ts | 1-
Meditor/app/jobs/active/page.tsx | 6+-----
Deditor/app/jobs/bookmarkActions.ts | 106-------------------------------------------------------------------------------
Deditor/app/jobs/bookmarks/page.tsx | 37-------------------------------------
Deditor/app/jobs/components/BookmarkJobButton.tsx | 50--------------------------------------------------
Deditor/app/jobs/components/BookmarkRunButton.tsx | 94-------------------------------------------------------------------------------
Deditor/app/jobs/components/BookmarksList.tsx | 239-------------------------------------------------------------------------------
Deditor/app/jobs/components/BookmarksMenu.tsx | 45---------------------------------------------
Meditor/app/jobs/components/JobsTable.tsx | 4+---
Meditor/app/jobs/components/RunningJobsList.tsx | 4----
Meditor/app/jobs/jobReplayRegistry.ts | 22+++++++++++-----------
Deditor/app/jobs/loadBookmarks.ts | 22----------------------
Meditor/app/jobs/page.tsx | 6+-----
Meditor/app/jobs/runJobSpec.ts | 4++--
Deditor/e2e/bookmarks.spec.ts | 355-------------------------------------------------------------------------------
Meditor/e2e/jobs-retry.spec.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
35 files changed, 162 insertions(+), 1285 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; // The two CHANGELOG.md files the "cut release" flow reads and rewrites. They // are TRACKED source files, and cutting a release optionally makes a real git // commit — so e2e must be able to point them somewhere disposable. Overridable @@ -175,7 +171,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"), lmdbPath: path.join(transcriptsDir, "index.mdb"), exportDir, exportPublicDir, 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. - **Speaker capture can now run on the graphics card, and stops inventing speakers that were never there.** The old engine works by grouping voices it thinks sound alike, and it splits far too eagerly: on a 13-minute reaction video with one host it found **13 speakers**, and on the worst video in the corpus it found **35**. The new engine decides speaker turns directly instead of grouping them afterwards, and returns **4** in both cases — agreeing with the old one about how much of the video the main speaker talks for (73.5% against 73.1%) while collapsing the invented tail. It has no threshold to tune, and it caps at 4 speakers, so a panel of five will merge two of them rather than split one into twenty. Run `scripts/build-sortformer.sh`, then pick **Engine → Sortformer** under Settings → Diarization and paste the two paths it prints. The previous engine stays the default and stays supported for machines with no usable GPU. - **Speaker capture no longer has a length limit to worry about.** The old engine's memory grew with the square of how many turns a recording contains, which is why long streams had to be processed in windows and why a duration cap existed at all. The new engine holds a fixed amount of memory no matter how long the recording is — 558 MB for a 13-minute video or an 8-hour one — so nothing has to be windowed, capped, or decoded to a temporary file first. - **Switching speaker-capture engines correctly marks the old results as work to redo.** The two engines disagree about how many speakers exist, so a corpus half-captured by each is not one corpus. Changing the engine now shows every recording captured by the other one as outstanding backfill work, rather than leaving it looking finished. Changing unrelated settings — a clustering threshold the new engine does not even have — no longer marks anything stale. 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/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); +});