Archilyzer · Source

archilyzer

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

commit 310a9afb3af1fb0826033f689b9045e2a50b142b
parent 025ed41d78557f162d403cbefd7209190c3291b4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 24 Jul 2026 18:14:47 -0400

Dashboard mission-control cockpit + URL-first onboarding + persisted pause

Rebuild the editor dashboard as a live, information-first operations
surface, streamline channel creation into a URL-first flow, and make
pause first-class and restart-surviving for both transcriptions and
downloads.

- Cockpit: page.tsx SSRs initial payloads into a client DashboardCockpit
  that polls /api/{jobs/active,workers,widget/actionable,widget/sync}.
  New PipelineBand (hero) / NeedsWorkPanel / QuickAddPanel / ChannelsTable
  under editor/app/components/dashboard/. Poll hooks extracted to
  widget/lib/usePolledPayload.ts and time helpers to relativeTime.ts,
  shared with the monitor widget.
- Onboarding: ChannelForm is now a client component with offline
  platform/handling/slug autofill + a derived-queue hint, an opt-in
  "Fetch details" yt-dlp probe (probeChannelMeta / probeChannelUrlAction)
  that can register an unknown-but-yt-dlp-known host's own per-domain
  queue, always-store-playlist on create, an optional add-to-top-of-
  auto-queue (prioritizeChannelDownloadAction), and the missing kick
  platform option.
- Pause: settings.json gains transcriptionsPaused/downloadsPaused.
  Transcriptions re-pause at boot (instrumentation.ts); downloads idle
  the auto-runner each loop and block manual download modes with a
  friendly notice (store-playlist stays allowed). PauseDownloadsButton
  mirrors PauseTranscriptionsButton on the dashboard and widget.
- Tests: rewrite dashboard.spec.ts, add new-channel-onboarding.spec.ts,
  and a fake-ytdlp probe branch. Update both CHANGELOGs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mcommon/controller/autoRunner.ts | 9++++++++-
Mcommon/lib/settings.ts | 20++++++++++++++++++++
Mcommon/ytdlp/runYtdlp.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 3+++
Meditor/app/api/widget/sync/route.ts | 12+++++++++---
Meditor/app/auto-queue/actions.ts | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/pipelineActions.ts | 12++++++++++++
Meditor/app/channels/actions.ts | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/components/ChannelForm.tsx | 419++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Aeditor/app/components/dashboard/ChannelsTable.tsx | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/dashboard/DashboardCockpit.tsx | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/dashboard/NeedsWorkPanel.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/dashboard/PipelineBand.tsx | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/dashboard/QuickAddPanel.tsx | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/dashboard/types.ts | 14++++++++++++++
Meditor/app/jobs/actions.ts | 32++++++++++++++++++++++++++++++++
Aeditor/app/jobs/components/PauseDownloadsButton.tsx | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/page.tsx | 248+++++++++++++++++++------------------------------------------------------------
Meditor/app/settings/actions.ts | 4++++
Meditor/app/widget/components/MonitorWidget.tsx | 93++++---------------------------------------------------------------------------
Meditor/app/widget/components/WidgetControls.tsx | 7+++++++
Aeditor/app/widget/lib/relativeTime.ts | 32++++++++++++++++++++++++++++++++
Aeditor/app/widget/lib/usePolledPayload.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/workers/actions.ts | 21+++++++++++++++++++++
Meditor/app/workers/buildWorkers.ts | 10+++++++++-
Meditor/app/workers/components/WorkersView.tsx | 4++++
Meditor/e2e/dashboard.spec.ts | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 15+++++++++++++++
Aeditor/e2e/new-channel-onboarding.spec.ts | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/instrumentation.ts | 19+++++++++++++++++++
Mexport/CHANGELOG.md | 1+
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&apos;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&apos;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&apos;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`.