commit addfed539a2ffada1c736f925b4818f1eb3417c0
parent 025ed41d78557f162d403cbefd7209190c3291b4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 24 Jul 2026 18:14:56 -0400
Merge branch 'feat/dashboard-cockpit'
Dashboard mission-control cockpit + URL-first channel onboarding +
restart-surviving pause for transcriptions and downloads.
Diffstat:
31 files changed, 1838 insertions(+), 349 deletions(-)
diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts
@@ -323,12 +323,19 @@ async function runLoop(
stopController.abort();
return null;
}
- const policy: AutoQueuePolicy = getSettings().autoQueue[kind];
+ const settings = getSettings();
+ const policy: AutoQueuePolicy = settings.autoQueue[kind];
if (!policy.enabled) {
onLog(`Auto-${kind} disabled — stopping runner.`);
stopController.abort();
return null;
}
+ // Global downloads pause: like the `enabled` flag, this is re-read each
+ // iteration. Rather than stop the runner, idle it (return null) so it
+ // resumes dispatching on the next tick once unpaused — no restart needed.
+ if (kind === "download" && settings.downloadsPaused) {
+ return null;
+ }
// Refresh the (rarely-changing) channel list/platforms on a TTL.
const now = Date.now();
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -111,6 +111,16 @@ export type SiteSettings = {
// video once the stream ends. Per-channel override available
// (ChannelConfig.skipLiveDownloads).
skipLiveDownloads: boolean;
+ // Global, restart-surviving pause for transcription workers. When true, the
+ // worker pool is pause-all'd at boot (editor/instrumentation.ts) so no new
+ // transcriptions start until resumed. The Workers-page Pause button and the
+ // dashboard toggle both persist this. Default false. See pauseAllWorkersAction.
+ transcriptionsPaused: boolean;
+ // Global, restart-surviving pause for downloads. When true, the auto-download
+ // runner skips dispatch (checked each loop iteration, like `enabled`) and
+ // manual download-bearing pipeline actions return a "Downloads are paused"
+ // result. Enumeration/store-playlist stay allowed. Default false.
+ downloadsPaused: boolean;
// Whether site builds generate downloadable transcript/live-chat archive zips
// (into public/archives, linked on the Downloads page). Global default; a site
// can opt out via site.json `archives: false`, and a single build can skip via
@@ -476,6 +486,8 @@ function defaults(): SiteSettings {
parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT,
inlineTranscribeOnFallback: false,
skipLiveDownloads: true,
+ transcriptionsPaused: false,
+ downloadsPaused: false,
buildArchives: true,
archiveStorage: { bucket: "", publicBaseUrl: "" },
reportDebouncePreset: DEFAULT_REPORT_DEBOUNCE_PRESET,
@@ -671,6 +683,12 @@ export function getSettings(): SiteSettings {
if (typeof merged.skipLiveDownloads !== "boolean") {
merged.skipLiveDownloads = true;
}
+ if (typeof merged.transcriptionsPaused !== "boolean") {
+ merged.transcriptionsPaused = false;
+ }
+ if (typeof merged.downloadsPaused !== "boolean") {
+ merged.downloadsPaused = false;
+ }
if (typeof merged.buildArchives !== "boolean") {
merged.buildArchives = true;
}
@@ -861,6 +879,8 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
),
inlineTranscribeOnFallback: next.inlineTranscribeOnFallback === true,
skipLiveDownloads: next.skipLiveDownloads !== false,
+ transcriptionsPaused: next.transcriptionsPaused === true,
+ downloadsPaused: next.downloadsPaused === true,
buildArchives: next.buildArchives !== false,
archiveStorage: {
bucket:
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -352,6 +352,64 @@ export async function fetchFlatPlaylistUrls(opts: {
);
}
+// Lightweight metadata probe for the URL-first new-channel flow. Fetches a
+// SINGLE flat-playlist entry (`--playlist-items 1`, no media) and prints the
+// best available name field, so the create form can pre-fill the display name
+// from a pasted URL. Crucially it also proves yt-dlp *can* handle the URL — a
+// host the app's detectPlatform doesn't recognize but yt-dlp does still probes
+// successfully, which is how an unknown-platform channel gets created (routed to
+// its own `platform:<domain>` serial queue by downloadQueueKey). Returns what it
+// could read; a null name just means yt-dlp printed nothing usable.
+export async function probeChannelMeta(opts: {
+ url: string;
+ paths: Paths;
+ onLog?: (s: string) => void;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+}): Promise<{ name: string | null; entryCount: number | null }> {
+ const { url, paths } = opts;
+ const log = opts.onLog ?? (() => {});
+ // yt-dlp's field-fallback template prints the first non-null of the listed
+ // fields, so a channel URL yields "Veritasium" (channel), a bare playlist its
+ // title, etc. `--flat-playlist --playlist-items 1` keeps it to one cheap
+ // metadata read with no download.
+ const args = [
+ "--flat-playlist",
+ "--skip-download",
+ "--playlist-items",
+ "1",
+ "--print",
+ "%(channel,uploader,playlist_title,playlist,uploader_id,title)s",
+ url,
+ ];
+ log(`$ ${paths.ytdlpBin} ${args.join(" ")}\n`);
+ const child = execa(paths.ytdlpBin, args, {
+ cancelSignal: opts.signal,
+ all: false,
+ buffer: true,
+ reject: false,
+ timeout: opts.timeoutMs ?? 60_000,
+ });
+ child.stderr?.on("data", (c: Buffer) => log(c.toString("utf8")));
+ const result = await child;
+ if (
+ result.exitCode !== 0 &&
+ result.exitCode !== 101 &&
+ !opts.signal?.aborted
+ ) {
+ throw new Error(
+ `yt-dlp exited with code ${result.exitCode}${
+ result.stderr ? `: ${String(result.stderr).trim().slice(-400)}` : ""
+ }`,
+ );
+ }
+ const lines = String(result.stdout ?? "")
+ .split("\n")
+ .map((s) => s.trim())
+ .filter((s) => s && s !== "NA");
+ return { name: lines[0] ?? null, entryCount: null };
+}
+
async function storePlaylist(opts: RunYtdlpOpts): Promise<void> {
const root = channelRoot(opts);
await mkdir(root, { recursive: true });
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
+- **The dashboard is now a live mission-control cockpit.** The home page used to be a static SSR card stack (four stat tiles, a "needs attention" link, a bare channels table) — all the *live* operational density lived only in the opt-in `/widget` monitor. The dashboard is rebuilt as an information-first operations surface that updates in place, following the widget's proven pattern: `page.tsx` stays a server component that SSRs initial payloads and hands them to a client shell (`DashboardCockpit`) that polls the same `/api` endpoints (`/api/jobs/active`, `/api/workers`, `/api/widget/actionable`, `/api/widget/sync`). The hero is a full-width **Pipeline band** — a single instrument readout of running/queued jobs, worker-pool busy/total + pause state, sync heartbeat + scheduler on/off, the live job rows with progress bars, and the global controls (Pause transcriptions, Pause downloads, Sync all, + Add). Below it a **Needs-work** panel (top channels with ↓/✎ counts and inline Download/Transcribe actions, linking to `/actionable`) sits beside a **Quick-add / recent-changes** column, over an **enriched channels table** (relative "last sync" that ticks live, plus per-row inline Sync / Download-missing / Top-of-queue actions). Everything stays on the existing semantic "base" tokens (no new palette) and respects `prefers-reduced-motion`. The two ~1s poll hooks are extracted from the widget into `editor/app/widget/lib/usePolledPayload.ts` and the relative-time helpers into `.../relativeTime.ts`, shared by both surfaces. See `editor/app/page.tsx`, `editor/app/components/dashboard/*`, and `editor/e2e/dashboard.spec.ts`.
+- **URL-first channel onboarding.** The New-channel form now derives what it can from a pasted URL with **no network call** — platform (`detectPlatform`), handling (YouTube → subs, else transcribe), and a slug candidate (`@Veritasium` → `veritasium`, `/c/Some Name` → `some-name`) — and surfaces the **derived download queue** as a read-only hint (e.g. `Queue: platform:vimeo.com (new)` for a host the app doesn't recognize). An opt-in **Fetch details** button runs a lightweight single-entry yt-dlp probe (`probeChannelMeta` → `probeChannelUrlAction`) that fills the name and, crucially, lets a site **unknown to the app but known to yt-dlp** be created with its own `platform:<domain>` serial queue in the same action (no change to the closed `Platform` union). On create it now **always stores the playlist** ("Fetch playlist now", default on) so pending downloads populate immediately, with an optional **Add to top of auto-queue** that prepends a channel leaf at the head of the download policy tree and starts the runner (`prioritizeChannelDownloadAction`, also a one-click "Top of queue" on the dashboard channels table). Also fixes the missing **Kick** option in the platform select. See `editor/app/channels/{components/ChannelForm.tsx,actions.ts}`, `editor/app/auto-queue/actions.ts`, `common/ytdlp/runYtdlp.ts`, and `editor/e2e/new-channel-onboarding.spec.ts`.
+- **Pause is now first-class and survives restarts — for transcriptions and downloads.** Transcription pause was runtime-only (lost on restart) and buried on the Workers page; downloads had no pause at all. Both are now persisted in `settings.json` (`transcriptionsPaused` / `downloadsPaused`) and toggled from the dashboard Pipeline band (and, for parity, the monitor widget's controls). Pausing transcriptions persists the flag and re-pauses the worker pool at boot (`editor/instrumentation.ts`); pausing downloads idles the auto-download runner on its next loop iteration (re-read each tick, like the `enabled` flag) **and** makes manual download-bearing pipeline actions (`sync`, `download-from-playlist`, `download-missing`, `download-missing-subs`, `retry-bucket`) return a friendly "Downloads are paused" notice — store-playlist/enumeration stay allowed since they write no media. See `common/lib/settings.ts`, `editor/app/workers/actions.ts`, `editor/app/jobs/actions.ts`, `common/controller/autoRunner.ts`, and `editor/app/channels/[slug]/pipelineActions.ts`.
- **Channel groups gain a "Show channels as individual chips" checkbox on the Site form.** Toggling it on marks the group `inline` in site.json, so the viewer renders each member channel as its own loose chip in the filter row instead of a collapsible group box. New sites' seeded default group ("All channels") ships with it **on**; groups added manually on the Site form or created inline from a channel form default **off**. See `editor/app/sites/components/SiteForm.tsx`, `common/lib/channelGroups.ts`, and `editor/e2e/sites-crud.spec.ts`.
- **Cookies-from-browser is now configurable: three cookie modes, per-channel overrides, and a "Needs cookies" bucket.** The single global cookies value used to be hard-wired to one behavior (passed only on the download auth-retry attempt). Settings now carry a **Cookie mode** next to the value — **When required** (default; the old behavior, now also cookie-retrying a failed *metadata prefetch*, so an age-gated video succeeds on its first real attempt and reports `ok-with-cookies` with a `metadata-prefetch-auth-retry` attempt in `download-outcome.json`), **Always** (every yt-dlp invocation carries `--cookies-from-browser`: enumeration, prefetch, availability probes, downloads — for channels whose listing itself needs auth; a clean success still reports plain `ok`), and **Defer** (normal runs never use cookies: known `needs_auth` videos are excluded from sync/download-missing batches — previously they were re-attempted and re-failed on *every* run — and wait for a manual cookie run). Both the value and the mode can be overridden per channel in the channel form's Advanced section (blank/Inherit = use global). Every channel's Download stage gains a **Needs cookies (N)** card listing undownloaded videos whose recorded availability is cookie-recoverable (`needs_auth`, and also `members_only`/`private`, which batches always excluded but a subscribed/owning account's cookies can fetch) with a **Download with cookies** button that re-runs them with cookies forced on every invocation (bookmarkable, like the other bucket jobs; it warns if no cookie value is configured anywhere). Caveats: in defer mode a *manual single-video* download intentionally gets no cookies and no auth retry — the bucket button is the explicit cookie path; availability probes only attach cookies in Always mode, so needs_auth keeps being observed (defer depends on that signal). Back-compat: an existing `settings.json` without `cookieMode` behaves exactly as before (`when-required`). See `common/lib/cookiePolicy.ts`, `common/ytdlp/{downloadOneManaged,runYtdlp}.ts`, `common/controller/{channelSnapshot,checkAvailability}.ts`, and `editor/e2e/cookies-mode.spec.ts`.
- **Channel forms now edit site membership directly — pick sites, groups, and create groups inline.** A channel's site membership used to be editable only from the Site form (`/sites/<id>`), and creating a channel silently appended it to the active site (a hidden `activeSite` field) with no group choice and no visibility. Both the **New channel** and channel **Configure** forms now carry a **Sites** section: every configured site listed with a membership checkbox and a compact group dropdown — `(default)`, any existing group, or **"+ New group…"**, which reveals a name input and creates the group on that site (id slugified from the name, reused if it already exists) as part of the save. On create, the active site (`?site=`) is pre-checked, reproducing the old behavior but visibly and overridably; on edit, current memberships pre-check with their groups, and unchecking removes the membership. The checked sites serialize into one hidden `siteMembershipsJson` field (the SiteForm hidden-JSON precedent); the server plans all writes up front — validating group ids, preserving other channels' entries and this channel's `order`, erroring clearly on a since-deleted group, skipping since-deleted sites, and rewriting only sites that actually changed. See the new `editor/app/channels/{lib/siteMemberships.ts,components/SiteMembershipsSection.tsx}`, `editor/app/channels/{actions.ts,components/{ChannelForm,ChannelFormClient}.tsx,new/page.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-site-membership.spec.ts`.
diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts
@@ -25,7 +25,9 @@ export type WidgetSyncPayload = {
};
};
-export async function GET() {
+// Build the widget sync payload. Exported so the dashboard cockpit can seed its
+// initial sync readout server-side (SSR) without going through the HTTP route.
+export async function buildWidgetSyncPayload(): Promise<WidgetSyncPayload> {
const paths = getPaths();
const settings = getSettings();
const state = await readSchedulerState(paths);
@@ -59,7 +61,7 @@ export async function GET() {
}
}
- return NextResponse.json({
+ return {
lastSyncAllAt: state.lastSyncAllAt,
lastIndividualSyncAt,
scheduler: {
@@ -68,5 +70,9 @@ export async function GET() {
nextRunAt,
overdue,
},
- } satisfies WidgetSyncPayload);
+ } satisfies WidgetSyncPayload;
+}
+
+export async function GET() {
+ return NextResponse.json(await buildWidgetSyncPayload());
}
diff --git a/editor/app/auto-queue/actions.ts b/editor/app/auto-queue/actions.ts
@@ -11,7 +11,11 @@ import {
stopAutoRunner,
} from "yt-dlp-transcript-common/controller/autoRunner";
import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState";
-import type { AutoQueueGroup } from "yt-dlp-transcript-common/jobs/autoQueuePolicy";
+import {
+ isGroup,
+ type AutoQueueGroup,
+ type AutoQueueNode,
+} from "yt-dlp-transcript-common/jobs/autoQueuePolicy";
export type SaveResult = { ok: true } | { ok: false; error: string };
@@ -68,3 +72,58 @@ export async function stopAutoQueueAction(
revalidatePath("/auto-queue");
return { ok: true };
}
+
+// Recursively drop every leaf that matches this channel, so re-prioritizing the
+// same channel doesn't accumulate duplicate leaves (a group emptied of children
+// is kept — sanitizeAutoQueue tolerates it, and removing it could orphan a
+// group the operator configured). Returns a fresh tree.
+function stripChannelLeaves(node: AutoQueueNode, slug: string): AutoQueueNode {
+ if (!isGroup(node)) return node;
+ const children = node.children
+ .filter(
+ (c) =>
+ isGroup(c) ||
+ !(c.match.type === "channel" && c.match.value === slug),
+ )
+ .map((c) => stripChannelLeaves(c, slug));
+ return { ...node, children };
+}
+
+// "Add to top of auto-queue": prepend a channel leaf at the HEAD of the download
+// policy's strict root (first child = highest priority), enable the download
+// policy, and start the runner if it isn't up. Directly uses the existing
+// policy-tree engine — no engine change. Idempotent: any prior leaf for this
+// channel is stripped first so the head stays the single owner (first-match-wins
+// in buildPendingByLeaf).
+export async function prioritizeChannelDownloadAction(
+ slug: string,
+): Promise<SaveResult> {
+ const trimmed = (slug ?? "").trim();
+ if (!trimmed) return { ok: false, error: "No channel slug supplied." };
+ const current = getSettings();
+ const download = current.autoQueue.download;
+ const stripped = stripChannelLeaves(download.root, trimmed) as AutoQueueGroup;
+ const nextRoot: AutoQueueGroup = {
+ ...stripped,
+ children: [
+ { id: `prioritize-${trimmed}`, match: { type: "channel", value: trimmed } },
+ ...stripped.children,
+ ],
+ };
+ const next: SiteSettings = {
+ ...current,
+ autoQueue: {
+ ...current.autoQueue,
+ download: { ...download, enabled: true, root: nextRoot },
+ },
+ };
+ try {
+ await writeSettings(next);
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ await startAutoRunner("download");
+ revalidatePath("/auto-queue");
+ revalidatePath("/");
+ return { ok: true };
+}
diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts
@@ -92,6 +92,18 @@ async function runPipelineAction(
if (!channelConfig.url) {
return { ok: false, error: "Channel has no `url` configured" };
}
+ // Global downloads pause: every mode that writes media is blocked with a
+ // friendly notice (info, not an error). store-playlist only enumerates the
+ // video list — no media is written — so it stays allowed even while paused.
+ if (mode !== "store-playlist" && getSettings().downloadsPaused) {
+ return {
+ ok: false,
+ info: true,
+ error:
+ "Downloads are paused. Resume downloads from the dashboard (or Jobs) " +
+ "to run this.",
+ };
+ }
// Platform key shared with the auto-download runner (detectPlatform(url) ??
// "unknown") — drives the 429 cooldown both honor.
const platform = detectPlatform(channelConfig.url) ?? "unknown";
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -5,6 +5,12 @@ import { redirect } from "next/navigation";
import slugify from "@sindresorhus/slugify";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import {
+ detectPlatform,
+ queueKeyForUrl,
+ type Platform,
+} from "yt-dlp-transcript-common/lib/platform";
+import { probeChannelMeta } from "yt-dlp-transcript-common/ytdlp/runYtdlp";
+import {
channelExists,
createChannel,
deleteChannel,
@@ -32,10 +38,61 @@ import {
parseSiteMembershipsField,
planSiteMembershipWrites,
} from "./lib/siteMemberships";
-import { syncAction } from "./[slug]/pipelineActions";
+import { storePlaylistAction, syncAction } from "./[slug]/pipelineActions";
+import { prioritizeChannelDownloadAction } from "../auto-queue/actions";
export type ActionResult = { error: string } | undefined;
+export type ProbeChannelResult =
+ | {
+ ok: true;
+ // Display-name candidate yt-dlp printed (null when it printed nothing
+ // usable). The client slugifies this to seed the slug field.
+ name: string | null;
+ // What detectPlatform() makes of the URL offline (null for a host it
+ // doesn't recognize but yt-dlp still handled).
+ platformDetected: Platform | null;
+ // The serial queue this channel's downloads will land on (e.g.
+ // "platform:youtube" or, for an unknown host, "platform:vimeo.com").
+ queueKey: string;
+ // False when the host is unknown to the app — surfaced as "(new)" in the
+ // form hint. A successful probe on an unknown host is exactly the
+ // "yt-dlp supports it, the app didn't know" case.
+ queueKnown: boolean;
+ }
+ | { ok: false; error: string };
+
+// Opt-in yt-dlp probe for the URL-first new-channel flow. Runs a single
+// flat-playlist metadata read (no download) to prove the URL is fetchable and
+// pull a display-name candidate — including for hosts detectPlatform() doesn't
+// recognize but yt-dlp does, which is how an unknown-platform channel is
+// created (its downloads route to a per-domain serial queue). No writes.
+export async function probeChannelUrlAction(
+ url: string,
+): Promise<ProbeChannelResult> {
+ const trimmed = (url ?? "").trim();
+ if (!/^https?:\/\/\S+/i.test(trimmed)) {
+ return {
+ ok: false,
+ error: "Enter a valid channel or playlist URL (http/https).",
+ };
+ }
+ const platformDetected = detectPlatform(trimmed);
+ const queueKey = queueKeyForUrl(trimmed);
+ try {
+ const meta = await probeChannelMeta({ url: trimmed, paths: getPaths() });
+ return {
+ ok: true,
+ name: meta.name,
+ platformDetected,
+ queueKey,
+ queueKnown: platformDetected !== null,
+ };
+ } catch (e) {
+ return { ok: false, error: `yt-dlp probe failed: ${(e as Error).message}` };
+ }
+}
+
export async function createChannelAction(
_prev: ActionResult,
formData: FormData,
@@ -84,6 +141,29 @@ export async function createChannelAction(
error: `Channel "${slug}" was created, but updating site memberships failed: ${(e as Error).message}. Open its Configure panel to retry.`,
};
}
+ // URL-first onboarding: unless opted out ("Fetch playlist now", default on),
+ // immediately store the channel's playlist so undownloadedIds populates for
+ // the snapshot + auto-queue. Needs a URL to do anything; storePlaylistAction
+ // no-ops (returns !ok) otherwise, which we ignore. Fire-and-forget — cancel
+ // the stream so buffered chunks GC while the job runs server-side.
+ if (config.url && formData.get("fetchPlaylist") != null) {
+ try {
+ const res = await storePlaylistAction(slug);
+ if (res.ok) void res.stream.cancel();
+ } catch {
+ /* best-effort — the channel was created regardless */
+ }
+ }
+ // Optional "Add to top of auto-queue": prepend a channel leaf at the head of
+ // the download policy tree and start the runner, for a channel you want
+ // auto-downloading fast.
+ if (config.url && formData.get("prioritizeDownload") != null) {
+ try {
+ await prioritizeChannelDownloadAction(slug);
+ } catch {
+ /* best-effort — the channel was created regardless */
+ }
+ }
revalidatePath("/channels");
revalidatePath("/sites");
revalidatePath("/");
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -1,5 +1,13 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import slugify from "@sindresorhus/slugify";
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
import {
+ detectPlatform,
+ queueKeyForUrl,
+} from "yt-dlp-transcript-common/lib/platform";
+import {
DOWNLOAD_FORMAT_LABELS,
DOWNLOAD_FORMAT_PRESETS,
} from "yt-dlp-transcript-common/ytdlp/downloadFormat";
@@ -15,12 +23,61 @@ import {
AUDIO_CHECK_MAX_ROLLBACKS_MIN,
} from "yt-dlp-transcript-common/lib/channelConfig";
import { SYNC_INTERVAL_PRESETS } from "../../scheduler/intervalPresets";
+import { probeChannelUrlAction } from "../actions";
import {
SiteMembershipsSection,
type InitialMembership,
type SiteMembershipOption,
} from "./SiteMembershipsSection";
+// Path segments that never make a good slug (feed/tab names, container labels),
+// so slugCandidateFromUrl skips them when picking the channel name from a URL.
+const SLUG_NOISE_SEGMENTS = new Set([
+ "videos",
+ "featured",
+ "streams",
+ "playlists",
+ "community",
+ "channel",
+ "channels",
+ "c",
+ "user",
+ "about",
+ "shorts",
+ "live",
+]);
+
+// Offline slug candidate from a channel/playlist URL, no network. Prefers an
+// @handle (@Veritasium -> veritasium); otherwise the last non-noise path segment
+// (/c/Some Name -> some-name). Returns "" when nothing usable is found, so the
+// caller falls back to the name-derived slug.
+function slugCandidateFromUrl(url: string): string {
+ const trimmed = url.trim();
+ if (!trimmed) return "";
+ try {
+ const u = new URL(trimmed);
+ const segs = u.pathname
+ .split("/")
+ .map((s) => {
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return s;
+ }
+ })
+ .filter(Boolean);
+ const handle = segs.find((s) => s.startsWith("@"));
+ const base =
+ handle ??
+ [...segs].reverse().find((s) => !SLUG_NOISE_SEGMENTS.has(s.toLowerCase())) ??
+ segs[segs.length - 1] ??
+ "";
+ return slugify(base.replace(/^@/, ""));
+ } catch {
+ return "";
+ }
+}
+
type Props = {
action: string | ((formData: FormData) => void | Promise<void>);
initial?: { slug: string; config: ChannelConfig };
@@ -45,6 +102,79 @@ export function ChannelForm({
}: Props) {
const isEdit = !!initial;
const c = initial?.config;
+
+ // URL-first onboarding state (create mode only — edit mode keeps the plain
+ // uncontrolled fields). We track "touched" flags so the offline autofill and
+ // the Fetch-details probe only ever fill a field the operator hasn't set by
+ // hand. Hooks run unconditionally (rules of hooks); they're simply unused in
+ // edit mode.
+ const [name, setName] = useState(c?.name ?? "");
+ const [slug, setSlug] = useState("");
+ const [slugTouched, setSlugTouched] = useState(false);
+ const [url, setUrl] = useState(c?.url ?? "");
+ const [handling, setHandling] = useState<"youtube" | "transcribe">(
+ c?.handling === "transcribe" ? "transcribe" : "youtube",
+ );
+ const [handlingTouched, setHandlingTouched] = useState(false);
+ const [platform, setPlatform] = useState<string>(c?.platform ?? "");
+ const [platformTouched, setPlatformTouched] = useState(false);
+ const [probe, setProbe] = useState<{
+ state: "idle" | "loading" | "done" | "error";
+ message?: string;
+ queueKey?: string;
+ queueKnown?: boolean;
+ }>({ state: "idle" });
+
+ // Offline autofill: derive platform / handling / slug from the URL alone, with
+ // NO network call. Only fills untouched fields, so a hand-set value sticks.
+ useEffect(() => {
+ if (isEdit) return;
+ const detected = detectPlatform(url);
+ if (!platformTouched) setPlatform(detected ?? "");
+ if (!handlingTouched && url.trim()) {
+ setHandling(detected === "youtube" ? "youtube" : "transcribe");
+ }
+ if (!slugTouched) {
+ const cand = slugCandidateFromUrl(url);
+ if (cand) setSlug(cand);
+ }
+ }, [url, isEdit, platformTouched, handlingTouched, slugTouched]);
+
+ // The derived download queue this channel will use. Offline from the URL; the
+ // probe result (which may prove an unknown host) overrides it once fetched.
+ const offlineQueueKey = url.trim() ? queueKeyForUrl(url) : null;
+ const shownQueueKey =
+ probe.state === "done" && probe.queueKey ? probe.queueKey : offlineQueueKey;
+ const queueKnown =
+ probe.state === "done"
+ ? probe.queueKnown === true
+ : detectPlatform(url) !== null;
+
+ async function fetchDetails() {
+ if (!url.trim()) return;
+ setProbe({ state: "loading" });
+ try {
+ const res = await probeChannelUrlAction(url);
+ if (!res.ok) {
+ setProbe({ state: "error", message: res.error });
+ return;
+ }
+ if (res.name) {
+ setName(res.name);
+ if (!slugTouched) setSlug(slugify(res.name));
+ }
+ if (!platformTouched) setPlatform(res.platformDetected ?? "");
+ setProbe({
+ state: "done",
+ message: res.name ?? undefined,
+ queueKey: res.queueKey,
+ queueKnown: res.queueKnown,
+ });
+ } catch (e) {
+ setProbe({ state: "error", message: (e as Error).message });
+ }
+ }
+
return (
<form action={action} className="flex flex-col gap-4 max-w-xl">
{errorMessage && (
@@ -53,13 +183,28 @@ export function ChannelForm({
</div>
)}
<Section title="Identity">
- <Field
- label="Name"
- name="name"
- required
- defaultValue={c?.name ?? ""}
- placeholder="Display name (e.g., TheQuartering)"
- />
+ {isEdit ? (
+ <Field
+ label="Name"
+ name="name"
+ required
+ defaultValue={c?.name ?? ""}
+ placeholder="Display name (e.g., TheQuartering)"
+ />
+ ) : (
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Name</span>
+ <input
+ type="text"
+ name="name"
+ required
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ placeholder="Display name (e.g., TheQuartering)"
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ </label>
+ )}
{isEdit ? (
<Field
label="Slug"
@@ -69,11 +214,20 @@ export function ChannelForm({
hint="To change the slug, use Rename in the Danger zone below."
/>
) : (
- <Field
- label="Slug"
- name="slug"
- placeholder="kebab-case (auto-derived from name if blank)"
- />
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Slug</span>
+ <input
+ type="text"
+ name="slug"
+ value={slug}
+ onChange={(e) => {
+ setSlug(e.target.value);
+ setSlugTouched(true);
+ }}
+ placeholder="kebab-case (auto-derived from URL or name if blank)"
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ </label>
)}
</Section>
<Section title="Sites">
@@ -84,52 +238,203 @@ export function ChannelForm({
/>
</Section>
<Section title="Source">
- <fieldset className="flex flex-col gap-2">
- <legend className="text-sm font-medium">Handling</legend>
- <label className="flex items-center gap-2 text-sm">
- <input
- type="radio"
- name="handling"
- value="youtube"
- defaultChecked={c?.handling !== "transcribe"}
- />
- YouTube (auto-subtitles via yt-dlp)
- </label>
- <label className="flex items-center gap-2 text-sm">
- <input
- type="radio"
- name="handling"
- value="transcribe"
- defaultChecked={c?.handling === "transcribe"}
+ {isEdit ? (
+ <>
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium">Handling</legend>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="youtube"
+ defaultChecked={c?.handling !== "transcribe"}
+ />
+ YouTube (auto-subtitles via yt-dlp)
+ </label>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="transcribe"
+ defaultChecked={c?.handling === "transcribe"}
+ />
+ Transcribe (download audio, run whisper-cpp)
+ </label>
+ </fieldset>
+ <Field
+ label="URL"
+ name="url"
+ defaultValue={c?.url ?? ""}
+ placeholder="https://www.youtube.com/@channel/videos"
+ hint="Channel or playlist URL passed to yt-dlp."
/>
- Transcribe (download audio, run whisper-cpp)
- </label>
- </fieldset>
- <Field
- label="URL"
- name="url"
- defaultValue={c?.url ?? ""}
- placeholder="https://www.youtube.com/@channel/videos"
- hint="Channel or playlist URL passed to yt-dlp."
- />
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Platform</span>
- <select
- name="platform"
- defaultValue={c?.platform ?? ""}
- className="rounded border border-border bg-card px-2 py-1 text-sm"
- >
- <option value="">Auto (detect from URL)</option>
- <option value="youtube">YouTube</option>
- <option value="rumble">Rumble</option>
- <option value="odysee">Odysee</option>
- <option value="twitch">Twitch</option>
- </select>
- <span className="text-xs text-muted-foreground">
- Used as the default job queue, so all channels on the same platform
- run sequentially.
- </span>
- </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Platform</span>
+ <select
+ name="platform"
+ defaultValue={c?.platform ?? ""}
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ <option value="">Auto (detect from URL)</option>
+ <option value="youtube">YouTube</option>
+ <option value="rumble">Rumble</option>
+ <option value="odysee">Odysee</option>
+ <option value="twitch">Twitch</option>
+ <option value="kick">Kick</option>
+ </select>
+ <span className="text-xs text-muted-foreground">
+ Used as the default job queue, so all channels on the same
+ platform run sequentially.
+ </span>
+ </label>
+ </>
+ ) : (
+ <>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">URL</span>
+ <div className="flex items-stretch gap-2">
+ <input
+ type="text"
+ name="url"
+ value={url}
+ onChange={(e) => setUrl(e.target.value)}
+ placeholder="https://www.youtube.com/@channel/videos"
+ aria-label="URL"
+ className="flex-1 rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ <button
+ type="button"
+ onClick={fetchDetails}
+ disabled={!url.trim() || probe.state === "loading"}
+ aria-label="fetch details"
+ className="shrink-0 rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
+ >
+ {probe.state === "loading" ? "Fetching…" : "Fetch details"}
+ </button>
+ </div>
+ <span className="text-xs text-muted-foreground">
+ Channel or playlist URL passed to yt-dlp. Platform, handling and
+ a slug are derived from the URL automatically; Fetch details runs
+ a one-entry yt-dlp probe to fill the name (and prove a new
+ platform works).
+ </span>
+ {shownQueueKey && (
+ <span
+ aria-label="derived queue"
+ className="text-xs text-muted-foreground font-mono"
+ >
+ Queue: {shownQueueKey}
+ {queueKnown ? "" : " (new)"}
+ </span>
+ )}
+ {probe.state === "done" && (
+ <span
+ role="status"
+ aria-label="probe result"
+ className="text-xs text-success"
+ >
+ {probe.message
+ ? `yt-dlp found "${probe.message}".`
+ : "yt-dlp reached the URL."}
+ </span>
+ )}
+ {probe.state === "error" && (
+ <span role="alert" className="text-xs text-destructive">
+ {probe.message}
+ </span>
+ )}
+ </label>
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium">Handling</legend>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="youtube"
+ checked={handling === "youtube"}
+ onChange={() => {
+ setHandling("youtube");
+ setHandlingTouched(true);
+ }}
+ />
+ YouTube (auto-subtitles via yt-dlp)
+ </label>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="transcribe"
+ checked={handling === "transcribe"}
+ onChange={() => {
+ setHandling("transcribe");
+ setHandlingTouched(true);
+ }}
+ />
+ Transcribe (download audio, run whisper-cpp)
+ </label>
+ </fieldset>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Platform</span>
+ <select
+ name="platform"
+ value={platform}
+ onChange={(e) => {
+ setPlatform(e.target.value);
+ setPlatformTouched(true);
+ }}
+ aria-label="platform"
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ <option value="">Auto (detect from URL)</option>
+ <option value="youtube">YouTube</option>
+ <option value="rumble">Rumble</option>
+ <option value="odysee">Odysee</option>
+ <option value="twitch">Twitch</option>
+ <option value="kick">Kick</option>
+ </select>
+ <span className="text-xs text-muted-foreground">
+ Used as the default job queue, so all channels on the same
+ platform run sequentially. Leave on Auto for a host yt-dlp knows
+ but the app doesn't — it gets its own per-domain queue.
+ </span>
+ </label>
+ <fieldset className="flex flex-col gap-2 rounded-md border border-border px-3 py-2">
+ <legend className="px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
+ On create
+ </legend>
+ <label className="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ name="fetchPlaylist"
+ defaultChecked
+ className="mt-1"
+ />
+ <span className="flex flex-col gap-0.5">
+ <span className="font-medium">Fetch playlist now</span>
+ <span className="text-xs text-muted-foreground">
+ Store the channel's video list immediately (a
+ store-playlist job) so its pending downloads populate right
+ away. Requires a URL.
+ </span>
+ </span>
+ </label>
+ <label className="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ name="prioritizeDownload"
+ className="mt-1"
+ />
+ <span className="flex flex-col gap-0.5">
+ <span className="font-medium">Add to top of auto-queue</span>
+ <span className="text-xs text-muted-foreground">
+ Prepend this channel to the auto-download queue and start the
+ runner, so its videos download first. Requires a URL.
+ </span>
+ </span>
+ </label>
+ </fieldset>
+ </>
+ )}
<SyncIntervalField value={c?.syncIntervalMinutes} />
</Section>
<Section title="Transcription">
diff --git a/editor/app/components/dashboard/ChannelsTable.tsx b/editor/app/components/dashboard/ChannelsTable.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import Link from "next/link";
+import { useState, useTransition } from "react";
+import { syncAction } from "../../channels/[slug]/pipelineActions";
+import { prioritizeChannelDownloadAction } from "../../auto-queue/actions";
+import { InlineActionButton } from "../../actionable/components/InlineActionButton";
+import { fmtTime } from "../../widget/lib/relativeTime";
+import type { DashboardChannel } from "./types";
+
+// The enriched channels table: the plain slug/handling/videos list plus a
+// relative "last sync" that ticks live, and per-row inline actions (Sync,
+// Download missing, and a one-click "Top of queue" that prioritizes the channel
+// for auto-download).
+export function ChannelsTable({
+ channels,
+ now,
+}: {
+ channels: DashboardChannel[];
+ now: number | null;
+}) {
+ if (channels.length === 0) return null;
+ return (
+ <section className="flex flex-col gap-3">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ Channels
+ </h2>
+ <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
+ <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full">
+ <thead className="bg-muted">
+ <tr>
+ <th className="text-left font-medium px-3 py-2">Slug</th>
+ <th className="text-left font-medium px-3 py-2">Handling</th>
+ <th className="text-right font-medium px-3 py-2">Videos</th>
+ <th className="text-left font-medium px-3 py-2 whitespace-nowrap">
+ Last sync
+ </th>
+ <th className="text-left font-medium px-3 py-2">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {channels.map((c) => (
+ <tr
+ key={c.slug}
+ aria-label={`channel row ${c.slug}`}
+ className="border-t border-border align-top"
+ >
+ <td className="px-3 py-2 font-mono">
+ <Link
+ href={`/channels/${c.slug}`}
+ className="underline underline-offset-2 hover:text-brand transition-colors"
+ >
+ {c.slug}
+ </Link>
+ </td>
+ <td className="px-3 py-2">{c.handling}</td>
+ <td className="px-3 py-2 text-right tabular-nums">
+ {c.videoCount}
+ </td>
+ <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap tabular-nums">
+ {c.lastSyncedAt == null
+ ? "never"
+ : (fmtTime(c.lastSyncedAt, now, false) ?? "…")}
+ </td>
+ <td className="px-3 py-2">
+ <div className="flex flex-wrap items-center gap-1.5">
+ {c.hasUrl ? (
+ <>
+ <InlineSyncButton slug={c.slug} />
+ {c.undownloaded > 0 && (
+ <InlineActionButton
+ variant={{ kind: "downloadMissing", slug: c.slug }}
+ />
+ )}
+ <PrioritizeButton slug={c.slug} />
+ </>
+ ) : (
+ <span className="text-xs text-muted-foreground">
+ no URL
+ </span>
+ )}
+ </div>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </section>
+ );
+}
+
+// Single-channel Sync: kicks the streaming sync and drains the stream so the job
+// keeps running server-side while this row returns to idle.
+function InlineSyncButton({ slug }: { slug: string }) {
+ const [status, setStatus] = useState<"idle" | "running" | "done" | "error">(
+ "idle",
+ );
+ const [message, setMessage] = useState<string | null>(null);
+
+ async function handleClick() {
+ setStatus("running");
+ setMessage(null);
+ try {
+ const res = await syncAction(slug);
+ if (!res.ok) {
+ setStatus("error");
+ setMessage(res.error);
+ return;
+ }
+ void res.stream.cancel();
+ setStatus("done");
+ } catch (e) {
+ setStatus("error");
+ setMessage((e as Error).message);
+ }
+ }
+
+ return (
+ <span className="inline-flex items-center gap-1.5">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={status === "running"}
+ aria-label={`sync ${slug}`}
+ className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
+ >
+ {status === "running" ? "Syncing…" : "Sync"}
+ </button>
+ {status === "done" && (
+ <span className="text-xs text-muted-foreground">queued</span>
+ )}
+ {status === "error" && message && (
+ <span role="alert" className="text-xs text-destructive">
+ {message}
+ </span>
+ )}
+ </span>
+ );
+}
+
+// One-click "Top of queue": prepend this channel to the auto-download policy and
+// start the runner (prioritizeChannelDownloadAction).
+function PrioritizeButton({ slug }: { slug: string }) {
+ const [pending, startTransition] = useTransition();
+ const [done, setDone] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ function handleClick() {
+ setError(null);
+ startTransition(async () => {
+ const res = await prioritizeChannelDownloadAction(slug);
+ if (res.ok) setDone(true);
+ else setError(res.error);
+ });
+ }
+
+ return (
+ <span className="inline-flex items-center gap-1.5">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={pending || done}
+ aria-label={`prioritize ${slug} for download`}
+ title="Prepend to the auto-download queue and start the runner"
+ className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
+ >
+ {done ? "Queued ↑" : pending ? "…" : "Top of queue"}
+ </button>
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}
+ </span>
+ );
+}
diff --git a/editor/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs";
+import type { WorkersPayload } from "../../workers/components/WorkersView";
+import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import type { WidgetSyncPayload } from "../../api/widget/sync/route";
+import { usePolledPayload, useNow } from "../../widget/lib/usePolledPayload";
+import { PipelineBand } from "./PipelineBand";
+import { NeedsWorkPanel } from "./NeedsWorkPanel";
+import { QuickAddPanel } from "./QuickAddPanel";
+import { ChannelsTable } from "./ChannelsTable";
+import type { DashboardChannel } from "./types";
+
+// Poll cadences mirror the monitor widget: job/worker state moves by the second;
+// the worklist and sync/scheduler markers move by the minute, so they poll no
+// faster than every 15s regardless.
+const FAST_MS = 1000;
+const SLOW_MS = 15000;
+
+export type DashboardCockpitInitial = {
+ jobs: ActiveJobsPayload;
+ workers: WorkersPayload;
+ actionable: WidgetActionablePayload;
+ sync: WidgetSyncPayload;
+ channels: DashboardChannel[];
+ changelogSource: string | null;
+};
+
+// The mission-control cockpit. A server component (app/page.tsx) SSRs the
+// initial payloads for a flicker-free first paint; this client shell then polls
+// the same /api endpoints the widget uses so the whole surface updates in place.
+export function DashboardCockpit({
+ initial,
+}: {
+ initial: DashboardCockpitInitial;
+}) {
+ const now = useNow();
+
+ const { data: jobs } = usePolledPayload<ActiveJobsPayload>(
+ "/api/jobs/active",
+ true,
+ FAST_MS,
+ initial.jobs,
+ );
+ const { data: workers, refetch: refetchWorkers } =
+ usePolledPayload<WorkersPayload>(
+ "/api/workers",
+ true,
+ FAST_MS,
+ initial.workers,
+ );
+ const { data: actionable } = usePolledPayload<WidgetActionablePayload>(
+ "/api/widget/actionable",
+ true,
+ SLOW_MS,
+ initial.actionable,
+ );
+ const { data: sync, refetch: refetchSync } = usePolledPayload<WidgetSyncPayload>(
+ "/api/widget/sync",
+ true,
+ SLOW_MS,
+ initial.sync,
+ );
+
+ return (
+ <div className="flex flex-col gap-6">
+ <h1 className="sr-only">Dashboard</h1>
+ <PipelineBand
+ jobs={jobs}
+ workers={workers}
+ sync={sync}
+ now={now}
+ onWorkersChange={refetchWorkers}
+ onSynced={refetchSync}
+ />
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
+ <NeedsWorkPanel data={actionable} />
+ <QuickAddPanel changelogSource={initial.changelogSource} />
+ </div>
+ <ChannelsTable channels={initial.channels} now={now} />
+ </div>
+ );
+}
diff --git a/editor/app/components/dashboard/NeedsWorkPanel.tsx b/editor/app/components/dashboard/NeedsWorkPanel.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import Link from "next/link";
+import type { WidgetActionablePayload } from "../../api/widget/actionable/route";
+import { InlineActionButton } from "../../actionable/components/InlineActionButton";
+
+const LIMIT = 10;
+
+// Live "needs work" list: the channels with videos to download or transcribe,
+// echoing the ↓ (download) / ✎ (transcribe) verbs used across the app, each row
+// carrying the same per-channel Download/Transcribe actions as /actionable.
+export function NeedsWorkPanel({
+ data,
+}: {
+ data: WidgetActionablePayload | null;
+}) {
+ const channels = data?.channels ?? [];
+ const shown = channels.slice(0, LIMIT);
+ const overflow = channels.length - shown.length;
+
+ return (
+ <section
+ aria-label="Needs work"
+ className="flex flex-col gap-3 rounded-md border border-border bg-card px-4 py-3"
+ >
+ <div className="flex items-baseline justify-between gap-2">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ Needs work
+ </h2>
+ <Link
+ href="/actionable"
+ className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
+ >
+ {channels.length} {channels.length === 1 ? "channel" : "channels"}
+ </Link>
+ </div>
+ {channels.length === 0 ? (
+ <p
+ aria-label="needs work empty"
+ className="text-sm text-muted-foreground"
+ >
+ Everything's handled.
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-2">
+ {shown.map((c) => (
+ <li
+ key={c.slug}
+ aria-label={`needs work ${c.slug}`}
+ className="flex flex-col gap-1.5 rounded border border-border px-2 py-1.5"
+ >
+ <div className="flex items-center justify-between gap-2">
+ <Link
+ href={`/channels/${c.slug}`}
+ className="font-mono text-xs truncate underline underline-offset-2 hover:text-brand"
+ title={c.slug}
+ >
+ {c.slug}
+ </Link>
+ <span className="flex shrink-0 items-center gap-1">
+ {c.undownloaded > 0 && (
+ <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-warning-soft text-warning">
+ ↓ {c.undownloaded}
+ </span>
+ )}
+ {c.untranscribed > 0 && (
+ <span className="rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-info-soft text-info">
+ ✎ {c.untranscribed}
+ </span>
+ )}
+ </span>
+ </div>
+ <div className="flex flex-wrap items-center gap-1.5">
+ {c.undownloaded > 0 && (
+ <InlineActionButton
+ variant={{ kind: "downloadMissing", slug: c.slug }}
+ />
+ )}
+ {c.untranscribed > 0 && (
+ <InlineActionButton
+ variant={{ kind: "transcribeMissing", slug: c.slug }}
+ />
+ )}
+ </div>
+ </li>
+ ))}
+ {overflow > 0 && (
+ <li className="text-xs text-muted-foreground">
+ <Link
+ href="/actionable"
+ className="underline underline-offset-2 hover:text-foreground"
+ >
+ +{overflow} more {overflow === 1 ? "channel" : "channels"}
+ </Link>
+ </li>
+ )}
+ </ul>
+ )}
+ </section>
+ );
+}
diff --git a/editor/app/components/dashboard/PipelineBand.tsx b/editor/app/components/dashboard/PipelineBand.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs";
+import type { WorkersPayload } from "../../workers/components/WorkersView";
+import type { WidgetSyncPayload } from "../../api/widget/sync/route";
+import { ActiveJobsLive } from "../../jobs/components/ActiveJobsLive";
+import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
+import { PauseDownloadsButton } from "../../jobs/components/PauseDownloadsButton";
+import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions";
+import { fmtTime } from "../../widget/lib/relativeTime";
+
+// The hero: a single live instrument readout of the whole pipeline — running/
+// queued jobs, worker-pool occupancy + pause state, sync heartbeat + scheduler —
+// plus the global pause/sync/add controls, and the live job rows underneath.
+// Updates in place from the cockpit's polls (this is the one signature element).
+export function PipelineBand({
+ jobs,
+ workers,
+ sync,
+ now,
+ onWorkersChange,
+ onSynced,
+}: {
+ jobs: ActiveJobsPayload | null;
+ workers: WorkersPayload | null;
+ sync: WidgetSyncPayload | null;
+ now: number | null;
+ onWorkersChange: () => void | Promise<void>;
+ onSynced: () => void | Promise<void>;
+}) {
+ const jobList = jobs?.jobs ?? [];
+ const running = jobList.filter((j) => j.status === "running").length;
+ const queued = jobList.filter((j) => j.status === "queued").length;
+ const workerList = workers?.workers ?? [];
+ const busy = workerList.filter((w) => w.busy).length;
+ const paused = workers?.paused ?? false;
+ const downloadsPaused = workers?.downloadsPaused ?? false;
+
+ const lastSyncText =
+ sync == null
+ ? "…"
+ : sync.lastSyncAllAt === null && sync.lastIndividualSyncAt === null
+ ? "never"
+ : fmtTime(
+ Math.max(sync.lastSyncAllAt ?? 0, sync.lastIndividualSyncAt ?? 0),
+ now,
+ false,
+ ) ?? "…";
+ const schedulerOn = sync?.scheduler.enabled ?? false;
+
+ return (
+ <section
+ aria-label="Pipeline"
+ className="flex flex-col gap-3 rounded-md border border-border bg-card px-4 py-4"
+ >
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ Pipeline
+ </h2>
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm tabular-nums">
+ <Instrument
+ dotClass={running > 0 ? "bg-info animate-pulse motion-reduce:animate-none" : "bg-muted-foreground/40"}
+ label={
+ <>
+ <span className="font-medium">{running}</span> running
+ {queued > 0 && (
+ <>
+ {" · "}
+ <span className="font-medium">{queued}</span> queued
+ </>
+ )}
+ </>
+ }
+ />
+ <Instrument
+ dotClass={
+ paused
+ ? "bg-destructive"
+ : busy > 0
+ ? "bg-success"
+ : "bg-success/40"
+ }
+ label={
+ <>
+ workers{" "}
+ <span className="font-medium">
+ {busy}/{workerList.length}
+ </span>
+ {paused && (
+ <span className="ml-1 text-destructive">· paused</span>
+ )}
+ </>
+ }
+ />
+ <Instrument
+ dotClass={sync?.scheduler.overdue ? "bg-warning" : "bg-muted-foreground/40"}
+ label={
+ <>
+ ⟳ synced <span className="font-medium">{lastSyncText}</span>
+ </>
+ }
+ />
+ <Instrument
+ dotClass={schedulerOn ? "bg-success" : "bg-muted-foreground/40"}
+ label={<>sched {schedulerOn ? "on" : "off"}</>}
+ />
+ {downloadsPaused && (
+ <Instrument
+ dotClass="bg-destructive"
+ label={<span className="text-destructive">downloads paused</span>}
+ />
+ )}
+ </div>
+ </div>
+
+ <div className="flex flex-wrap items-center gap-2">
+ <PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} />
+ <PauseDownloadsButton
+ paused={downloadsPaused}
+ onChange={onWorkersChange}
+ />
+ <SyncAllButton onSynced={onSynced} />
+ <Link
+ href="/channels/new"
+ className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
+ >
+ + Add a channel
+ </Link>
+ </div>
+
+ {jobList.length > 0 && jobs ? (
+ <ActiveJobsLive initial={jobs} />
+ ) : (
+ <p
+ aria-label="pipeline idle"
+ className="text-sm text-muted-foreground border border-dashed border-border rounded p-3"
+ >
+ Pipeline idle — no active jobs.
+ </p>
+ )}
+ </section>
+ );
+}
+
+function Instrument({
+ dotClass,
+ label,
+}: {
+ dotClass: string;
+ label: React.ReactNode;
+}) {
+ return (
+ <span className="flex items-center gap-1.5 text-muted-foreground">
+ <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${dotClass}`} />
+ <span>{label}</span>
+ </span>
+ );
+}
+
+// Sweep every channel now, surfacing the queued/skipped counts briefly. Mirrors
+// the widget's Sync-all control; refetches the sync readouts on completion.
+function SyncAllButton({
+ onSynced,
+}: {
+ onSynced: () => void | Promise<void>;
+}) {
+ const [running, setRunning] = useState(false);
+ const [result, setResult] = useState<SyncAllResult | null>(null);
+ const [error, setError] = useState<string | null>(null);
+
+ async function handleClick() {
+ setError(null);
+ setResult(null);
+ setRunning(true);
+ try {
+ setResult(await syncAllChannelsAction());
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setRunning(false);
+ await onSynced();
+ }
+ }
+
+ return (
+ <span className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={running}
+ aria-label="sync all channels"
+ className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
+ >
+ {running ? "Syncing…" : "Sync all"}
+ </button>
+ {result && (
+ <span
+ aria-label="sync all result"
+ className="text-xs text-muted-foreground tabular-nums"
+ title={
+ result.skipped.length === 0
+ ? undefined
+ : result.skipped.map((s) => `${s.slug}: ${s.reason}`).join("\n")
+ }
+ >
+ Queued {result.queued.length} · skipped {result.skipped.length}
+ </span>
+ )}
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}
+ </span>
+ );
+}
diff --git a/editor/app/components/dashboard/QuickAddPanel.tsx b/editor/app/components/dashboard/QuickAddPanel.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import Link from "next/link";
+import { Changelog } from "yt-dlp-transcript-common/components/Changelog";
+
+// The quiet right-hand column: a prominent entry into the URL-first onboarding
+// flow, and the recent-changes card beneath it (reusing the shared Changelog).
+export function QuickAddPanel({
+ changelogSource,
+}: {
+ changelogSource: string | null;
+}) {
+ return (
+ <section
+ aria-label="Quick add and changes"
+ className="flex flex-col gap-3"
+ >
+ <Link
+ href="/channels/new"
+ aria-label="add a channel"
+ className="block rounded-md border border-brand/40 bg-brand/5 px-4 py-3 hover:border-brand/60 transition-colors"
+ >
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-brand mb-1">
+ Add a channel
+ </h2>
+ <p className="text-sm text-foreground">
+ Paste a channel or playlist URL — platform, handling and slug fill in
+ automatically, and it can fetch the playlist on create.
+ </p>
+ </Link>
+
+ {changelogSource && (
+ // A plain <div>, not a Link: the Changelog renders its own anchors and
+ // CopyLink buttons, and wrapping interactive content in an outer <a> is
+ // invalid nesting that the browser reparents — an SSR hydration
+ // mismatch now that this panel is a client component. The "View all"
+ // affordance is a sibling link in the header instead.
+ <div className="rounded-md border border-border bg-card px-4 py-3">
+ <div className="flex items-baseline justify-between gap-2 mb-1">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ Recent changes
+ </h2>
+ <Link
+ href="/changelog"
+ aria-label="View full changelog"
+ className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
+ >
+ View all
+ </Link>
+ </div>
+ <div className="relative max-h-48 overflow-hidden">
+ <Changelog source={changelogSource} limit={1} />
+ <div
+ aria-hidden="true"
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-card to-transparent"
+ />
+ </div>
+ </div>
+ )}
+ </section>
+ );
+}
diff --git a/editor/app/components/dashboard/types.ts b/editor/app/components/dashboard/types.ts
@@ -0,0 +1,14 @@
+// Enriched per-channel row for the dashboard cockpit's Channels table. Built
+// server-side in app/page.tsx from the actionable summary and passed straight
+// through (not into client state), so a global AutoRefresh re-seeds it live.
+export type DashboardChannel = {
+ slug: string;
+ handling: string;
+ videoCount: number;
+ // Epoch ms of the channel's last sync, or null when it has never synced.
+ lastSyncedAt: number | null;
+ // Whether the channel has a URL — inline Sync / Download need one.
+ hasUrl: boolean;
+ undownloaded: number;
+ untranscribed: number;
+};
diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings";
import { pruneJobLogs } from "yt-dlp-transcript-common/jobs/listJobs";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { readJobMeta } from "yt-dlp-transcript-common/jobs/jobMeta";
@@ -137,6 +138,37 @@ export async function retryAllFailedAction(): Promise<{ count: number }> {
return { count };
}
+// Global, restart-surviving downloads pause. Toggling `downloadsPaused` in
+// settings.json gates the auto-download runner (which re-reads getSettings each
+// loop iteration, so it stops dispatching on the next tick) and every manual
+// download-bearing pipeline action (which returns a "Downloads are paused"
+// result). Enumeration/store-playlist stay allowed — they write no media.
+export type DownloadsPauseResult = { ok: boolean; error?: string };
+
+async function setDownloadsPaused(
+ paused: boolean,
+): Promise<DownloadsPauseResult> {
+ try {
+ const current = getSettings();
+ if (current.downloadsPaused !== paused) {
+ await writeSettings({ ...current, downloadsPaused: paused });
+ }
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidatePath("/");
+ revalidatePath("/jobs");
+ return { ok: true };
+}
+
+export async function pauseDownloadsAction(): Promise<DownloadsPauseResult> {
+ return setDownloadsPaused(true);
+}
+
+export async function resumeDownloadsAction(): Promise<DownloadsPauseResult> {
+ return setDownloadsPaused(false);
+}
+
// Retention scopes offered by the ClearLogsMenu. "all" clears every finished
// job's log; the day-scopes clear anything older than that. Running/queued jobs
// are never deleted (see pruneJobLogs).
diff --git a/editor/app/jobs/components/PauseDownloadsButton.tsx b/editor/app/jobs/components/PauseDownloadsButton.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import {
+ pauseDownloadsAction,
+ resumeDownloadsAction,
+} from "../actions";
+
+// Global pause/resume for downloads, persisted in settings.json (downloadsPaused
+// — survives a restart). Pausing gates the auto-download runner on its next loop
+// iteration and makes manual download-bearing pipeline actions return a
+// "Downloads are paused" notice; store-playlist/enumeration stay allowed. Mirrors
+// PauseTranscriptionsButton so the dashboard cockpit and the monitor widget can
+// present the two pauses side by side.
+export function PauseDownloadsButton({
+ paused,
+ disabled,
+ onChange,
+}: {
+ paused: boolean;
+ disabled?: boolean;
+ onChange?: () => void | Promise<void>;
+}) {
+ const [pending, startTransition] = useTransition();
+ const router = useRouter();
+
+ function toggle() {
+ startTransition(async () => {
+ await (paused ? resumeDownloadsAction() : pauseDownloadsAction());
+ if (onChange) await onChange();
+ else router.refresh();
+ });
+ }
+
+ return paused ? (
+ <button
+ type="button"
+ disabled={pending}
+ onClick={toggle}
+ className="px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
+ >
+ Resume Downloads
+ </button>
+ ) : (
+ <button
+ type="button"
+ disabled={pending || disabled}
+ onClick={toggle}
+ title="Stop new downloads: the auto-download runner idles and manual sync/download actions are blocked until resumed. Enumeration (store-playlist) still runs."
+ className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
+ >
+ Pause Downloads
+ </button>
+ );
+}
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -1,21 +1,25 @@
import { readFileSync } from "node:fs";
-import { stat } from "node:fs/promises";
import path from "node:path";
import type { Metadata } from "next";
-import Link from "next/link";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
-import { Changelog } from "yt-dlp-transcript-common/components/Changelog";
import {
getSite,
listSiteIds,
siteChannelSlugs,
} from "yt-dlp-transcript-common/lib/site";
-import { loadActionableSummary } from "./actionable/lib/loadActionable";
+import {
+ actionableUndownloadedCount,
+ actionableUntranscribedCount,
+ loadActionableSummary,
+} from "./actionable/lib/loadActionable";
import { resolveActiveSite } from "./lib/activeSite";
import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs";
-import { ActiveJobsLive } from "./jobs/components/ActiveJobsLive";
+import { buildWorkersPayload } from "./workers/buildWorkers";
+import { buildWidgetSyncPayload } from "./api/widget/sync/route";
+import { DashboardCockpit } from "./components/dashboard/DashboardCockpit";
+import type { DashboardChannel } from "./components/dashboard/types";
+import type { WidgetActionablePayload } from "./api/widget/actionable/route";
import type { ActionableRow } from "./actionable/lib/loadActionable";
export const dynamic = "force-dynamic";
@@ -28,28 +32,18 @@ export async function generateMetadata(): Promise<Metadata> {
return { title: `Dashboard — ${settings.adminTitle}` };
}
-async function getLastBuildTime(paths: ReturnType<typeof getPaths>) {
+function loadChangelog(): string | null {
try {
- const manifestPath = path.join(
- paths.exportSummariesDir,
- "manifest.json",
- );
- const s = await stat(manifestPath);
- return s.mtime.toISOString();
+ return readFileSync(path.join(process.cwd(), "CHANGELOG.md"), "utf8");
} catch {
return null;
}
}
-function loadChangelog(): string | null {
- try {
- return readFileSync(
- path.join(process.cwd(), "CHANGELOG.md"),
- "utf8",
- );
- } catch {
- return null;
- }
+function toMs(iso: string | undefined): number | null {
+ if (!iso) return null;
+ const n = Date.parse(iso);
+ return Number.isNaN(n) ? null : n;
}
export default async function Dashboard({
@@ -62,182 +56,60 @@ export default async function Dashboard({
const active = resolveActiveSite(site, listSiteIds(paths));
const summary = await loadActionableSummary(paths);
- // Scope the channel-derived stats to the active site's membership. Under "all
- // sites" the full pool is shown. Job stats stay global (pool state).
+ // Scope the channel-derived data to the active site's membership. Under "all
+ // sites" the full pool is shown. Job/worker state stays global (pool state).
const inScope = (() => {
if (active.isAll || !active.siteId) return () => true;
const slugs = siteChannelSlugs(getSite(active.siteId, paths));
return (r: ActionableRow) => slugs.has(r.channel.slug);
})();
const rows = summary.rows.filter(inScope);
- const undownloaded = summary.undownloaded.filter(inScope);
- const untranscribed = summary.untranscribed.filter(inScope);
- const channels = rows.map((r) => r.channel);
- const totalVideos = channels.reduce((sum, c) => sum + c.videoCount, 0);
- const lastBuild = await getLastBuildTime(paths);
- const runningJobs = getRegistry()
- .list()
- .filter((j) => j.status === "running" || j.status === "queued");
- const activeJobsPayload = await buildActiveJobsPayload();
- const changelogSource = loadChangelog();
- const needsAttention =
- undownloaded.length > 0 || untranscribed.length > 0;
-
- return (
- <div className="flex flex-col gap-6">
- <h1 className="font-display text-2xl font-semibold tracking-tight">
- Dashboard
- </h1>
- <dl className="grid grid-cols-1 sm:grid-cols-4 gap-4">
- <Stat label="channels" value={String(channels.length)} />
- <Stat label="videos" value={totalVideos.toLocaleString()} />
- <StatLink
- label="running jobs"
- value={String(runningJobs.length)}
- href="/jobs"
- />
- <Stat
- label="last build"
- value={lastBuild ? new Date(lastBuild).toLocaleString() : "never"}
- />
- </dl>
- {runningJobs.length > 0 && (
- <section aria-label="Live jobs" className="flex flex-col gap-3">
- <h2 className="font-display text-lg font-semibold">Live jobs</h2>
- <ActiveJobsLive initial={activeJobsPayload} />
- </section>
- )}
+ // Enriched channels table rows (pass-through, not client state, so a global
+ // AutoRefresh re-seeds them live).
+ const channels: DashboardChannel[] = rows.map((r) => ({
+ slug: r.channel.slug,
+ handling: r.channel.config.handling,
+ videoCount: r.channel.videoCount,
+ lastSyncedAt: toMs(r.channel.config.lastSyncedAt),
+ hasUrl: Boolean(r.channel.config.url),
+ undownloaded: actionableUndownloadedCount(r),
+ untranscribed: actionableUntranscribedCount(r),
+ }));
- {needsAttention && (
- <Link
- href="/actionable"
- aria-label="needs attention"
- className="block rounded-md border border-warning/40 bg-warning-soft px-4 py-3 hover:border-warning/60 transition-colors"
- >
- <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-warning mb-1">
- Needs attention
- </h2>
- <p className="text-sm">
- {undownloaded.length}{" "}
- {undownloaded.length === 1 ? "channel has" : "channels have"}{" "}
- undownloaded videos
- {untranscribed.length > 0 ? (
- <>
- {" · "}
- {untranscribed.length}{" "}
- {untranscribed.length === 1 ? "has videos" : "have videos"}{" "}
- awaiting transcription
- </>
- ) : null}
- .
- </p>
- </Link>
- )}
+ // "Needs work" payload, same shape the widget endpoint the cockpit polls
+ // returns, scoped to the active site for the SSR seed.
+ const actionable: WidgetActionablePayload = {
+ channels: channels
+ .filter((c) => c.undownloaded > 0 || c.untranscribed > 0)
+ .map((c) => ({
+ slug: c.slug,
+ undownloaded: c.undownloaded,
+ untranscribed: c.untranscribed,
+ }))
+ .sort(
+ (a, b) =>
+ b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed),
+ ),
+ };
- {changelogSource && (
- <Link
- href="/changelog"
- aria-label="View full changelog"
- className="block rounded-md border border-border bg-card px-4 py-3 hover:border-brand/40 transition-colors"
- >
- <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">
- Recent changes
- </h2>
- <div className="relative max-h-48 overflow-hidden">
- <Changelog source={changelogSource} limit={1} />
- <div
- aria-hidden="true"
- className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-card to-transparent"
- />
- </div>
- </Link>
- )}
-
- {channels.length > 0 && (
- <section className="flex flex-col gap-3">
- <h2 className="font-display text-lg font-semibold">Channels</h2>
- <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
- <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full">
- <thead className="bg-muted">
- <tr>
- <th className="text-left font-medium px-3 py-2">Slug</th>
- <th className="text-left font-medium px-3 py-2">Handling</th>
- <th className="text-right font-medium px-3 py-2">Videos</th>
- <th className="text-left font-medium px-3 py-2 whitespace-nowrap">
- Last sync
- </th>
- </tr>
- </thead>
- <tbody>
- {channels.map((c) => (
- <tr key={c.slug} className="border-t border-border">
- <td className="px-3 py-2 font-mono">
- <Link
- href={`/channels/${c.slug}`}
- className="underline underline-offset-2 hover:text-brand transition-colors"
- >
- {c.slug}
- </Link>
- </td>
- <td className="px-3 py-2">{c.config.handling}</td>
- <td className="px-3 py-2 text-right tabular-nums">
- {c.videoCount}
- </td>
- <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">
- {c.config.lastSyncedAt
- ? new Date(c.config.lastSyncedAt).toLocaleString()
- : "never"}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </section>
- )}
- </div>
- );
-}
-
-function Stat({ label, value }: { label: string; value: string }) {
- return (
- <div
- role="group"
- aria-label={label}
- className="rounded-md border border-border bg-card px-4 py-3"
- >
- <div className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- {label}
- </div>
- <div className="font-display text-2xl font-semibold mt-1 tabular-nums">
- {value}
- </div>
- </div>
- );
-}
+ const [jobs, sync] = await Promise.all([
+ buildActiveJobsPayload(),
+ buildWidgetSyncPayload(),
+ ]);
+ const workers = buildWorkersPayload();
+ const changelogSource = loadChangelog();
-function StatLink({
- label,
- value,
- href,
-}: {
- label: string;
- value: string;
- href: string;
-}) {
return (
- <Link
- href={href}
- aria-label={label}
- className="rounded-md border border-border bg-card px-4 py-3 hover:border-brand/40 transition-colors"
- >
- <div className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- {label}
- </div>
- <div className="font-display text-2xl font-semibold mt-1 tabular-nums">
- {value}
- </div>
- </Link>
+ <DashboardCockpit
+ initial={{
+ jobs,
+ workers,
+ actionable,
+ sync,
+ channels,
+ changelogSource,
+ }}
+ />
);
}
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -228,6 +228,10 @@ export async function saveSettingsAction(
parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT,
inlineTranscribeOnFallback,
skipLiveDownloads,
+ // Pause flags aren't edited by this form (the dashboard/widget/Workers
+ // controls own them). Preserve the current values on an unrelated save.
+ transcriptionsPaused: getSettings().transcriptionsPaused,
+ downloadsPaused: getSettings().downloadsPaused,
buildArchives,
archiveStorage,
reportDebouncePreset,
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -1,7 +1,9 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useState } from "react";
import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { usePolledPayload, useNow } from "../lib/usePolledPayload";
+import { fmtTime } from "../lib/relativeTime";
import type {
ActiveJobsPayload,
DiskStatusView,
@@ -20,64 +22,6 @@ import { WidgetControls } from "./WidgetControls";
// ActiveJobsLive / WorkersView against the same /api endpoints, but renders a
// compact, control-free view meant to fit in a small pinned window or iframe.
-// Live wall-clock that re-renders once a second; null until mounted so SSR and
-// the first client render agree (no Date.now() hydration mismatch).
-function useNow(): number | null {
- const [now, setNow] = useState<number | null>(null);
- useEffect(() => {
- setNow(Date.now());
- const id = setInterval(() => setNow(Date.now()), 1000);
- return () => clearInterval(id);
- }, []);
- return now;
-}
-
-// Generic poller: fetches `url` every `pollMs` while enabled, swallowing
-// transient errors. Disabled (enabled=false) leaves the initial value as-is.
-// Returns the latest payload plus a `refetch` so a control action can refresh
-// it immediately instead of waiting for the next poll tick.
-function usePolledPayload<T>(
- url: string,
- enabled: boolean,
- pollMs: number,
- initial: T | null,
-): { data: T | null; refetch: () => Promise<void> } {
- const [data, setData] = useState<T | null>(initial);
- const refetch = useCallback(async () => {
- try {
- const res = await fetch(url, { cache: "no-store" });
- if (res.ok) setData((await res.json()) as T);
- } catch {
- // transient — ignore
- }
- }, [url]);
- useEffect(() => {
- if (!enabled) return;
- let cancelled = false;
- let timer: ReturnType<typeof setTimeout> | null = null;
- async function tick() {
- try {
- const res = await fetch(url, { cache: "no-store" });
- if (res.ok && !cancelled) setData((await res.json()) as T);
- } catch {
- // transient — keep polling
- } finally {
- if (!cancelled) timer = setTimeout(tick, pollMs);
- }
- }
- // Fetch immediately on (re)subscribe, then poll on the interval. Sections
- // seeded from the server (jobs/workers) get a harmless refresh; those with a
- // null initial (cleanable/actionable) render on first tick instead of after
- // a full interval — matters most for the actionable strip's 15s cadence.
- void tick();
- return () => {
- cancelled = true;
- if (timer) clearTimeout(timer);
- };
- }, [url, enabled, pollMs]);
- return { data, refetch };
-}
-
export function MonitorWidget({
config: initialConfig,
initialJobs,
@@ -207,6 +151,7 @@ export function MonitorWidget({
channel={config.channel}
confirmSyncAll={config.syncConfirm}
paused={workersPayload?.paused ?? false}
+ downloadsPaused={workersPayload?.downloadsPaused ?? false}
onWorkersChange={refetchWorkers}
onSynced={refetchSync}
/>
@@ -357,36 +302,6 @@ function DiskStrip({ disk }: { disk: DiskStatusView }) {
);
}
-// Compact relative label. `deltaMs = now - target`: positive is past ("5m
-// ago"), negative is future ("in 5m"). Steps s → m → h → d.
-function relativeTime(deltaMs: number): string {
- const past = deltaMs >= 0;
- const s = Math.round(Math.abs(deltaMs) / 1000);
- const val =
- s < 60
- ? `${s}s`
- : s < 3600
- ? `${Math.round(s / 60)}m`
- : s < 86400
- ? `${Math.round(s / 3600)}h`
- : `${Math.round(s / 86400)}d`;
- return past ? `${val} ago` : `in ${val}`;
-}
-
-// Format an epoch-ms marker per the widget's abstime flag. Absolute is
-// now-independent (locale string); relative depends on the live clock, so it
-// returns null until `now` is set (post-mount) to avoid an SSR/hydration
-// mismatch and a nonsensical "0s ago" flash. Callers render "…" for null.
-function fmtTime(
- ms: number,
- now: number | null,
- absolute: boolean,
-): string | null {
- if (absolute) return new Date(ms).toLocaleString();
- if (now === null) return null;
- return relativeTime(now - ms);
-}
-
// Last-sync freshness readout: the last full "Sync all" sweep, plus the newest
// individual channel sync when it is more recent than that sweep (a per-channel
// Sync since the last global one).
diff --git a/editor/app/widget/components/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
+import { PauseDownloadsButton } from "../../jobs/components/PauseDownloadsButton";
import { DrainAllButton } from "../../jobs/components/DrainAllButton";
import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton";
import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions";
@@ -24,6 +25,7 @@ export function WidgetControls({
channel,
confirmSyncAll,
paused,
+ downloadsPaused,
onWorkersChange,
onSynced,
}: {
@@ -32,6 +34,7 @@ export function WidgetControls({
channel?: string;
confirmSyncAll: boolean;
paused: boolean;
+ downloadsPaused: boolean;
onWorkersChange: () => void | Promise<void>;
onSynced: () => void | Promise<void>;
}) {
@@ -43,6 +46,10 @@ export function WidgetControls({
{controls && (
<>
<PauseTranscriptionsButton paused={paused} onChange={onWorkersChange} />
+ <PauseDownloadsButton
+ paused={downloadsPaused}
+ onChange={onWorkersChange}
+ />
<DrainAllButton />
<RetryAllFailedButton />
</>
diff --git a/editor/app/widget/lib/relativeTime.ts b/editor/app/widget/lib/relativeTime.ts
@@ -0,0 +1,32 @@
+// Relative-time formatting shared by the monitor widget and the dashboard
+// cockpit, so their "5m ago" labels never drift.
+
+// Compact relative label. `deltaMs = now - target`: positive is past ("5m
+// ago"), negative is future ("in 5m"). Steps s → m → h → d.
+export function relativeTime(deltaMs: number): string {
+ const past = deltaMs >= 0;
+ const s = Math.round(Math.abs(deltaMs) / 1000);
+ const val =
+ s < 60
+ ? `${s}s`
+ : s < 3600
+ ? `${Math.round(s / 60)}m`
+ : s < 86400
+ ? `${Math.round(s / 3600)}h`
+ : `${Math.round(s / 86400)}d`;
+ return past ? `${val} ago` : `in ${val}`;
+}
+
+// Format an epoch-ms marker per an absolute/relative flag. Absolute is
+// now-independent (locale string); relative depends on the live clock, so it
+// returns null until `now` is set (post-mount) to avoid an SSR/hydration
+// mismatch and a nonsensical "0s ago" flash. Callers render "…" for null.
+export function fmtTime(
+ ms: number,
+ now: number | null,
+ absolute: boolean,
+): string | null {
+ if (absolute) return new Date(ms).toLocaleString();
+ if (now === null) return null;
+ return relativeTime(now - ms);
+}
diff --git a/editor/app/widget/lib/usePolledPayload.ts b/editor/app/widget/lib/usePolledPayload.ts
@@ -0,0 +1,64 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+// Shared client polling primitives, extracted from the monitor widget so the
+// dashboard cockpit (editor/app/components/dashboard) can reuse the exact same
+// ~1s poll pattern the widget proved out against the /api endpoints.
+
+// Live wall-clock that re-renders once a second; null until mounted so SSR and
+// the first client render agree (no Date.now() hydration mismatch).
+export function useNow(): number | null {
+ const [now, setNow] = useState<number | null>(null);
+ useEffect(() => {
+ setNow(Date.now());
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, []);
+ return now;
+}
+
+// Generic poller: fetches `url` every `pollMs` while enabled, swallowing
+// transient errors. Disabled (enabled=false) leaves the initial value as-is.
+// Returns the latest payload plus a `refetch` so a control action can refresh
+// it immediately instead of waiting for the next poll tick.
+export function usePolledPayload<T>(
+ url: string,
+ enabled: boolean,
+ pollMs: number,
+ initial: T | null,
+): { data: T | null; refetch: () => Promise<void> } {
+ const [data, setData] = useState<T | null>(initial);
+ const refetch = useCallback(async () => {
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (res.ok) setData((await res.json()) as T);
+ } catch {
+ // transient — ignore
+ }
+ }, [url]);
+ useEffect(() => {
+ if (!enabled) return;
+ let cancelled = false;
+ let timer: ReturnType<typeof setTimeout> | null = null;
+ async function tick() {
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (res.ok && !cancelled) setData((await res.json()) as T);
+ } catch {
+ // transient — keep polling
+ } finally {
+ if (!cancelled) timer = setTimeout(tick, pollMs);
+ }
+ }
+ // Fetch immediately on (re)subscribe, then poll on the interval. Sections
+ // seeded from the server get a harmless refresh; those with a null initial
+ // render on the first tick instead of after a full interval.
+ void tick();
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [url, enabled, pollMs]);
+ return { data, refetch };
+}
diff --git a/editor/app/workers/actions.ts b/editor/app/workers/actions.ts
@@ -4,6 +4,23 @@ import { revalidatePath } from "next/cache";
import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { writeWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults";
+import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings";
+
+// Persist the transcription-pause flag to settings.json so a pause survives a
+// server restart (the pool is re-paused at boot in editor/instrumentation.ts).
+// Best-effort: the live pause already took effect, so a failed write only means
+// the pause won't survive a restart — surface it in the log rather than throw.
+async function persistTranscriptionsPaused(paused: boolean): Promise<void> {
+ try {
+ const current = getSettings();
+ if (current.transcriptionsPaused === paused) return;
+ await writeSettings({ ...current, transcriptionsPaused: paused });
+ } catch (e) {
+ console.warn(
+ `Failed to persist transcriptionsPaused=${paused}: ${(e as Error).message}`,
+ );
+ }
+}
// Runtime worker controls for the Workers page. Most of these are transient
// operator overrides on the live pool — they are NOT written to settings.json
@@ -44,14 +61,18 @@ export async function drainWorkerAction(id: string): Promise<WorkerActionResult>
// state. Running batches pause (they wait for a worker) rather than failing.
export async function pauseAllWorkersAction(): Promise<WorkerActionResult> {
getWorkerPool().pauseAll();
+ await persistTranscriptionsPaused(true);
refresh();
+ revalidatePath("/");
return { ok: true };
}
// Resume: restore every worker to the state it had when pauseAll ran.
export async function resumeAllWorkersAction(): Promise<WorkerActionResult> {
getWorkerPool().resumeAll();
+ await persistTranscriptionsPaused(false);
refresh();
+ revalidatePath("/");
return { ok: true };
}
diff --git a/editor/app/workers/buildWorkers.ts b/editor/app/workers/buildWorkers.ts
@@ -2,6 +2,7 @@ import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { readWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import type { WorkersPayload, WorkerTask } from "./components/WorkersView";
// Builds the Workers screen payload: the pool's per-worker slot/state summary
@@ -45,5 +46,12 @@ export function buildWorkersPayload(): WorkersPayload {
const defaultEnabledIds =
readWorkerDefaults(getPaths())?.enabledWorkerIds ?? null;
- return { paused: pool.isPaused(), workers, defaultEnabledIds };
+ return {
+ paused: pool.isPaused(),
+ // Persisted global downloads pause (settings.json). Surfaced here so the
+ // dashboard/widget controls can read both pause states from one poll.
+ downloadsPaused: getSettings().downloadsPaused,
+ workers,
+ defaultEnabledIds,
+ };
}
diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx
@@ -51,6 +51,10 @@ export type WorkerView = {
export type WorkersPayload = {
paused: boolean;
+ // Persisted global downloads pause (settings.json downloadsPaused). Separate
+ // from `paused` (live transcription-worker pause). Surfaced on the same poll
+ // so the dashboard/widget controls reflect both without a second request.
+ downloadsPaused: boolean;
workers: WorkerView[];
// Worker ids saved as the launch default ("Set as default"), or null when no
// default has been saved yet. Drives the button label and per-worker marker.
diff --git a/editor/e2e/dashboard.spec.ts b/editor/e2e/dashboard.spec.ts
@@ -1,20 +1,110 @@
+// The dashboard mission-control cockpit (app/page.tsx → DashboardCockpit). SSRs
+// initial payloads then polls the same /api endpoints the monitor widget uses:
+// a live Pipeline band (jobs/workers/sync + global pause/sync controls) over a
+// Needs-work / Quick-add split and an enriched channels table. Also covers the
+// persisted downloads pause blocking a manual sync.
+
import { test, expect } from "@playwright/test";
-import { resetData } from "./helpers";
+import { resetData, writeSettings, readJson } from "./helpers";
-test.beforeEach(async () => {
- await resetData();
+const ONE_WORKER = {
+ workers: [
+ {
+ id: "gpu",
+ name: "GPU",
+ kind: "local",
+ enabled: true,
+ priority: 0,
+ appId: "whisper-cpp",
+ config: {},
+ },
+ ],
+};
+
+test("empty state: pipeline idle, nothing to do, no channels table", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await writeSettings(ONE_WORKER);
+ await page.goto("/");
+
+ await expect(page.getByRole("region", { name: "Pipeline" })).toBeVisible();
+ await expect(page.getByLabel("pipeline idle")).toBeVisible();
+ await expect(page.getByLabel("needs work empty")).toBeVisible();
+ // No channels → the Channels table is omitted entirely.
+ await expect(page.getByRole("columnheader", { name: "Slug" })).toHaveCount(0);
+ // The add-a-channel entry point is always present.
+ await expect(
+ page.getByRole("link", { name: "+ Add a channel" }),
+ ).toHaveAttribute("href", "/channels/new");
});
-test("shows zero channels in an empty fixture", async ({ page }) => {
+test("renders the pipeline band, needs-work panel and enriched channels table", async ({
+ page,
+}) => {
+ await resetData("youtube-with-playlist");
+ await writeSettings(ONE_WORKER);
+ // Visiting the channel detail page auto-generates the snapshot that feeds the
+ // undownloaded counts.
+ await page.goto("/channels/test-youtube");
await page.goto("/");
+
+ const pipeline = page.getByRole("region", { name: "Pipeline" });
+ await expect(pipeline).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Pause Transcriptions" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Pause Downloads" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "sync all channels" }),
+ ).toBeVisible();
+
+ // Needs-work panel lists the channel with pending downloads.
+ await expect(page.getByLabel("needs work test-youtube")).toBeVisible();
+
+ // Enriched channels table: the row plus its inline actions.
+ const row = page.getByRole("row", { name: "channel row test-youtube" });
+ await expect(row).toBeVisible();
await expect(
- page.getByRole("heading", { level: 1, name: /dashboard/i }),
+ row.getByRole("button", { name: "sync test-youtube" }),
).toBeVisible();
- await expect(page.getByRole("group", { name: "channels" })).toContainText("0");
- await expect(page.getByRole("group", { name: "videos" })).toContainText("0");
+ await expect(
+ row.getByRole("button", { name: "prioritize test-youtube for download" }),
+ ).toBeVisible();
+});
+
+test("pausing downloads persists and blocks a manual sync", async ({ page }) => {
+ await resetData("youtube-with-playlist");
+ await writeSettings(ONE_WORKER);
+ await page.goto("/channels/test-youtube");
+ await page.goto("/");
+
+ await page.getByRole("button", { name: "Pause Downloads" }).click();
+ // The toggle flips (the workers poll re-reads the persisted flag).
+ await expect(
+ page.getByRole("button", { name: "Resume Downloads" }),
+ ).toBeVisible();
+
+ // The flag is written to settings.json (survives a restart).
+ await expect
+ .poll(async () => {
+ const s = await readJson<{ downloadsPaused?: boolean }>(
+ "test-settings.json",
+ );
+ return s.downloadsPaused;
+ })
+ .toBe(true);
+
+ // A manual per-channel Sync is now refused with the paused notice.
+ const row = page.getByRole("row", { name: "channel row test-youtube" });
+ await row.getByRole("button", { name: "sync test-youtube" }).click();
+ await expect(row.getByRole("alert")).toContainText("Downloads are paused");
});
test("sidebar links to Channels, Build, Jobs, Settings", async ({ page }) => {
+ await resetData();
await page.goto("/");
const sidebar = page.locator("aside");
await expect(sidebar.getByRole("link", { name: /channels/i })).toHaveAttribute(
@@ -35,10 +125,13 @@ test("sidebar links to Channels, Build, Jobs, Settings", async ({ page }) => {
});
test("settings page lists getPaths() values", async ({ page }) => {
+ await resetData();
await page.goto("/settings");
await page.locator("summary").filter({ hasText: "System paths" }).click();
// Match the table-row headers, not stray <code> mentions in nearby blurbs.
- await expect(page.getByRole("rowheader", { name: "transcriptsDir" })).toBeVisible();
+ await expect(
+ page.getByRole("rowheader", { name: "transcriptsDir" }),
+ ).toBeVisible();
await expect(page.getByRole("rowheader", { name: "ytdlpBin" })).toBeVisible();
await expect(page.getByRole("rowheader", { name: "whisperBin" })).toBeVisible();
});
diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs
@@ -495,6 +495,21 @@ async function main() {
return;
}
+ // Metadata probe for the URL-first new-channel flow:
+ // --flat-playlist --skip-download --playlist-items 1 --print <template> <URL>
+ // The print template is anything other than "url" (a name field-fallback).
+ // Emit a deterministic channel name so probeChannelMeta fills the form; a
+ // `probefail` sentinel in the URL simulates an unfetchable URL.
+ if (has("--flat-playlist") && has("--print") && arg("--print") !== "url") {
+ const url = lastNonFlag() ?? "";
+ if (url.toLowerCase().includes("probefail")) {
+ process.stderr.write(`ERROR: [generic] ${url}: Unable to extract\n`);
+ process.exit(1);
+ }
+ process.stdout.write("Fake Probe Channel\n");
+ return;
+ }
+
// Availability probe: --dump-json --skip-download -- <url>
// Sentinels in the URL select the response so a fixture can deterministically
// produce each availability bucket.
diff --git a/editor/e2e/new-channel-onboarding.spec.ts b/editor/e2e/new-channel-onboarding.spec.ts
@@ -0,0 +1,141 @@
+// URL-first new-channel onboarding (editor/app/channels/components/ChannelForm).
+// Pasting a URL derives platform / handling / slug and the download queue OFFLINE
+// (no network); an opt-in "Fetch details" runs a one-entry yt-dlp probe that
+// fills the name and can register a host unknown to the app. On create it stores
+// the playlist and can jump the channel to the top of the auto-download queue.
+//
+// The fake yt-dlp (e2e/fixtures/bin/fake-ytdlp.mjs) answers the probe with the
+// deterministic name "Fake Probe Channel".
+
+import { test, expect } from "@playwright/test";
+import { resetData, readJson, pathExists } from "./helpers";
+
+test("derives platform, handling, slug and queue from a YouTube URL offline", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await page.goto("/channels/new");
+
+ await page
+ .getByLabel("URL")
+ .fill("https://www.youtube.com/@Veritasium/videos");
+
+ await expect(page.getByLabel("platform")).toHaveValue("youtube");
+ await expect(
+ page.locator('input[name="handling"][value="youtube"]'),
+ ).toBeChecked();
+ await expect(page.locator('input[name="slug"]')).toHaveValue("veritasium");
+ const queue = page.getByLabel("derived queue");
+ await expect(queue).toContainText("platform:youtube");
+ await expect(queue).not.toContainText("(new)");
+});
+
+test("an unknown host is offered its own per-domain queue, marked (new)", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await page.goto("/channels/new");
+
+ await page.getByLabel("URL").fill("https://vimeo.com/someuser");
+
+ // detectPlatform doesn't recognize vimeo → platform stays Auto, handling
+ // falls to transcribe, and the queue is the per-domain fallback marked new.
+ await expect(page.getByLabel("platform")).toHaveValue("");
+ await expect(
+ page.locator('input[name="handling"][value="transcribe"]'),
+ ).toBeChecked();
+ const queue = page.getByLabel("derived queue");
+ await expect(queue).toContainText("platform:vimeo.com");
+ await expect(queue).toContainText("(new)");
+});
+
+test("Fetch details fills the name from the yt-dlp probe", async ({ page }) => {
+ await resetData("empty");
+ await page.goto("/channels/new");
+
+ await page
+ .getByLabel("URL")
+ .fill("https://www.youtube.com/@testchan/videos");
+ await page.getByRole("button", { name: "fetch details" }).click();
+
+ await expect(page.locator('input[name="name"]')).toHaveValue(
+ "Fake Probe Channel",
+ );
+ await expect(page.getByLabel("probe result")).toContainText(
+ "Fake Probe Channel",
+ );
+});
+
+test("creating with 'Fetch playlist now' stores the playlist", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await page.goto("/channels/new");
+
+ await page
+ .getByLabel("URL")
+ .fill("https://www.youtube.com/@testchan/videos");
+ await page.getByRole("button", { name: "fetch details" }).click();
+ await expect(page.locator('input[name="name"]')).toHaveValue(
+ "Fake Probe Channel",
+ );
+ // Pin the slug so the assertion path is deterministic.
+ await page.locator('input[name="slug"]').fill("playlist-chan");
+
+ // "Fetch playlist now" is checked by default.
+ await expect(page.locator('input[name="fetchPlaylist"]')).toBeChecked();
+ await page.getByRole("button", { name: "Create channel" }).click();
+
+ await expect(page).toHaveURL(/\/channels\/playlist-chan(\?|$)/);
+ // The store-playlist job runs async on create; wait for the playlist file.
+ await expect
+ .poll(
+ () =>
+ pathExists("test-transcripts/channels/playlist-chan/playlist"),
+ { timeout: 15000 },
+ )
+ .toBe(true);
+});
+
+test("'Add to top of auto-queue' prepends a channel leaf and enables the runner", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await page.goto("/channels/new");
+
+ await page
+ .getByLabel("URL")
+ .fill("https://www.youtube.com/@testchan/videos");
+ await page.getByRole("button", { name: "fetch details" }).click();
+ await expect(page.locator('input[name="name"]')).toHaveValue(
+ "Fake Probe Channel",
+ );
+ await page.locator('input[name="slug"]').fill("prio-chan");
+ await page.locator('input[name="prioritizeDownload"]').check();
+
+ await page.getByRole("button", { name: "Create channel" }).click();
+ await expect(page).toHaveURL(/\/channels\/prio-chan(\?|$)/);
+
+ // The download policy is enabled with our channel leaf at the head of the
+ // strict root (first child = highest priority).
+ await expect
+ .poll(
+ async () => {
+ const s = await readJson<{
+ autoQueue?: {
+ download?: {
+ enabled?: boolean;
+ root?: { children?: { match?: { value?: string } }[] };
+ };
+ };
+ }>("test-settings.json");
+ const dl = s.autoQueue?.download;
+ return {
+ enabled: dl?.enabled ?? false,
+ head: dl?.root?.children?.[0]?.match?.value ?? null,
+ };
+ },
+ { timeout: 15000 },
+ )
+ .toEqual({ enabled: true, head: "prio-chan" });
+});
diff --git a/editor/instrumentation.ts b/editor/instrumentation.ts
@@ -15,6 +15,25 @@ export async function register() {
const { startSyncHeartbeat } = await import("./app/scheduler/heartbeat");
startSyncHeartbeat();
+ // Restore a persisted transcription pause: if the operator paused
+ // transcriptions and the server later restarted, re-pause the worker pool so
+ // the pause survives the restart (it's otherwise a live-pool-only state). The
+ // downloads pause is enforced by reading settings at dispatch time, so it
+ // needs no boot step. Best-effort — never block server readiness.
+ try {
+ const { getSettings } = await import(
+ "yt-dlp-transcript-common/lib/settings"
+ );
+ if (getSettings().transcriptionsPaused) {
+ const { getWorkerPool } = await import(
+ "yt-dlp-transcript-common/jobs/workerPool"
+ );
+ getWorkerPool().pauseAll();
+ }
+ } catch {
+ /* a failed re-pause must not block server readiness */
+ }
+
// Start the automatic priority-queue runners (auto-transcribe / auto-download)
// if their policies are enabled. Each is a self-managed registry job; this only
// kicks them off and returns. Best-effort — a failure here must not stop the
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Admin: the editor dashboard is now a live mission-control cockpit, with URL-first channel onboarding and restart-surviving pause.** An operations-side change (it doesn't affect the published site): the editor's home page becomes a live, information-first operations surface — a full-width Pipeline band (running jobs, worker occupancy, sync heartbeat, global pause/sync controls) over a Needs-work / Quick-add split and an enriched channels table, all polling the same endpoints the monitor widget uses. Adding a channel is now URL-first (offline platform/handling/slug autofill + an opt-in yt-dlp "Fetch details" probe that can register an unknown platform's own queue, always store the playlist, and optionally jump the auto-queue), and pause is first-class for both transcriptions and downloads, persisted in `settings.json`. See the editor changelog for detail.
- **"Ask AI" — the Report panel's citations are now clickable, precise to the exact moment cited.** The chat's answer bubbles already linked their `[1]`, `[2]`… markers to the cited transcript; the **Report** panel — the persistent document Report mode and whole-corpus sweeps maintain — rendered as plain text, so the citations a user works from most were dead. Now the report mirrors the chat: it renders each citation as an in-page link and lists its **numbered sources** beneath the document, and clicking one **opens the transcript modal seeked to the cited line**. Citations are also upgraded — in the report *and* the chat — to a precise-moment **`[n @ mm:ss]`** form (the source's number plus the specific line's timestamp, e.g. `[3 @ 12:34]`), so a citation jumps to the exact moment instead of the video's first matched snippet. A slightly-off model timestamp is **snapped to the nearest real transcript line**, and it stays graceful: a bare `[n]` still resolves to the first snippet, and an out-of-range number or unparseable time stays plain text rather than becoming a broken link. Making this work in the report (a single accumulated string, unlike a chat message that carries its own sources) required two new pieces: a **persisted report source registry** — every video the report cites, deduped and stored light (no excerpt text) — and **global citation numbering** so a report's `[n]` is stable across every batch/turn folded in, not renumbered per write. The registry is saved with the conversation and each saved chat, so a report's clickable citations survive a reload; New chat clears it; and on the federated hub /ask (no player) citations fall back to scroll-to-source, exactly like the chat. See `export/app/ask/citations.tsx` (new shared renderer — `linkifyCitations` + `CitationLink` + `CitationSources`, with `[n @ mm:ss]` parsing and nearest-snippet snapping), `export/app/ask/{MessageBubble,ReportPanel,AskChat,useAskChat,askChatStorage}.tsx/ts`, `export/app/lib/{askRetrieval,askConversation,searchAgent}.ts` (optional/global-registry numbering + `mergeReportSources` + the `[n @ mm:ss]` prompt updates), and `export/e2e/ask-chat.spec.ts`.
- **Loose channel chips for flagged groups, plus uniform chip layout.** A channel group can now be marked `inline` (site.json → manifest), which renders each of its member channels as its own loose chip in the filter row — a checkbox + name styled like a collapsed group chip, with no expand affordance — flowing in sort order among the normal group chips; inline groups are also skipped by the multi-group default-collapse heuristic, so their channels are always visible. The out-of-the-box **"All channels" bucket now behaves this way**: a site with no configured groups switches from one open full-width card to loose per-channel chips (behavior change; add an explicit group to keep the old card). The chips themselves also get a layout fix: instead of shrink-to-fit chips that could line-break their own text, all collapsed chips share a **uniform min width** (`sm:min-w-48`), never wrap internally — long names ellipsize with the full name as a tooltip — counts right-align inside the chip, and below the `sm` breakpoint chips stack one per line at full width while larger screens pack them into rows. See `common/lib/channelGroups.ts` (`ChannelGroup.inline`), `common/components/{WorkspaceSearchBar,SearchSessionContext}.tsx`, and `export/e2e/inline-channel-chips.spec.ts`.