commit 5ccb1a8f113ba57f8bb50fef3e48fb66cf1dc6ed
parent 49814eee56824921830960c7b0ca813b08e301d5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 19 Jun 2026 19:24:36 -0400
Add ↑/↓ reordering of command bookmarks
The /jobs/bookmarks management list and the compact one-click menu atop
/jobs and /jobs/active both render bookmarks in stored array order, but
there was no way to change it — new bookmarks just landed on top. Each
management row now has ↑/↓ buttons (disabled at the ends) that move the
bookmark one slot, persisting the new order immediately. Because both
views read the same array in order, the compact menu reflects it too.
Adds moveBookmark() (atomic read→swap→write, like the other mutations)
and moveBookmarkAction(); the row buttons reuse the existing
useTransition + router.refresh idiom and the repo's ↑/↓ button styling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 147 insertions(+), 2 deletions(-)
diff --git a/common/jobs/bookmarks.ts b/common/jobs/bookmarks.ts
@@ -118,6 +118,24 @@ export async function removeBookmark(
if (next.length !== existing.length) await writeBookmarks(paths, next);
}
+// 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,
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Bookmarks can be reordered on the management page, and the order carries to the compact menu.** Both the `/jobs/bookmarks` management list and the compact one-click menu atop `/jobs` and `/jobs/active` render bookmarks in stored order, but there was no way to change it — new bookmarks just landed on top. Each row on the management page now has **↑ / ↓** buttons that move the bookmark one slot (disabled at the ends), persisting the new order immediately to `transcripts/.bookmarks/bookmarks.json`. Because both views read the same array in order, reordering on the management page is reflected in the compact quick-run menu too, so you can put your most-used job first. See `moveBookmark` in `common/jobs/bookmarks.ts`, `moveBookmarkAction` in `editor/app/jobs/bookmarkActions.ts`, and `editor/app/jobs/components/BookmarksList.tsx`.
- **"Retry partial downloads" no longer skips partials that carry an audio-check snapshot.** A transcribe channel's retry-bucket prefilter decided a video was "already complete" by looking for any `audio.*` file not ending in `.part` — which wrongly matched the audio-integrity snapshots (`audio.<ext>.part.good` / `.part.testing`) and sidecars (`audio.info.json`, `audio.live_chat.json`, `audio.*.tmp-*`) left in a partial video's dir. So a genuine `audio.<ext>.part` that happened to sit next to a `.part.good` snapshot got prefiltered out (`Prefilter: 0 missing destination files, N already complete` → `Nothing to fetch`), even though that same snapshot is excluded when the video is placed in the **Partial downloads** bucket. The prefilter (`destinationExists`) now reuses the same `isRealAudioFile` predicate the bucket uses, so the two agree and genuine partials resume. See `common/ytdlp/runYtdlp.ts` and `common/lib/videoStatus.ts`.
- **One-click "Drain all" to spin work down before a server restart.** The `/jobs/active` header gained a **Drain all** button that, in a single confirmed action, **drains every running job** (lets in-flight sub-operations finish, starts no new ones) and **cancels every queued job** — so you can wind the queue down gracefully before restarting the server instead of draining/cancelling each job row by hand. It reuses the existing per-job drain path (`requestDrain` drains running jobs and cancels queued ones), so semantics match the per-row **Drain**/**Cancel** buttons exactly; non-drainable running kinds are marked draining and finish on their own. A confirm prompt guards the bulk action. See `editor/app/jobs/components/DrainAllButton.tsx` and `drainAllAction` in `editor/app/jobs/actions.ts`.
- **Queued jobs now have a Cancel button right on the row.** On `/jobs/active`, a queued (not-yet-started) job previously offered no inline way to cancel it — you had to expand its log to find a control. Each active row now shows a **Cancel** button directly (queued *and* running), so you can drop a queued job without opening anything. The running-only **Drain** button is unchanged. See `editor/app/jobs/components/RunningJobsList.tsx`.
diff --git a/editor/app/jobs/bookmarkActions.ts b/editor/app/jobs/bookmarkActions.ts
@@ -7,6 +7,7 @@ import { readJobMeta } from "yt-dlp-transcript-common/jobs/jobMeta";
import {
addBookmark,
defaultBookmarkName,
+ moveBookmark,
readBookmarks,
removeBookmark,
updateBookmark,
@@ -48,6 +49,18 @@ export async function deleteBookmarkAction(
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(
diff --git a/editor/app/jobs/components/BookmarksList.tsx b/editor/app/jobs/components/BookmarksList.tsx
@@ -8,6 +8,7 @@ import type { JobBookmark } from "yt-dlp-transcript-common/jobs/bookmarks";
import { cancelJobAction } from "../actions";
import {
deleteBookmarkAction,
+ moveBookmarkAction,
renameBookmarkAction,
runBookmarkAction,
} from "../bookmarkActions";
@@ -34,11 +35,13 @@ export function BookmarksList({
Bookmarks
</h2>
<ul className="flex flex-col gap-3">
- {bookmarks.map((b) => (
+ {bookmarks.map((b, i) => (
<BookmarkRow
key={b.id}
bookmark={b}
channelMissing={missing.has(b.spec.slug)}
+ index={i}
+ total={bookmarks.length}
/>
))}
</ul>
@@ -54,9 +57,13 @@ function fmtDate(ms?: number): string {
function BookmarkRow({
bookmark,
channelMissing,
+ index,
+ total,
}: {
bookmark: JobBookmark;
channelMissing: boolean;
+ index: number;
+ total: number;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
@@ -80,6 +87,13 @@ function BookmarkRow({
});
}
+ function onMove(dir: -1 | 1) {
+ startTransition(async () => {
+ await moveBookmarkAction(bookmark.id, dir);
+ router.refresh();
+ });
+ }
+
return (
<li
aria-label={`bookmark ${bookmark.name}`}
@@ -150,6 +164,24 @@ function BookmarkRow({
</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-zinc-300 dark:border-zinc-700 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-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
+ >
+ ↓
+ </button>
{confirmingDelete ? (
<>
<button
diff --git a/editor/e2e/bookmarks.spec.ts b/editor/e2e/bookmarks.spec.ts
@@ -12,7 +12,7 @@
// the streaming "Run again" with its inline log). The behavioral re-derive
// proofs run against the management page's streaming log.
-import { rename, rm, writeFile } from "node:fs/promises";
+import { mkdir, rename, rm, writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
import { pathExists, resetData, resolvePath } from "./helpers";
@@ -211,6 +211,87 @@ test("management page: Run again retries whatever is partial now, then delete it
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,
}) => {