Archilyzer · Source

archilyzer

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

commit 0d043404065374f061b33c6bd91d5a91c81474ea
parent fff366c4bfff0ea9bdaa84337985ba9e5eb6ff9d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 17:43:04 -0400

Give the editor's actions an HTTP door

Every editor action is a server action, so an agent that wanted to drive the
editor had to drive a BROWSER. /api/ops is a thin, token-gated layer over the
actions that already exist: one route per gesture, each ~5 lines, none of them
carrying a rule the UI does not already enforce.

Three decisions worth naming:

  - THE TOKEN IS WORKER_TOKEN, shared with the LAN worker protocol on purpose.
    It already means "this instance accepts instructions from something that is
    not the browser in front of it", and a second secret would be a second thing
    to leave unset. Unset => 503, wrong => 401.

  - A JOB-STARTING ROUTE RETURNS A jobId AND NEVER STREAMS. runManagedFunction
    hands back a ReadableStream for the browser; an HTTP caller wants to hang up
    and poll, so every adapter cancels it (the on-disk log keeps going) and the
    caller follows /api/jobs/<id>/log.

  - UNKNOWN BODY KEYS ARE A 400. A misspelled downloadFilterExclude would
    otherwise get a cheerful { ok: true } and a channel that still downloads
    everything.

channel-config is the one that is not a one-liner, and channelConfigToForm.ts
says why: updateChannelAction DELETES every form-managed key before layering the
parse result on, so a FormData carrying only a patch would clear everything the
patch did not name. The patch goes on top of the channel's current form
representation instead, and parseChannelForm then refuses a bad regex with the
same sentence the form shows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/jobs/jobSpec.ts | 7+++++++
Aeditor/app/api/ops/_lib.ts | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/build-deploy/route.ts | 33+++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/build-index/route.ts | 11+++++++++++
Aeditor/app/api/ops/build-site/route.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/channel-config/route.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/channel-priority/route.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/channel/[slug]/route.ts | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/download-missing/route.ts | 21+++++++++++++++++++++
Aeditor/app/api/ops/import-video/route.ts | 17+++++++++++++++++
Aeditor/app/api/ops/lane/route.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/metadata-scan/route.ts | 16++++++++++++++++
Aeditor/app/api/ops/refresh-report/route.ts | 32++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/relocate-back/route.ts | 20++++++++++++++++++++
Aeditor/app/api/ops/relocate/route.ts | 33+++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/retry-bucket/route.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/sync/route.ts | 20++++++++++++++++++++
Aeditor/app/channels/components/channelConfigToForm.ts | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 985 insertions(+), 0 deletions(-)

diff --git a/common/jobs/jobSpec.ts b/common/jobs/jobSpec.ts @@ -53,6 +53,13 @@ const REPLAY_BUCKETS: ReadonlySet<string> = new Set<ReplayBucket>([ "supersededAutoSubs", ]); +// Is this bucket name one a replayed job can be re-derived from? Exported so a +// caller naming a bucket (the ops API's retry-bucket route) can decide whether +// the job is replayable without re-spelling the set. +export function isReplayBucket(value: unknown): value is ReplayBucket { + return typeof value === "string" && REPLAY_BUCKETS.has(value); +} + // Defensive parse for a spec read back from JSON (the <id>.meta.json sidecar). // Returns null on anything malformed so a hand-edited or stale file can't // crash a reader. Mirrors the tolerance of readJobMeta / readWorkerDefaults. diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts @@ -0,0 +1,177 @@ +import { NextResponse } from "next/server"; +import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import type { QueueOutcome } from "../../channels/lib/queueForSlugs"; + +// THE OPS API IS ADAPTERS, AND NOTHING ELSE. +// +// Every route under /api/ops is ~5 lines that validate a JSON body and call ONE +// existing server action. No route may contain a rule the UI does not already +// enforce: the point of the layer is that an agent driving the editor over HTTP +// and an operator clicking the same button get the same refusal, with the same +// sentence, from the same code. A check written here would be a second opinion +// nobody maintains. +// +// THE TOKEN IS THE WORKER TOKEN, ON PURPOSE. `WORKER_TOKEN` already gates the +// LAN worker protocol and already means "this instance accepts instructions +// from something that is not the browser in front of it". A second secret would +// be a second thing to distribute, rotate and leave unset; the failure modes are +// identical, so the gate is. Unset => 503 (the surface is off, you opt in), +// wrong => 401. +// +// A JOB-STARTING ROUTE RETURNS A jobId AND NEVER STREAMS. runManagedFunction +// hands back a ReadableStream the browser consumes; an HTTP caller wants to +// disconnect and poll. So every adapter cancels the stream (which stops pushing +// into the controller and leaves the on-disk log running — see streamCommand's +// `cancel()` note) and returns the id. Follow it with /api/jobs/<id>/log. + +export type OpsBody = Record<string, unknown>; + +// Thrown by the field readers below; caught by `ops()` and rendered as a 400. +export class OpsInputError extends Error {} + +export function opsFail( + error: string, + status = 400, + extra?: Record<string, unknown>, +): NextResponse { + return NextResponse.json({ ok: false, error, ...extra }, { status }); +} + +// Auth + body parse + unknown-key rejection, wrapped around one handler. +// +// UNKNOWN KEYS ARE A 400, not a silent ignore. A caller that misspells +// `downloadFilterExclude` would otherwise get a cheerful `{ ok: true }` and a +// channel that still downloads everything. The allow-list IS the route's +// documented body shape. +export async function ops( + request: Request, + allowedKeys: readonly string[], + run: (body: OpsBody) => Promise<NextResponse>, +): Promise<NextResponse> { + const auth = authorizeWorkerRequest(request.headers.get("authorization")); + if (!auth.ok) return opsFail(auth.error, auth.status); + let body: unknown; + try { + body = await request.json(); + } catch { + return opsFail("malformed JSON body"); + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return opsFail("body must be a JSON object"); + } + const unknown = Object.keys(body as OpsBody).filter( + (k) => !allowedKeys.includes(k), + ); + if (unknown.length) { + return opsFail( + `unknown key(s): ${unknown.join(", ")} — this route accepts ${ + allowedKeys.length ? allowedKeys.join(", ") : "no keys" + }`, + ); + } + try { + return await run(body as OpsBody); + } catch (e) { + if (e instanceof OpsInputError) return opsFail(e.message); + return opsFail((e as Error).message, 500); + } +} + +// A GET route's gate. Same token, no body. +export function opsAuth(request: Request): NextResponse | null { + const auth = authorizeWorkerRequest(request.headers.get("authorization")); + return auth.ok ? null : opsFail(auth.error, auth.status); +} + +// --- field readers ---------------------------------------------------------- + +export function reqString(body: OpsBody, key: string): string { + const v = body[key]; + if (typeof v !== "string" || !v.trim()) { + throw new OpsInputError(`"${key}" is required and must be a non-empty string`); + } + return v.trim(); +} + +export function optString(body: OpsBody, key: string): string | undefined { + const v = body[key]; + if (v === undefined) return undefined; + if (typeof v !== "string") { + throw new OpsInputError(`"${key}" must be a string`); + } + return v; +} + +export function optBool(body: OpsBody, key: string): boolean | undefined { + const v = body[key]; + if (v === undefined) return undefined; + if (typeof v !== "boolean") { + throw new OpsInputError(`"${key}" must be a boolean`); + } + return v; +} + +export function reqStringArray(body: OpsBody, key: string): string[] { + const v = body[key]; + if ( + !Array.isArray(v) || + v.length === 0 || + v.some((s) => typeof s !== "string" || !s.trim()) + ) { + throw new OpsInputError( + `"${key}" is required and must be a non-empty array of strings`, + ); + } + return (v as string[]).map((s) => s.trim()); +} + +export function oneOf<T extends string>( + body: OpsBody, + key: string, + values: readonly T[], +): T { + const v = reqString(body, key); + if (!(values as readonly string[]).includes(v)) { + throw new OpsInputError(`"${key}" must be one of ${values.join(", ")}`); + } + return v as T; +} + +// --- result mapping --------------------------------------------------------- + +// StreamActionResult -> { ok: true, jobId } | { ok: false, error, info? }. +// The stream is cancelled, never returned: see the header. +export function jobResponse(result: StreamActionResult): NextResponse { + if (!result.ok) { + return opsFail(result.error, 400, result.info ? { info: true } : undefined); + } + void result.stream.cancel(); + return NextResponse.json({ ok: true, jobId: result.jobId }); +} + +// The `{ error } | undefined` shape every /channels action returns. +export function actionResponse( + result: { error: string } | undefined, +): NextResponse { + if (result?.error) return opsFail(result.error); + return NextResponse.json({ ok: true }); +} + +// The `{ ok } | { ok: false, error }` shape the lane/pause actions return. +export function okResponse( + result: { ok: boolean; error?: string }, +): NextResponse { + if (!result.ok) return opsFail(result.error ?? "action failed"); + return NextResponse.json({ ok: true }); +} + +// A bulk fan-out's { queued, skipped }. A skip is not a failure — the caller +// gets both lists and decides, exactly as the bulk bar in the UI does. +export function queueResponse(outcome: QueueOutcome): NextResponse { + return NextResponse.json({ + ok: true, + queued: outcome.queued, + skipped: outcome.skipped, + }); +} diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { + buildAndDeployAction, + buildAndDeployAllSitesAction, +} from "../../../sites/lib/buildAction"; +import { jobResponse, OpsInputError, ops, optBool, optString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { siteId: string, skipArchives? } | { all: true, skipArchives? } +// -> { ok: true, jobId } +// +// One managed job either way (build then deploy, one log, one Cancel), so the +// caller polls /api/jobs/<jobId>/log exactly as for a single site. +export async function POST(request: Request) { + return ops(request, ["siteId", "all", "skipArchives"], async (body) => { + const skipArchives = optBool(body, "skipArchives"); + const all = optBool(body, "all"); + const siteId = optString(body, "siteId"); + if (all) { + if (siteId) throw new OpsInputError('send either "siteId" or "all", not both'); + return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); + } + if (!siteId?.trim()) { + throw new OpsInputError('"siteId" is required (or send { "all": true })'); + } + return jobResponse(await buildAndDeployAction(siteId.trim(), skipArchives)); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/api/ops/build-index/route.ts b/editor/app/api/ops/build-index/route.ts @@ -0,0 +1,11 @@ +import { buildIndexAction } from "../../../sites/lib/buildAction"; +import { jobResponse, ops, optString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { queueKey? } -> { ok: true, jobId }. Rebuilds the LMDB corpus index. +export async function POST(request: Request) { + return ops(request, ["queueKey"], async (body) => + jobResponse(await buildIndexAction(optString(body, "queueKey"))), + ); +} diff --git a/editor/app/api/ops/build-site/route.ts b/editor/app/api/ops/build-site/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { + buildAllSitesAction, + buildExportAction, +} from "../../../sites/lib/buildAction"; +import { jobResponse, OpsInputError, ops, optBool } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { siteIds: string[], skipData?, skipArchives? } | { all: true, skipArchives? } +// +// BUILD WITHOUT DEPLOYING. `siteIds` queues one build-export job per site on the +// shared build queue (they run one at a time, as they do from /sites) and +// returns `{ jobs: [{ siteId, jobId }] }` — a list, because there is a job per +// site and a caller waiting on them needs all the ids. `{ all: true }` is the +// single build-all job instead, which is the docker fan-out. +export async function POST(request: Request) { + return ops( + request, + ["siteIds", "all", "skipData", "skipArchives"], + async (body) => { + const skipArchives = optBool(body, "skipArchives"); + if (optBool(body, "all")) { + if (body.siteIds !== undefined) { + throw new OpsInputError('send either "siteIds" or "all", not both'); + } + return jobResponse(await buildAllSitesAction(skipArchives)); + } + const raw = body.siteIds; + if ( + !Array.isArray(raw) || + raw.length === 0 || + raw.some((s) => typeof s !== "string" || !s.trim()) + ) { + throw new OpsInputError( + '"siteIds" is required and must be a non-empty array of strings (or send { "all": true })', + ); + } + const skipData = optBool(body, "skipData"); + const jobs: { siteId: string; jobId: string }[] = []; + const skipped: { siteId: string; reason: string }[] = []; + for (const siteId of (raw as string[]).map((s) => s.trim())) { + const result = await buildExportAction( + siteId, + undefined, + skipData, + skipArchives, + ); + if (!result.ok) { + skipped.push({ siteId, reason: result.error }); + continue; + } + void result.stream.cancel(); + jobs.push({ siteId, jobId: result.jobId }); + } + return NextResponse.json({ ok: true, jobs, skipped }); + }, + ); +} diff --git a/editor/app/api/ops/channel-config/route.ts b/editor/app/api/ops/channel-config/route.ts @@ -0,0 +1,43 @@ +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { + applyChannelFormPatch, + channelConfigToFormData, +} from "../../../channels/components/channelConfigToForm"; +import { updateChannelAction } from "../../../channels/actions"; +import { actionResponse, OpsInputError, ops, reqString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug: string, patch: { <Configure-form field>: string|number|boolean|null } } +// +// The patch keys are the FORM's field names, not ChannelConfig's, because the +// form is what validates them: `downloadFilterInclude` / `downloadFilterExclude` +// rather than a `downloadFilter` object, `ytdlpExtraArgs` as a string or an +// array of lines. See channelConfigToForm.ts for why the patch is laid over the +// channel's current form representation instead of being written directly. +// +// `""` (or null) clears a field, exactly as clearing the input does. +export async function POST(request: Request) { + return ops(request, ["slug", "patch"], async (body) => { + const slug = reqString(body, "slug"); + const patch = body.patch; + if ( + typeof patch !== "object" || + patch === null || + Array.isArray(patch) + ) { + throw new OpsInputError('"patch" must be a JSON object'); + } + const paths = getPaths(); + const existing = await readChannelConfig(paths, slug); + if (!existing) throw new OpsInputError(`Channel "${slug}" not found`); + const fd = channelConfigToFormData(existing); + try { + applyChannelFormPatch(fd, patch as Record<string, unknown>); + } catch (e) { + throw new OpsInputError((e as Error).message); + } + return actionResponse(await updateChannelAction(slug, undefined, fd)); + }); +} diff --git a/editor/app/api/ops/channel-priority/route.ts b/editor/app/api/ops/channel-priority/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { + STORED_CHANNEL_TIERS, + PRIORITY_OPERATIONS, + type PriorityOperation, + type StoredChannelTier, +} from "yt-dlp-transcript-common/lib/channelPriority"; +import { + applyChannelPriorityPresetAction, + setChannelOperationTierAction, + setChannelTierAction, +} from "../../../channels/actions"; +import { + actionResponse, + OpsInputError, + ops, + optString, + reqStringArray, +} from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slugs: string[], tier?: "normal"|"low"|"paused"|null, operation?: one +// of sync|transcription|download|digest|backfill, preset?: "sync-only"|"clear" } +// +// Three named gestures, one route, because they are one gesture in the UI: the +// /channels deck's tier control. `operation` present pins that operation's +// override (null clears it back to the base tier); absent sets the BASE tier. +// `preset` is the deck's two shortcuts and takes no tier. +export async function POST(request: Request) { + return ops( + request, + ["slugs", "tier", "operation", "preset"], + async (body) => { + const slugs = reqStringArray(body, "slugs"); + const preset = optString(body, "preset"); + if (preset !== undefined) { + if (preset !== "sync-only" && preset !== "clear") { + throw new OpsInputError('"preset" must be "sync-only" or "clear"'); + } + return actionResponse( + await applyChannelPriorityPresetAction(slugs, preset), + ); + } + const rawTier = body.tier; + if (rawTier !== null && typeof rawTier !== "string") { + throw new OpsInputError( + `"tier" must be one of ${STORED_CHANNEL_TIERS.join(", ")} (or null with an "operation", to clear the override)`, + ); + } + if ( + rawTier !== null && + !(STORED_CHANNEL_TIERS as readonly string[]).includes(rawTier) + ) { + throw new OpsInputError( + `"tier" must be one of ${STORED_CHANNEL_TIERS.join(", ")}`, + ); + } + const operation = optString(body, "operation"); + if (operation !== undefined) { + if (!(PRIORITY_OPERATIONS as readonly string[]).includes(operation)) { + throw new OpsInputError( + `"operation" must be one of ${PRIORITY_OPERATIONS.join(", ")}`, + ); + } + return actionResponse( + await setChannelOperationTierAction( + slugs, + operation as PriorityOperation, + rawTier as StoredChannelTier | null, + ), + ); + } + if (rawTier === null) { + throw new OpsInputError( + 'a null "tier" clears an operation override — name an "operation", or send a stored tier', + ); + } + return actionResponse( + await setChannelTierAction(slugs, rawTier as StoredChannelTier), + ); + }, + ); +} + +export function GET() { + return NextResponse.json( + { ok: false, error: "POST only" }, + { status: 405 }, + ); +} diff --git a/editor/app/api/ops/channel/[slug]/route.ts b/editor/app/api/ops/channel/[slug]/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + readChannelConfig, + readChannelSnapshot, + readChannelStat, +} from "yt-dlp-transcript-common/controller/channels"; +import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; +import { + overridesOf, + rankOf, + tierOf, +} from "yt-dlp-transcript-common/lib/channelPriority"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { opsAuth } from "../../_lib"; + +export const dynamic = "force-dynamic"; + +// GET /api/ops/channel/<slug> +// +// THE READ SIDE, so a caller never opens transcripts/ itself. Everything a +// script needs to decide what to do next about one channel: its stored config, +// its report totals and bucket SIZES (not the id lists — a bucket can hold tens +// of thousands of ids and a caller deciding "is there work" only needs the +// count; /api/ops/retry-bucket resolves the ids server-side anyway), its +// priority tier, and — the one that cannot be inferred from the filesystem — +// whether its media is reachable. +// +// `media.status` is `inspectChannelMedia`'s, which is the ONE module that can +// tell an unmounted drive from an empty channel. Every enumerator else swallows +// ENOENT on data/ as "no videos", so a script reading counts alone would read an +// unmounted platter as a channel that has downloaded nothing. +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const denied = opsAuth(request); + if (denied) return denied; + const { slug } = await params; + const paths = getPaths(); + const config = await readChannelConfig(paths, slug); + if (!config) { + return NextResponse.json( + { ok: false, error: `Channel "${slug}" not found` }, + { status: 404 }, + ); + } + const priority = getSettings().channelPriority; + const [snapshot, stat, media] = await Promise.all([ + readChannelSnapshot(paths, slug), + readChannelStat(paths, slug), + inspectChannelMedia(paths, slug, config), + ]); + const buckets = snapshot + ? Object.fromEntries( + Object.entries(snapshot.buckets).map(([k, v]) => [ + k, + Array.isArray(v) ? v.length : v, + ]), + ) + : null; + return NextResponse.json({ + ok: true, + slug, + config, + priority: { + tier: tierOf(priority, slug), + overrides: overridesOf(priority, slug), + rank: rankOf(priority, slug), + }, + stat, + media, + report: snapshot + ? { + generatedAt: snapshot.generatedAt, + totals: snapshot.totals, + buckets, + } + : null, + }); +} diff --git a/editor/app/api/ops/download-missing/route.ts b/editor/app/api/ops/download-missing/route.ts @@ -0,0 +1,21 @@ +import { downloadMissingAction } from "../../../channels/[slug]/pipelineActions"; +import { jobResponse, ops, optBool, optString, reqString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug, queueKey?, ignoreArchive?, abortOnError? } -> { ok: true, jobId } +export async function POST(request: Request) { + return ops( + request, + ["slug", "queueKey", "ignoreArchive", "abortOnError"], + async (body) => + jobResponse( + await downloadMissingAction( + reqString(body, "slug"), + optString(body, "queueKey"), + optBool(body, "ignoreArchive"), + optBool(body, "abortOnError"), + ), + ), + ); +} diff --git a/editor/app/api/ops/import-video/route.ts b/editor/app/api/ops/import-video/route.ts @@ -0,0 +1,17 @@ +import { importVideoAction } from "../../../channels/[slug]/pipelineActions"; +import { jobResponse, ops, optString, reqString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug: string, url: string, queueKey?: string } -> { ok: true, jobId } +export async function POST(request: Request) { + return ops(request, ["slug", "url", "queueKey"], async (body) => + jobResponse( + await importVideoAction( + reqString(body, "slug"), + reqString(body, "url"), + optString(body, "queueKey"), + ), + ), + ); +} diff --git a/editor/app/api/ops/lane/route.ts b/editor/app/api/ops/lane/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { LANES, type AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { drainAutoRunner } from "yt-dlp-transcript-common/controller/autoRunner"; +import { + pauseLaneAction, + resumeLaneAction, + saveAutoQueueAction, + startAutoQueueAction, + stopAutoQueueAction, +} from "../../../operations/actions"; +import { OpsInputError, okResponse, ops, oneOf, optBool } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { lane: transcription|download|digest|backfill, +// held?: boolean, enabled?: boolean, action?: "start"|"stop"|"drain" } +// +// One lane, up to three independent changes, applied in the order the operator +// would: enable the policy, set the gate, then bring the runner up or down. +// Each is the existing action and nothing more. +// +// `held` IS NOT `enabled`. A hold is a dispatch gate (the runner idle-waits and +// keeps its place); disabling is the policy's master switch. The pair is what +// /operations draws as two separate controls and this route keeps them separate. +// +// `action` mirrors /api/auto-queue/control's body — same three verbs, same +// meanings — so a caller that already drives that route needs nothing new. +export async function POST(request: Request) { + return ops(request, ["lane", "held", "enabled", "action"], async (body) => { + const lane = oneOf(body, "lane", LANES) as AutoQueueKind; + const enabled = optBool(body, "enabled"); + const held = optBool(body, "held"); + const action = body.action; + if ( + action !== undefined && + action !== "start" && + action !== "stop" && + action !== "drain" + ) { + throw new OpsInputError('"action" must be "start", "stop" or "drain"'); + } + if (enabled === undefined && held === undefined && action === undefined) { + throw new OpsInputError( + 'nothing to do — send at least one of "enabled", "held" or "action"', + ); + } + if (enabled !== undefined) { + // EVERY OTHER POLICY FIELD IS CARRIED FROM THE STORED POLICY, including + // `root`: saveAutoQueueAction refuses a tree edit while the priority + // model compiles the roots, and handing it back the stored tree is what + // makes this a pure enable/disable rather than a tree write. + const policy = getSettings().autoQueue[lane]; + const saved = await saveAutoQueueAction(lane, { + enabled, + maxWorkers: policy.maxWorkers, + replaceAutoSubs: policy.replaceAutoSubs === true, + order: policy.order ?? "listed", + root: policy.root, + }); + if (!saved.ok) return okResponse(saved); + } + if (held !== undefined) { + const gated = held + ? await pauseLaneAction(lane) + : await resumeLaneAction(lane); + if (!gated.ok) return okResponse(gated); + } + if (action === "start") { + const started = await startAutoQueueAction(lane); + if (!started.ok) return okResponse(started); + } else if (action === "stop") { + const stopped = await stopAutoQueueAction(lane); + if (!stopped.ok) return okResponse(stopped); + } else if (action === "drain") { + drainAutoRunner(lane); + } + return NextResponse.json({ ok: true, lane }); + }); +} diff --git a/editor/app/api/ops/metadata-scan/route.ts b/editor/app/api/ops/metadata-scan/route.ts @@ -0,0 +1,16 @@ +import { runMetadataScanAction } from "../../../channels/[slug]/pipelineActions"; +import { jobResponse, ops, optString, reqString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug: string, queueKey?: string } -> { ok: true, jobId } +export async function POST(request: Request) { + return ops(request, ["slug", "queueKey"], async (body) => + jobResponse( + await runMetadataScanAction( + reqString(body, "slug"), + optString(body, "queueKey"), + ), + ), + ); +} diff --git a/editor/app/api/ops/refresh-report/route.ts b/editor/app/api/ops/refresh-report/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { + refreshAllChannelSnapshotsAction, + refreshChannelSnapshotAction, +} from "../../../channels/actions"; +import { actionResponse, OpsInputError, ops, optBool, optString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug: string } | { all: true } +// +// The single-channel form REGENERATES SYNCHRONOUSLY (it is a filesystem scan, +// not a job) and returns `{ ok: true }` once snapshot.json is on disk. The +// `all` form queues one refresh-report job per channel and returns the bulk +// { queued, skipped } the /channels header button shows. +export async function POST(request: Request) { + return ops(request, ["slug", "all"], async (body) => { + const all = optBool(body, "all"); + const slug = optString(body, "slug"); + if (all) { + if (slug) { + throw new OpsInputError('send either "slug" or "all", not both'); + } + const result = await refreshAllChannelSnapshotsAction(); + return NextResponse.json({ ok: true, ...result }); + } + if (!slug?.trim()) { + throw new OpsInputError('"slug" is required (or send { "all": true })'); + } + return actionResponse(await refreshChannelSnapshotAction(slug.trim())); + }); +} diff --git a/editor/app/api/ops/relocate-back/route.ts b/editor/app/api/ops/relocate-back/route.ts @@ -0,0 +1,20 @@ +import { moveChannelMediaBackAction } from "../../../channels/[slug]/storageActions"; +import { ops, queueResponse, reqStringArray } from "../_lib"; +import { queueForSlugs } from "../../../channels/lib/queueForSlugs"; + +export const dynamic = "force-dynamic"; + +// POST { slugs: string[] } -> { ok: true, queued, skipped } +// +// One job per slug on the shared relocation queue, and the per-channel action's +// own busy guard decides each one — the same fan-out loop the bulk move uses, +// so a refusal reads identically whether it came from the panel or from here. +export async function POST(request: Request) { + return ops(request, ["slugs"], async (body) => + queueResponse( + await queueForSlugs(reqStringArray(body, "slugs"), { + run: (slug) => moveChannelMediaBackAction(slug), + }), + ), + ); +} diff --git a/editor/app/api/ops/relocate/route.ts b/editor/app/api/ops/relocate/route.ts @@ -0,0 +1,33 @@ +import { bulkRelocateChannelMediaAction } from "../../../channels/bulkStorageActions"; +import { OpsInputError, ops, optString, queueResponse, reqStringArray } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slugs: string[], locationId?: string, root?: string } +// -> { ok: true, queued, skipped } +// +// THE DESTINATION IS A LOCATION ID WHEREVER POSSIBLE — the root is resolved on +// the server from settings.storage.locations, so a caller holding a stale root +// cannot aim a batch somewhere a re-point has moved. `root` is the one-off +// escape hatch the panel also offers. A skip is not a failure: every slug that +// did not queue comes back with the same sentence the bulk bar shows. +export async function POST(request: Request) { + return ops(request, ["slugs", "locationId", "root"], async (body) => { + const slugs = reqStringArray(body, "slugs"); + const locationId = optString(body, "locationId"); + const root = optString(body, "root"); + if ((locationId ? 1 : 0) + (root ? 1 : 0) !== 1) { + throw new OpsInputError( + 'send exactly one of "locationId" (a location configured on /storage) or "root" (an absolute path)', + ); + } + return queueResponse( + await bulkRelocateChannelMediaAction( + slugs, + locationId + ? { kind: "location", locationId } + : { kind: "custom", root: root as string }, + ), + ); + }); +} diff --git a/editor/app/api/ops/retry-bucket/route.ts b/editor/app/api/ops/retry-bucket/route.ts @@ -0,0 +1,71 @@ +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { readChannelSnapshot } from "yt-dlp-transcript-common/controller/channels"; +import { isReplayBucket } from "yt-dlp-transcript-common/jobs/jobSpec"; +import { retryBucketAction } from "../../../channels/[slug]/pipelineActions"; +import { + jobResponse, + OpsInputError, + ops, + optBool, + optString, + reqString, +} from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug, bucket, queueKey?, abortOnError?, handlingOverride?, +// forceCookies?, replaceAutoSubs? } -> { ok: true, jobId } +// +// THE BUCKET IS RESOLVED FROM THE SNAPSHOT HERE, and that is not new logic: the +// bucket controls in the UI pass `snapshot.buckets[key]` to the same action, and +// the action's own argument is a list of ids. An HTTP caller naming a bucket +// rather than pasting ids is the same gesture — and `bucketKey` travelling with +// it is what makes the job replayable against the CURRENT bucket, exactly as a +// clicked one is. +export async function POST(request: Request) { + return ops( + request, + [ + "slug", + "bucket", + "queueKey", + "abortOnError", + "handlingOverride", + "forceCookies", + "replaceAutoSubs", + ], + async (body) => { + const slug = reqString(body, "slug"); + const bucket = reqString(body, "bucket"); + const snapshot = await readChannelSnapshot(getPaths(), slug); + if (!snapshot) { + throw new OpsInputError( + `Channel "${slug}" has no report yet — run /api/ops/refresh-report first.`, + ); + } + const ids = (snapshot.buckets as Record<string, unknown>)[bucket]; + if (!Array.isArray(ids)) { + throw new OpsInputError( + `"${bucket}" is not a bucket on this channel's report — known buckets: ${Object.keys( + snapshot.buckets, + ).join(", ")}`, + ); + } + return jobResponse( + await retryBucketAction( + slug, + ids as string[], + optString(body, "queueKey"), + optBool(body, "abortOnError"), + optString(body, "handlingOverride"), + // Replayable only for the buckets a replay can re-derive; an + // ad-hoc bucket name still runs, it just carries no spec — the + // same distinction the UI's named vs checkbox controls make. + isReplayBucket(bucket) ? bucket : undefined, + optBool(body, "forceCookies"), + optBool(body, "replaceAutoSubs"), + ), + ); + }, + ); +} diff --git a/editor/app/api/ops/sync/route.ts b/editor/app/api/ops/sync/route.ts @@ -0,0 +1,20 @@ +import { syncAction } from "../../../channels/[slug]/pipelineActions"; +import { jobResponse, ops, optBool, optString, reqString } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug: string, full?: boolean, queueKey?: string } -> { ok: true, jobId } +// +// `full` forces the periodic whole-listing sweep now; without it the job +// decides for itself from the configured cadence. +export async function POST(request: Request) { + return ops(request, ["slug", "full", "queueKey"], async (body) => + jobResponse( + await syncAction( + reqString(body, "slug"), + optString(body, "queueKey"), + optBool(body, "full"), + ), + ), + ); +} diff --git a/editor/app/channels/components/channelConfigToForm.ts b/editor/app/channels/components/channelConfigToForm.ts @@ -0,0 +1,173 @@ +import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; + +// THE INVERSE OF parseChannelForm, and it exists for exactly one caller: +// /api/ops/channel-config. +// +// WHY A ROUND TRIP THROUGH FormData rather than a direct config write. The form +// parser is where a download-filter regex is refused, where a sync cadence is +// bounded and where a cleared field is turned into an absent key — and +// `updateChannelAction` DELETES every CHANNEL_FORM_FIELDS key from the stored +// config before layering the parse result on top, so that a cleared input +// actually clears. Feed it a FormData carrying only a patch and it would clear +// everything the patch did not name. So the patch is applied ON TOP of the +// channel's current form representation, which is what this builds, and the +// action then behaves EXACTLY as it does for the browser form. One validator, +// one writer, one set of error sentences. +// +// Every key below is a field name `parseChannelForm` reads. Checkbox fields +// follow the browser: present means checked, absent means unchecked — which is +// why they are emitted only when true. + +// The checkbox-shaped fields (presence, not value). +export const CHANNEL_FORM_FLAGS = [ + "keepSourceVideo", + "downloadFilterIncludeLivestreams", + "audioCheckEnabled", + "audioCheckResumeDuringProbe", +] as const; + +// The value-shaped fields. A patch may set any of these to "" to clear it. +export const CHANNEL_FORM_VALUES = [ + "name", + "handling", + "url", + "platform", + "sourceKind", + "postFetcher", + "socialHandle", + "audioFormat", + "downloadFormat", + "keepLatest", + "extractionMode", + "savedVideosDir", + "ytdlpExtraArgs", + "sleepBetweenDownloadsSeconds", + "syncIntervalMinutes", + "fullSweepIntervalMinutes", + "cookiesFromBrowser", + "cookieMode", + "downloadFilterInclude", + "downloadFilterExclude", + "audioCheckIntervalSeconds", + "audioCheckMaxRollbacks", + "audioCheckCopyTimeoutSeconds", +] as const; + +export type ChannelFormFlag = (typeof CHANNEL_FORM_FLAGS)[number]; +export type ChannelFormValue = (typeof CHANNEL_FORM_VALUES)[number]; +export type ChannelFormField = ChannelFormFlag | ChannelFormValue; + +export const CHANNEL_FORM_FIELD_NAMES: readonly ChannelFormField[] = [ + ...CHANNEL_FORM_VALUES, + ...CHANNEL_FORM_FLAGS, +]; + +function put(fd: FormData, key: string, value: string | undefined | null): void { + if (value === undefined || value === null || value === "") return; + fd.set(key, value); +} + +function putNum(fd: FormData, key: string, value: number | undefined): void { + if (value === undefined || value === null) return; + fd.set(key, String(value)); +} + +// The channel's stored config as the Configure form would post it. +export function channelConfigToFormData(config: ChannelConfig): FormData { + const fd = new FormData(); + // Both are REQUIRED by parseChannelForm and are not CHANNEL_FORM_FIELDS, so + // they always travel. + fd.set("name", config.name ?? ""); + fd.set("handling", config.handling ?? "transcribe"); + put(fd, "url", config.url); + put(fd, "platform", config.platform); + put(fd, "sourceKind", config.sourceKind); + put(fd, "postFetcher", config.postFetcher); + put(fd, "socialHandle", config.socialHandle); + put(fd, "audioFormat", config.audioFormat); + put(fd, "downloadFormat", config.downloadFormat); + if (config.keepSourceVideo) fd.set("keepSourceVideo", "on"); + putNum(fd, "keepLatest", config.keepLatest); + put(fd, "extractionMode", config.extractionMode); + put(fd, "savedVideosDir", config.savedVideosDir); + if (config.ytdlpExtraArgs?.length) { + fd.set("ytdlpExtraArgs", config.ytdlpExtraArgs.join("\n")); + } + putNum(fd, "sleepBetweenDownloadsSeconds", config.sleepBetweenDownloadsSeconds); + putNum(fd, "syncIntervalMinutes", config.syncIntervalMinutes); + putNum(fd, "fullSweepIntervalMinutes", config.fullSweepIntervalMinutes); + put(fd, "cookiesFromBrowser", config.cookiesFromBrowser); + put(fd, "cookieMode", config.cookieMode); + put(fd, "downloadFilterInclude", config.downloadFilter?.include); + put(fd, "downloadFilterExclude", config.downloadFilter?.exclude); + if (config.downloadFilter?.includeLivestreams) { + fd.set("downloadFilterIncludeLivestreams", "on"); + } + if (config.audioCheck?.enabled) { + fd.set("audioCheckEnabled", "on"); + putNum(fd, "audioCheckIntervalSeconds", config.audioCheck.intervalSeconds); + putNum(fd, "audioCheckMaxRollbacks", config.audioCheck.maxRollbacks); + putNum( + fd, + "audioCheckCopyTimeoutSeconds", + config.audioCheck.copyTimeoutSeconds, + ); + if (config.audioCheck.resumeDuringProbe) { + fd.set("audioCheckResumeDuringProbe", "on"); + } + } + return fd; +} + +export type ChannelConfigPatch = Record<string, unknown>; + +// Lay a JSON patch over a FormData built above. Values: a string (or number, +// stringified) sets the field, `""` clears it; a flag takes a boolean. +// `ytdlpExtraArgs` additionally accepts an array of lines, because that is the +// shape it has in config.json and a caller should not have to know the textarea +// is newline-separated. +// +// Throws on a key this form has no field for — the allow-list IS the documented +// body shape, and a misspelled key that silently did nothing would look like a +// successful save. +export function applyChannelFormPatch( + fd: FormData, + patch: ChannelConfigPatch, +): void { + for (const [key, value] of Object.entries(patch)) { + if ((CHANNEL_FORM_FLAGS as readonly string[]).includes(key)) { + if (typeof value !== "boolean") { + throw new Error(`"${key}" must be a boolean`); + } + if (value) fd.set(key, "on"); + else fd.delete(key); + continue; + } + if (!(CHANNEL_FORM_VALUES as readonly string[]).includes(key)) { + throw new Error( + `"${key}" is not a channel config field — accepted: ${CHANNEL_FORM_FIELD_NAMES.join(", ")}`, + ); + } + if (key === "ytdlpExtraArgs" && Array.isArray(value)) { + if (value.some((v) => typeof v !== "string")) { + throw new Error(`"${key}" must be an array of strings`); + } + const joined = (value as string[]).join("\n"); + if (joined.trim()) fd.set(key, joined); + else fd.delete(key); + continue; + } + if (value === null || value === "") { + fd.delete(key); + continue; + } + if (typeof value === "number") { + fd.set(key, String(value)); + continue; + } + if (typeof value !== "string") { + throw new Error(`"${key}" must be a string, a number, or "" to clear it`); + } + fd.set(key, value); + } +}