commit 4f9352b9509aa01a6971d855e07812aed9899838
parent 7efc976ae375b59ceb2637f91f93ace38d55babf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 22:20:05 -0400
Merge feat/channel-management-ops (rename/delete a social channel from its page; create/rename/delete/sites over ops)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
14 files changed, 824 insertions(+), 70 deletions(-)
diff --git a/RUNNING_IN_DOCKER.md b/RUNNING_IN_DOCKER.md
@@ -251,6 +251,10 @@ pnpm ops sync --json '{"slug":"the-quartering"}' --wait
pnpm ops metadata-scan --json '{"slug":"the-quartering"}'
pnpm ops refresh-metadata --json '{"slug":"the-quartering","id":"<videoId>"}' --wait
pnpm ops channel-config --json '{"slug":"the-quartering","patch":{"downloadFilterExclude":"rerun"}}'
+pnpm ops channel-config --json '{"slug":"example-x","sites":[],"excludeFromBuild":true}'
+pnpm ops create-channel --json '{"fields":{"name":"Example (X)","handling":"transcribe","url":"https://x.com/example"}}'
+pnpm ops rename-channel --json '{"slug":"exmaple-x","newSlug":"example-x"}'
+pnpm ops delete-channel --json '{"slug":"example-x","confirm":"example-x"}'
pnpm ops channel-priority --json '{"slugs":["the-quartering"],"operation":"download","tier":"paused"}'
pnpm ops lane --json '{"lane":"download","held":true}'
pnpm ops refresh-report --json '{"all":true}'
@@ -259,6 +263,7 @@ pnpm ops fetch-posts --json '{"slug":"example-x","older":true}' --wait
pnpm ops capture-posts --json '{"slug":"example-x","ids":["1234567890"]}' --wait
pnpm ops persist-videos --json '{"items":[{"slug":"example-channel","id":"abc123"}],"dryRun":true}'
pnpm ops get channel the-quartering
+pnpm ops get channels # every channel, its kind and its sites
pnpm ops list # every action name
```
@@ -274,7 +279,9 @@ Four things to know before you script against it:
`config.json`'s — `downloadFilterInclude` / `downloadFilterExclude` rather than
a `downloadFilter` object. That is what routes them through the form's own
validators, so a bad regex is refused here with the sentence the form shows.
- `""` clears a field, exactly as clearing the input does.
+ `""` clears a field, exactly as clearing the input does. `"sites"` is the
+ form's Sites section — the WHOLE membership set, `[]` for on no site — and
+ `create-channel`'s `"fields"` take the same names.
- **`keep-videos` sets the do-not-clean marker** — the video page's "Do not
clean" toggle, over every video of one channel whose title or description
matches `match`. `match` is matched exactly as a `downloadFilterInclude` is (a
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **A social channel can be renamed and deleted from its page.** A posts channel's page (X, Bluesky, a forum thread) had no Danger zone, so it could not be renamed or deleted in the editor at all. It now has the one a video channel has, collapsed under the posts panel and opened by the same `?stage=danger` link: **Rename channel** and **Delete channel**, typing the slug to confirm, refused while the channel is busy, in the same sentences. Needs a restart of the editor.
+- **A channel can be created, renamed, deleted and put on sites over `pnpm ops`.** `create-channel` is the New channel form (`{"fields": {"name", "handling", "url", …}}`, the same field names as `channel-config`'s `patch`; `"slug"` and `"sites"` optional; the form's "Fetch playlist now", "Fetch posts now" and "Add to top of auto-queue" are off unless asked for, and a job they start is returned so `--wait` follows it). `rename-channel` (`{"slug", "newSlug"}`) and `delete-channel` (`{"slug", "confirm"}`, `confirm` repeating the slug) are the Danger zone's two forms. `channel-config` takes `"sites"` — the whole membership set, `[]` for on no site; an unknown site is refused rather than skipped — and `"excludeFromBuild"` / `"excludeFromCleanup"`, set to the value given rather than toggled, with or without a `patch`. `pnpm ops get channels` lists every channel with its kind, platform and the sites that carry it. Each runs the form's own action, so it refuses what the form refuses, in the same words. Needs a restart of the editor.
- **One video's metadata can be read again from its source.** A livestream that has just ended offers one fragmented audio format and no captions; hours later the same URL has plain formats and auto-captions, and the video's `metadata.info.json` still said what it said the first time. "Refresh metadata" under the video page's header, and `pnpm ops refresh-metadata --json '{"slug":"…","id":"…"}'`, re-read that one video with no subtitles and no media, on the platform's queue, with the channel's cookie policy and pace; a held platform or one in a rate-limit cooldown is refused, and a rate limit records the cooldown. The rewrite is recorded in the metadata history as `refresh`, and the job's log ends with what the source now says: `live_status`, how many formats, each audio-only format and its protocol, whether any is non-fragmented, the English subtitle and caption tracks, and the keys that changed. Nothing is downloaded or deleted, and no download attempt is recorded. A video not yet fetched into the archive is refused — a refresh never creates its directory — as are an archive.org record, a Wayback copy and a record completed from a podcast feed, whose metadata is not yt-dlp's. Needs a restart of the editor.
- **A renamed social channel's posts open again.** Each archived post carries the channel slug it was fetched under, and renaming the channel moves its directory without rewriting them, so every post of a renamed channel named the old slug: the index filed it under the new one, the post page and its thread could not find it there, and MCP links named a channel that no longer existed. A post's channel and slug are now read from the directory it is stored in, wherever it was fetched; the files are not rewritten. The next index build corrects the published records. Needs a restart of the editor.
- **A video's other English tracks are readable and searchable where their words differ.** Uploaded captions are not always a transcript of what was said, so the tracks beside the transcript stay: the served `en` beside `en-orig`, a regional or auto-translated track, and the captions a local transcription replaced. One is kept where its words differ from the transcript's and from every track kept before it; identical tracks, most of them, add nothing. The index keeps them in an `alts` sub-DB and writes `track` and `altTracks` onto the transcript record only then, so every other record's page is what it was. A search hit in a word only an alternate holds names the track; one every track says is found once, in the transcript. The video page's **Transcript** card reads the transcript and switches tracks ("Track: original audio captions ▾"); switching changes nothing on disk, and **Set as transcript** stays the way the transcript itself changes. English VTTs are no longer shipped as subtitle tracks. One notion of a track — ids, plain labels, which are kept, how a hit across them is found — lives in `common/lib/captionTracks.ts`.
diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts
@@ -2,7 +2,8 @@ import { NextResponse } from "next/server";
import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken";
import { isValidChannelSlug } from "yt-dlp-transcript-common/controller/channels";
import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { isValidSiteId } from "yt-dlp-transcript-common/lib/site";
+import { isValidSiteId, listSiteIds } from "yt-dlp-transcript-common/lib/site";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
import type { QueueOutcome } from "../../channels/lib/queueForSlugs";
@@ -146,6 +147,77 @@ export function optBool(body: OpsBody, key: string): boolean | undefined {
return v;
}
+// A channel's site memberships, as the Configure form's Sites section posts
+// them: THE WHOLE SET — a site left out is a site the channel leaves, and `[]`
+// is "on no site". Undefined when absent (memberships untouched).
+//
+// STRICTER THAN THE FORM'S PARSER, ON PURPOSE. planSiteMembershipWrites skips a
+// siteId it does not know, because for the form that means the site was deleted
+// since the page loaded. Over HTTP it means a typo, and skipping it would answer
+// `{ ok: true }` about a membership that was never written — the silent success
+// this file's unknown-key rule exists to refuse. Group ids and new group names
+// are still the planner's to judge.
+export type OpsSiteMembership = {
+ siteId: string;
+ groupId?: string;
+ newGroupName?: string;
+};
+
+export function readSiteMemberships(
+ body: OpsBody,
+ key = "sites",
+): OpsSiteMembership[] | undefined {
+ const v = body[key];
+ if (v === undefined) return undefined;
+ if (!Array.isArray(v)) {
+ throw new OpsInputError(
+ `"${key}" must be an array of { siteId, groupId? | newGroupName? } ([] = on no site)`,
+ );
+ }
+ const known = new Set(listSiteIds(getPaths()));
+ const out: OpsSiteMembership[] = [];
+ for (const entry of v) {
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
+ throw new OpsInputError(`each "${key}" entry must be an object with a "siteId"`);
+ }
+ const e = entry as Record<string, unknown>;
+ const stray = Object.keys(e).filter(
+ (k) => !["siteId", "groupId", "newGroupName"].includes(k),
+ );
+ if (stray.length) {
+ throw new OpsInputError(
+ `unknown key(s) in a "${key}" entry: ${stray.join(", ")} — accepted: siteId, groupId, newGroupName`,
+ );
+ }
+ if (typeof e.siteId !== "string" || !isValidSiteId(e.siteId)) {
+ throw new OpsInputError(`"${String(e.siteId)}" is not a valid site id`);
+ }
+ if (!known.has(e.siteId)) {
+ throw new OpsInputError(
+ `no site "${e.siteId}" — known: ${[...known].join(", ") || "none"}`,
+ );
+ }
+ for (const k of ["groupId", "newGroupName"] as const) {
+ if (e[k] !== undefined && typeof e[k] !== "string") {
+ throw new OpsInputError(`"${k}" in a "${key}" entry must be a string`);
+ }
+ }
+ if (e.groupId !== undefined && e.newGroupName !== undefined) {
+ throw new OpsInputError(
+ `a "${key}" entry takes "groupId" or "newGroupName", not both`,
+ );
+ }
+ out.push({
+ siteId: e.siteId,
+ ...(e.groupId !== undefined ? { groupId: e.groupId as string } : {}),
+ ...(e.newGroupName !== undefined
+ ? { newGroupName: e.newGroupName as string }
+ : {}),
+ });
+ }
+ return out;
+}
+
// A whole number above zero, or undefined when absent. A type check, not a
// rule: what the number means is the action's business.
export function optPositiveInt(body: OpsBody, key: string): number | undefined {
diff --git a/editor/app/api/ops/channel-config/route.ts b/editor/app/api/ops/channel-config/route.ts
@@ -5,12 +5,29 @@ import {
channelConfigToFormData,
validateChannelFormPatch,
} from "../../../channels/components/channelConfigToForm";
-import { updateChannelAction } from "../../../channels/actions";
-import { actionResponse, OpsInputError, ops, reqSlug } from "../_lib";
+import {
+ setChannelExclusionsAction,
+ updateChannelAction,
+} from "../../../channels/actions";
+import {
+ actionResponse,
+ OpsInputError,
+ ops,
+ optBool,
+ readSiteMemberships,
+ reqSlug,
+} from "../_lib";
export const dynamic = "force-dynamic";
-// POST { slug: string, patch: { <Configure-form field>: string|number|boolean|null } }
+// POST {
+// slug: string,
+// patch?: { <Configure-form field>: string|number|boolean|null },
+// sites?: [{ siteId, groupId? | newGroupName? }],
+// excludeFromBuild?: boolean,
+// excludeFromCleanup?: boolean,
+// }
+// — at least one of patch / sites / excludeFromBuild / excludeFromCleanup.
//
// The patch keys are the FORM's field names, not ChannelConfig's, because the
// form is what validates them: `downloadFilterInclude` / `downloadFilterExclude`
@@ -19,29 +36,68 @@ export const dynamic = "force-dynamic";
// channel's current form representation instead of being written directly.
//
// `""` (or null) clears a field, exactly as clearing the input does.
+//
+// `sites` is the Configure form's Sites section: the WHOLE membership set, so
+// a site left out is left, and `[]` takes the channel off every site. It rides
+// the same save as the patch, as it does in the form.
+//
+// The two exclusions are the /channels rack's build and cleanup toggles, set
+// to a value rather than flipped, since a caller cannot see the switch.
export async function POST(request: Request) {
- return ops(request, ["slug", "patch"], async (body) => {
- const slug = reqSlug(body, "slug");
- const patch = body.patch;
- if (
- typeof patch !== "object" ||
- patch === null ||
- Array.isArray(patch)
- ) {
- throw new OpsInputError('"patch" must be a JSON object');
- }
- // KEYS FIRST, CHANNEL SECOND. A misspelled field is a fact about the
- // request; reporting "channel not found" for it would hide the real error.
- try {
- validateChannelFormPatch(patch as Record<string, unknown>);
- } catch (e) {
- throw new OpsInputError((e as Error).message);
- }
- const paths = getPaths();
- const existing = await readChannelConfig(paths, slug);
- if (!existing) throw new OpsInputError(`Channel "${slug}" not found`);
- const fd = channelConfigToFormData(existing);
- applyChannelFormPatch(fd, patch as Record<string, unknown>);
- return actionResponse(await updateChannelAction(slug, undefined, fd));
- });
+ return ops(
+ request,
+ ["slug", "patch", "sites", "excludeFromBuild", "excludeFromCleanup"],
+ async (body) => {
+ const slug = reqSlug(body, "slug");
+ const patch = body.patch;
+ if (
+ patch !== undefined &&
+ (typeof patch !== "object" || patch === null || Array.isArray(patch))
+ ) {
+ throw new OpsInputError('"patch" must be a JSON object');
+ }
+ const sites = readSiteMemberships(body);
+ const excludeFromBuild = optBool(body, "excludeFromBuild");
+ const excludeFromCleanup = optBool(body, "excludeFromCleanup");
+ if (
+ patch === undefined &&
+ sites === undefined &&
+ excludeFromBuild === undefined &&
+ excludeFromCleanup === undefined
+ ) {
+ throw new OpsInputError(
+ 'nothing to change — give "patch", "sites", "excludeFromBuild" or "excludeFromCleanup"',
+ );
+ }
+ // KEYS FIRST, CHANNEL SECOND. A misspelled field is a fact about the
+ // request; reporting "channel not found" for it would hide the real error.
+ if (patch !== undefined) {
+ try {
+ validateChannelFormPatch(patch as Record<string, unknown>);
+ } catch (e) {
+ throw new OpsInputError((e as Error).message);
+ }
+ }
+ const paths = getPaths();
+ const existing = await readChannelConfig(paths, slug);
+ if (!existing) throw new OpsInputError(`Channel "${slug}" not found`);
+ if (patch !== undefined || sites !== undefined) {
+ const fd = channelConfigToFormData(existing);
+ if (patch !== undefined) {
+ applyChannelFormPatch(fd, patch as Record<string, unknown>);
+ }
+ if (sites !== undefined) {
+ fd.set("siteMembershipsJson", JSON.stringify(sites));
+ }
+ const saved = await updateChannelAction(slug, undefined, fd);
+ if (saved?.error) return actionResponse(saved);
+ }
+ return actionResponse(
+ await setChannelExclusionsAction(slug, {
+ excludeFromBuild,
+ excludeFromCleanup,
+ }),
+ );
+ },
+ );
}
diff --git a/editor/app/api/ops/channels/route.ts b/editor/app/api/ops/channels/route.ts
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
+import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig";
+import { siteChannelIndex } from "yt-dlp-transcript-common/lib/site";
+import { opsAuth } from "../_lib";
+
+export const dynamic = "force-dynamic";
+
+// GET /api/ops/channels
+//
+// Every channel, one line each: what a caller needs to pick a slug for the
+// other channel routes, and to see which sites carry it — a channel on no site
+// is private to this editor. Config reads only (no report, no media probe, no
+// directory walk); `get channel <slug>` is the deep read of one.
+export async function GET(request: Request) {
+ const denied = opsAuth(request);
+ if (denied) return denied;
+ const paths = getPaths();
+ const [rows, index] = await Promise.all([
+ listChannelConfigs(paths),
+ Promise.resolve(siteChannelIndex(paths)),
+ ]);
+ const sitesOf = new Map<string, string[]>();
+ for (const [siteId, slugs] of Object.entries(index)) {
+ for (const slug of slugs) {
+ sitesOf.set(slug, [...(sitesOf.get(slug) ?? []), siteId]);
+ }
+ }
+ return NextResponse.json({
+ ok: true,
+ channels: rows.map(({ slug, config }) => ({
+ slug,
+ name: config.name ?? slug,
+ kind: isSocialChannel(config) ? "social" : "video",
+ platform: config.platform ?? null,
+ handling: config.handling,
+ url: config.url ?? null,
+ sites: sitesOf.get(slug) ?? [],
+ ...(config.excludeFromBuild ? { excludeFromBuild: true } : {}),
+ ...(config.excludeFromCleanup ? { excludeFromCleanup: true } : {}),
+ })),
+ });
+}
diff --git a/editor/app/api/ops/create-channel/route.ts b/editor/app/api/ops/create-channel/route.ts
@@ -0,0 +1,74 @@
+import { NextResponse } from "next/server";
+import {
+ applyChannelFormPatch,
+ validateChannelFormPatch,
+} from "../../../channels/components/channelConfigToForm";
+import { createChannelFromForm } from "../../../channels/actions";
+import {
+ OpsInputError,
+ opsFail,
+ ops,
+ optBool,
+ readSiteMemberships,
+} from "../_lib";
+
+export const dynamic = "force-dynamic";
+
+// POST {
+// slug?: string, // else derived from fields.name, as the form does
+// fields: { name, handling, url?, platform?, sourceKind?, postFetcher?,
+// socialHandle?, … }, // the New channel form's field names
+// sites?: [{ siteId, groupId? | newGroupName? }], // absent = on no site
+// fetchPlaylist?: boolean, // the form's "Fetch playlist now"
+// fetchPostsNow?: boolean, // the form's "Fetch posts now"
+// prioritizeDownload?: boolean, // the form's "Add to top of auto-queue"
+// }
+//
+// The New channel form, over HTTP. `fields` takes exactly the keys
+// /api/ops/channel-config's `patch` takes (channelConfigToForm.ts), built into
+// the FormData the form would post, and the same parser refuses what the form
+// refuses: no name, a handling that is not "youtube" or "transcribe", a social
+// URL with no derivable handle, a bad filter regex.
+//
+// The three start-something options are OFF unless asked for. The form ticks
+// "Fetch playlist now" by default because a person creating a channel usually
+// wants it; a script creating one says so. Any job they start comes back in
+// `jobIds`, so `--wait` can follow it.
+export async function POST(request: Request) {
+ return ops(
+ request,
+ ["slug", "fields", "sites", "fetchPlaylist", "fetchPostsNow", "prioritizeDownload"],
+ async (body) => {
+ const fields = body.fields;
+ if (typeof fields !== "object" || fields === null || Array.isArray(fields)) {
+ throw new OpsInputError('"fields" must be a JSON object');
+ }
+ try {
+ validateChannelFormPatch(fields as Record<string, unknown>);
+ } catch (e) {
+ throw new OpsInputError((e as Error).message);
+ }
+ const fd = new FormData();
+ applyChannelFormPatch(fd, fields as Record<string, unknown>);
+ if (body.slug !== undefined) {
+ if (typeof body.slug !== "string") {
+ throw new OpsInputError('"slug" must be a string');
+ }
+ fd.set("slug", body.slug.trim());
+ }
+ const sites = readSiteMemberships(body);
+ if (sites) fd.set("siteMembershipsJson", JSON.stringify(sites));
+ for (const flag of ["fetchPlaylist", "fetchPostsNow", "prioritizeDownload"]) {
+ if (optBool(body, flag)) fd.set(flag, "on");
+ }
+ const result = await createChannelFromForm(fd);
+ if (!result.ok) return opsFail(result.error);
+ return NextResponse.json({
+ ok: true,
+ slug: result.slug,
+ jobIds: result.jobIds,
+ ...(result.jobIds.length === 1 ? { jobId: result.jobIds[0] } : {}),
+ });
+ },
+ );
+}
diff --git a/editor/app/api/ops/delete-channel/route.ts b/editor/app/api/ops/delete-channel/route.ts
@@ -0,0 +1,26 @@
+import { NextResponse } from "next/server";
+import { deleteChannelFromForm } from "../../../channels/actions";
+import { opsFail, ops, reqSlug, reqString } from "../_lib";
+
+export const dynamic = "force-dynamic";
+
+// POST { slug: string, confirm: string }
+//
+// The channel page's Danger → Delete, over HTTP: the whole channel directory
+// — every transcript, sidecar, post and media link — goes. `confirm` is the
+// form's "type the slug" box and must equal `slug` exactly; the action is what
+// compares them, and refuses in the form's own sentence. Every other refusal is
+// the form's too: a busy channel, media in transition.
+//
+// There is no undo, and transcripts/ is its own git repo — the only way back
+// is a commit there.
+export async function POST(request: Request) {
+ return ops(request, ["slug", "confirm"], async (body) => {
+ const slug = reqSlug(body, "slug");
+ const fd = new FormData();
+ fd.set("confirmSlug", reqString(body, "confirm"));
+ const result = await deleteChannelFromForm(slug, fd);
+ if (!result.ok) return opsFail(result.error);
+ return NextResponse.json({ ok: true, slug: result.slug, deleted: true });
+ });
+}
diff --git a/editor/app/api/ops/rename-channel/route.test.ts b/editor/app/api/ops/rename-channel/route.test.ts
@@ -0,0 +1,153 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/rename-channel/route.test.ts"
+//
+// The channel lifecycle over ops — rename, delete, create, the membership and
+// exclusion half of channel-config, and the list — against a temp corpus with
+// two social channels and one site: every REFUSAL, which is answered before
+// anything is written. The successful writes end in revalidatePath, which needs
+// the request store a running Next server has and a bare tsx process has not
+// (safeRevalidate.ts); they are driven over HTTP in e2e/ops-api.spec.ts.
+
+const ROOT = await mkdtemp(path.join(os.tmpdir(), "channel-lifecycle-route-"));
+process.env.WORKER_TOKEN = "test-token";
+process.env.TRANSCRIPTS_DIR = ROOT;
+process.env.SETTINGS_FILE = path.join(ROOT, "settings.json");
+
+async function makeSocial(slug: string, handle: string): Promise<void> {
+ const dir = path.join(ROOT, "channels", slug);
+ await mkdir(path.join(dir, "posts"), { recursive: true });
+ await writeFile(
+ path.join(dir, "config.json"),
+ JSON.stringify({
+ handling: "transcribe",
+ sourceKind: "social",
+ platform: "twitter",
+ postFetcher: "x-gallery-dl",
+ socialHandle: handle,
+ name: `${handle} (X)`,
+ url: `https://x.com/${handle}`,
+ }),
+ );
+}
+await makeSocial("misspeled-x", "example_user");
+await makeSocial("other-x", "other_user");
+await mkdir(path.join(ROOT, "sites", "demo-site"), { recursive: true });
+await writeFile(
+ path.join(ROOT, "sites", "demo-site", "site.json"),
+ JSON.stringify({ siteId: "demo-site", siteTitle: "Demo", channels: [] }),
+);
+
+const rename = (await import("./route")).POST;
+const del = (await import("../delete-channel/route")).POST;
+const create = (await import("../create-channel/route")).POST;
+const config = (await import("../channel-config/route")).POST;
+const list = (await import("../channels/route")).GET;
+test.after(() => rm(ROOT, { recursive: true, force: true }));
+
+type Res = { status: number; json: Record<string, unknown> };
+async function call(
+ handler: (r: Request) => Promise<Response>,
+ body?: Record<string, unknown>,
+): Promise<Res> {
+ const res = await handler(
+ new Request("http://localhost/api/ops/x", {
+ method: body ? "POST" : "GET",
+ headers: {
+ authorization: "Bearer test-token",
+ "content-type": "application/json",
+ },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ }),
+ );
+ return { status: res.status, json: (await res.json()) as Record<string, unknown> };
+}
+const exists = (p: string) => access(p).then(() => true, () => false);
+const readSite = async () =>
+ JSON.parse(await readFile(path.join(ROOT, "sites", "demo-site", "site.json"), "utf8")) as {
+ channels: { slug: string }[];
+ };
+
+test("rename refuses a taken slug and an invalid one, in the form's sentences", async () => {
+ const taken = await call(rename, { slug: "misspeled-x", newSlug: "other-x" });
+ assert.equal(taken.status, 400);
+ assert.equal(taken.json.error, 'Channel "other-x" already exists');
+ const bad = await call(rename, { slug: "misspeled-x", newSlug: "../escape" });
+ assert.equal(bad.status, 400);
+ assert.match(String(bad.json.error), /not a valid channel slug/);
+ const missing = await call(rename, { slug: "nope", newSlug: "nope-2" });
+ assert.equal(missing.status, 400);
+ assert.equal(missing.json.error, 'Channel "nope" not found');
+});
+
+test("the list names every channel, its kind and its sites", async () => {
+ const listed = await call(list);
+ assert.equal(listed.status, 200);
+ const rows = listed.json.channels as { slug: string; sites: string[]; kind: string }[];
+ assert.deepEqual(rows.map((c) => c.slug).sort(), ["misspeled-x", "other-x"]);
+ assert.ok(rows.every((c) => c.kind === "social" && c.sites.length === 0));
+});
+
+test("channel-config refuses an unknown site and a malformed membership", async () => {
+ const unknown = await call(config, { slug: "misspeled-x", sites: [{ siteId: "no-such-site" }] });
+ assert.equal(unknown.status, 400);
+ assert.equal(unknown.json.error, 'no site "no-such-site" — known: demo-site');
+ const stray = await call(config, { slug: "misspeled-x", sites: [{ siteId: "demo-site", group: "a" }] });
+ assert.equal(stray.status, 400);
+ assert.match(String(stray.json.error), /unknown key\(s\) in a "sites" entry: group/);
+ const notArray = await call(config, { slug: "misspeled-x", sites: "demo-site" });
+ assert.equal(notArray.status, 400);
+ assert.match(String(notArray.json.error), /"sites" must be an array/);
+ // Nothing was written.
+ assert.deepEqual((await readSite()).channels, []);
+});
+
+test("channel-config refuses an empty body and a non-boolean exclusion", async () => {
+ const none = await call(config, { slug: "misspeled-x" });
+ assert.equal(none.status, 400);
+ assert.match(String(none.json.error), /nothing to change/);
+ const notBool = await call(config, { slug: "misspeled-x", excludeFromBuild: "yes" });
+ assert.equal(notBool.status, 400);
+ assert.equal(notBool.json.error, '"excludeFromBuild" must be a boolean');
+ const cfg = JSON.parse(
+ await readFile(path.join(ROOT, "channels", "misspeled-x", "config.json"), "utf8"),
+ );
+ assert.equal(cfg.excludeFromBuild, undefined);
+});
+
+test("create refuses what the New form refuses, before any channel exists", async () => {
+ const noName = await call(create, { fields: { handling: "transcribe" } });
+ assert.equal(noName.status, 400);
+ assert.equal(noName.json.error, "Name is required");
+ const badKey = await call(create, { fields: { name: "X", handlng: "youtube" } });
+ assert.equal(badKey.status, 400);
+ assert.match(String(badKey.json.error), /"handlng" is not a channel config field/);
+ const taken = await call(create, {
+ slug: "other-x",
+ fields: { name: "Other", handling: "transcribe" },
+ });
+ assert.equal(taken.status, 400);
+ assert.equal(taken.json.error, 'Channel "other-x" already exists');
+ const unknownSite = await call(create, {
+ slug: "made-x",
+ fields: { name: "Made", handling: "transcribe" },
+ sites: [{ siteId: "nope" }],
+ });
+ assert.equal(unknownSite.status, 400);
+ assert.equal(await exists(path.join(ROOT, "channels", "made-x")), false);
+});
+
+test("delete needs the slug repeated, and refuses before touching anything", async () => {
+ const wrong = await call(del, { slug: "other-x", confirm: "other" });
+ assert.equal(wrong.status, 400);
+ assert.equal(wrong.json.error, 'Type the channel slug "other-x" exactly to confirm deletion');
+ const missing = await call(del, { slug: "other-x" });
+ assert.equal(missing.status, 400);
+ assert.match(String(missing.json.error), /"confirm" is required/);
+ assert.equal(await exists(path.join(ROOT, "channels", "other-x", "config.json")), true);
+});
diff --git a/editor/app/api/ops/rename-channel/route.ts b/editor/app/api/ops/rename-channel/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server";
+import { renameChannelFromForm } from "../../../channels/actions";
+import { opsFail, ops, reqSlug } from "../_lib";
+
+export const dynamic = "force-dynamic";
+
+// POST { slug: string, newSlug: string }
+//
+// The channel page's Danger → Rename, over HTTP. The form makes the operator
+// type the current slug to confirm; here the body naming `slug` IS that
+// confirmation, so it is passed through as the typed value and the action
+// applies every other rule unchanged — a busy channel (a running job, a lane
+// unit writing into data/, media in transition), a taken or invalid new slug.
+//
+// Answers { ok, slug: <new slug>, warnings }. A warning is a non-fatal
+// metadata-migration problem after the directory moved: the form can only log
+// it, and this can say it.
+export async function POST(request: Request) {
+ return ops(request, ["slug", "newSlug"], async (body) => {
+ const slug = reqSlug(body, "slug");
+ const newSlug = reqSlug(body, "newSlug");
+ const fd = new FormData();
+ fd.set("confirmSlug", slug);
+ fd.set("newSlug", newSlug);
+ const result = await renameChannelFromForm(slug, fd);
+ if (!result.ok) return opsFail(result.error);
+ return NextResponse.json({
+ ok: true,
+ slug: result.slug,
+ from: slug,
+ warnings: result.warnings,
+ });
+ });
+}
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -204,6 +204,44 @@ export default async function ChannelDetailPage({
...(forumSession ? { forumSession } : {}),
}}
/>
+ {/* THE SAME DANGER ZONE A VIDEO CHANNEL HAS, which a social channel
+ used to lack entirely: this branch returns before the stage list,
+ so a misspelled X channel could not be renamed from the editor. The
+ forms, actions and busy guard are the video page's own; collapsed,
+ because it is a chore and not this page's subject, and opened by
+ the same `?stage=danger` link a video channel answers to. */}
+ <details
+ data-social-danger=""
+ open={sp.stage === "danger"}
+ className="rounded-lg border border-border bg-card p-4"
+ >
+ <summary className="cursor-pointer text-sm font-medium text-destructive">
+ Danger zone
+ </summary>
+ <div className="mt-3 flex flex-col gap-4">
+ <RenameChannelForm
+ slug={slug}
+ busyReason={channelMediaBusyReason(slug, "renaming it")}
+ action={
+ renameChannelAction.bind(null, slug) as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ />
+ <hr className="border-border" />
+ <DeleteChannelForm
+ slug={slug}
+ busyReason={channelMediaBusyReason(slug, "deleting it")}
+ action={
+ deleteChannelAction.bind(null, slug) as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ />
+ </div>
+ </details>
</div>
);
}
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -182,27 +182,53 @@ export async function probeChannelUrlAction(
}
}
+// What a create / rename / delete did, without the redirect. The form actions
+// below call these and redirect on `ok`; /api/ops calls them and answers with
+// JSON, because a route handler cannot follow a redirect() it did not ask for.
+// ONE body each, so the browser and an ops caller get the same refusal in the
+// same sentence (the ops layer's whole rule, api/ops/_lib.ts).
+export type ChannelLifecycleResult =
+ | {
+ ok: true;
+ slug: string;
+ // Jobs the step started on the way (create's first playlist or post
+ // fetch), so an ops caller can follow them.
+ jobIds: string[];
+ // Non-fatal problems after the move (rename's metadata migration).
+ warnings: string[];
+ }
+ | ({ ok: false } & NonNullable<FormErrorState>);
+
export async function createChannelAction(
_prev: ActionResult,
formData: FormData,
): Promise<ActionResult> {
+ const result = await createChannelFromForm(formData);
+ if (!result.ok) return { error: result.error, values: result.values };
+ redirect(`/channels/${result.slug}`);
+}
+
+export async function createChannelFromForm(
+ formData: FormData,
+): Promise<ChannelLifecycleResult> {
const values = formValues(formData);
+ const fail = (error: string) => ({ ok: false as const, error, values });
+ const jobIds: string[] = [];
let parsed;
try {
parsed = parseChannelForm(formData);
} catch (e) {
- return { error: (e as Error).message, values };
+ return fail((e as Error).message);
}
const { name, config } = parsed;
const slug = parsed.slug || slugify(name);
if (!slug) {
- return { error: "Could not derive a slug from the name", values };
+ return fail("Could not derive a slug from the name");
}
if (!isValidChannelSlug(slug)) {
- return {
- error: `"${slug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`,
- values,
- };
+ return fail(
+ `"${slug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`,
+ );
}
const paths = getPaths();
// Validate + plan the site-membership writes from the form's Sites section
@@ -218,10 +244,10 @@ export async function createChannelAction(
siteWrites = planSiteMembershipWrites(paths, slug, requests);
}
} catch (e) {
- return { error: (e as Error).message, values };
+ return fail((e as Error).message);
}
if (await channelExists(paths, slug)) {
- return { error: `Channel "${slug}" already exists`, values };
+ return fail(`Channel "${slug}" already exists`);
}
await createChannel(paths, slug, config);
// THE COMPILED TREES NAME THEIR CHANNELS. A channel created since the last
@@ -238,10 +264,9 @@ export async function createChannelAction(
await applySiteWrites(siteWrites, paths);
} catch (e) {
// The channel itself was created; don't redirect as if nothing happened.
- return {
- error: `Channel "${slug}" was created, but updating site memberships failed: ${(e as Error).message}. Open its Configure panel to retry.`,
- values,
- };
+ return fail(
+ `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
@@ -251,7 +276,10 @@ export async function createChannelAction(
if (config.url && formData.get("fetchPlaylist") != null) {
try {
const res = await storePlaylistAction(slug);
- if (res.ok) void res.stream.cancel();
+ if (res.ok) {
+ void res.stream.cancel();
+ jobIds.push(res.jobId);
+ }
} catch {
/* best-effort — the channel was created regardless */
}
@@ -262,7 +290,10 @@ export async function createChannelAction(
if (config.url && formData.get("fetchPostsNow") != null) {
try {
const res = await fetchPostsAction(slug);
- if (res.ok) void res.stream.cancel();
+ if (res.ok) {
+ void res.stream.cancel();
+ jobIds.push(res.jobId);
+ }
} catch {
/* best-effort — the channel was created regardless */
}
@@ -283,7 +314,7 @@ export async function createChannelAction(
revalidatePath("/channels");
revalidatePath("/sites");
revalidatePath("/");
- redirect(`/channels/${slug}`);
+ return { ok: true, slug, jobIds, warnings: [] };
}
export async function updateChannelAction(
@@ -453,6 +484,29 @@ export async function toggleChannelBuildInclusionAction(
return undefined;
}
+// SET, not toggle: the two exclusions above to a stated value, for a caller
+// that cannot see the current one before it clicks (/api/ops/channel-config).
+// `undefined` leaves a flag alone; `false` removes the key, as the toggles do.
+export async function setChannelExclusionsAction(
+ slug: string,
+ flags: { excludeFromBuild?: boolean; excludeFromCleanup?: boolean },
+): Promise<ActionResult> {
+ const paths = getPaths();
+ const existing = await readChannelConfig(paths, slug);
+ if (!existing) return { error: `Channel "${slug}" not found` };
+ const set: { excludeFromBuild?: true; excludeFromCleanup?: true } = {};
+ const unset: ("excludeFromBuild" | "excludeFromCleanup")[] = [];
+ for (const key of ["excludeFromBuild", "excludeFromCleanup"] as const) {
+ if (flags[key] === true) set[key] = true;
+ else if (flags[key] === false) unset.push(key);
+ }
+ if (Object.keys(set).length === 0 && unset.length === 0) return undefined;
+ await patchChannelConfig(paths, slug, set, { unset });
+ revalidatePath("/channels");
+ revalidatePath("/cleanup");
+ return undefined;
+}
+
// Toggle whether this channel's reclaimable bytes count toward the aggregate
// "cleanable data" total on the /cleanup page (and its sidebar badge). The
// cleanup sweeps themselves stay available regardless; this only flips the
@@ -565,13 +619,20 @@ export async function deleteChannelAction(
_prev: ActionResult,
formData: FormData,
): Promise<ActionResult> {
+ const result = await deleteChannelFromForm(slug, formData);
+ if (!result.ok) return { error: result.error, values: result.values };
+ redirect("/channels");
+}
+
+export async function deleteChannelFromForm(
+ slug: string,
+ formData: FormData,
+): Promise<ChannelLifecycleResult> {
const values = formValues(formData);
+ const fail = (error: string) => ({ ok: false as const, error, values });
const confirm = String(formData.get("confirmSlug") ?? "").trim();
if (confirm !== slug) {
- return {
- error: `Type the channel slug "${slug}" exactly to confirm deletion`,
- values,
- };
+ return fail(`Type the channel slug "${slug}" exactly to confirm deletion`);
}
// THE RENAME'S GUARD, AND DELETE NEEDED IT MORE. Renaming while a job runs
// orphans a registry entry keyed by the old slug; DELETING while one runs
@@ -581,18 +642,18 @@ export async function deleteChannelAction(
// races the writer for the tree and whichever loses reports an ENOENT nobody
// asked about.
const busy = channelMediaBusyReason(slug, "deleting it");
- if (busy) return { error: busy, values };
+ if (busy) return fail(busy);
// THE OTHER REFUSAL REACHES THE FORM THE SAME WAY. `deleteChannel` THROWS
// when `.relocating.json` is present — media in transition is not a channel
// anyone may delete — and an uncaught throw from a server action is a
// digest-shaped error page, not the sentence above it. Both refusals are
// refusals; they belong in the same place, on the same form, in the
- // operator's words. The `redirect` below stays OUTSIDE this: it throws
- // NEXT_REDIRECT as its control flow and a catch here would swallow it.
+ // operator's words. (The `redirect` is the caller's, OUTSIDE this: it
+ // throws NEXT_REDIRECT as its control flow and a catch here would swallow it.)
try {
await deleteChannel(getPaths(), slug);
} catch (e) {
- return { error: (e as Error).message, values };
+ return fail((e as Error).message);
}
// Same reason as createChannelAction: the deleted channel keeps a leaf in
// every compiled tree until something recompiles. A leaf matching nothing is
@@ -604,7 +665,7 @@ export async function deleteChannelAction(
}
revalidatePath("/channels");
revalidatePath("/");
- redirect("/channels");
+ return { ok: true, slug, jobIds: [], warnings: [] };
}
// Change a channel's slug (its on-disk directory name). High-friction: the
@@ -618,32 +679,38 @@ export async function renameChannelAction(
_prev: ActionResult,
formData: FormData,
): Promise<ActionResult> {
+ const result = await renameChannelFromForm(oldSlug, formData);
+ if (!result.ok) return { error: result.error, values: result.values };
+ redirect(`/channels/${result.slug}`);
+}
+
+export async function renameChannelFromForm(
+ oldSlug: string,
+ formData: FormData,
+): Promise<ChannelLifecycleResult> {
const values = formValues(formData);
+ const fail = (error: string) => ({ ok: false as const, error, values });
const confirm = String(formData.get("confirmSlug") ?? "").trim();
if (confirm !== oldSlug) {
- return {
- error: `Type the channel slug "${oldSlug}" exactly to confirm the rename`,
- values,
- };
+ return fail(`Type the channel slug "${oldSlug}" exactly to confirm the rename`);
}
const newSlug = String(formData.get("newSlug") ?? "").trim();
if (!newSlug) {
- return { error: "Enter a new slug", values };
+ return fail("Enter a new slug");
}
if (newSlug === oldSlug) {
- return { error: "The new slug is the same as the current one", values };
+ return fail("The new slug is the same as the current one");
}
if (!isValidChannelSlug(newSlug)) {
- return {
- error: `"${newSlug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`,
- values,
- };
+ return fail(
+ `"${newSlug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`,
+ );
}
const paths = getPaths();
const config = await readChannelConfig(paths, oldSlug);
- if (!config) return { error: `Channel "${oldSlug}" not found`, values };
+ if (!config) return fail(`Channel "${oldSlug}" not found`);
if (await channelExists(paths, newSlug)) {
- return { error: `Channel "${newSlug}" already exists`, values };
+ return fail(`Channel "${newSlug}" already exists`);
}
// THE REGISTRY IS HALF THE TRUTH, and this check used to be the other half's
@@ -654,13 +721,13 @@ export async function renameChannelAction(
// under it. One question, one answer, the same sentence the Storage panel
// and the bulk move say.
const busy = channelMediaBusyReason(oldSlug, "renaming it");
- if (busy) return { error: busy, values };
+ if (busy) return fail(busy);
let result;
try {
result = await renameChannel(paths, oldSlug, newSlug, config);
} catch (e) {
- return { error: (e as Error).message, values };
+ return fail((e as Error).message);
}
// THE PRIORITY DOCUMENT KEYS BY SLUG, so it has to follow the rename or the
// channel's tier, rank and per-operation overrides stay under a slug that no
@@ -685,7 +752,8 @@ export async function renameChannelAction(
);
}
// The directory move succeeded; any warnings are non-fatal metadata-migration
- // problems. Log them (we redirect on success, so there's no UI to show them).
+ // problems. Log them (the form redirects on success, so there's no UI to show
+ // them) and return them, for an ops caller, which can.
if (result.warnings.length > 0) {
console.warn(
`Channel rename ${oldSlug} -> ${newSlug} completed with warnings:`,
@@ -694,7 +762,7 @@ export async function renameChannelAction(
}
revalidatePath("/channels");
revalidatePath("/");
- redirect(`/channels/${newSlug}`);
+ return { ok: true, slug: newSlug, jobIds: [], warnings: result.warnings };
}
// ---------------------------------------------------------------------------
diff --git a/editor/e2e/channel-rename.spec.ts b/editor/e2e/channel-rename.spec.ts
@@ -1,3 +1,4 @@
+import { mkdir, writeFile } from "node:fs/promises";
import { test, expect, type Page } from "@playwright/test";
import { baseUrl } from "./baseUrl";
import {
@@ -5,6 +6,8 @@ import {
generateReport,
readJson,
resetData,
+ resolvePath,
+ writeChannelConfig,
writeSite,
} from "./helpers";
@@ -58,6 +61,60 @@ test("rename requires the exact current slug and then moves the channel", async
expect(site.channels.map((c) => c.slug)).toEqual([NEW]);
});
+// A SOCIAL CHANNEL HAS THE SAME DANGER ZONE. Its page returns before the stage
+// list a video channel gets, so it used to have no Rename at all — a misspelled
+// X channel could only be renamed by hand. The zone is collapsed under the posts
+// panel and opens on the same `?stage=danger` link.
+test("a social channel renames from its Danger zone and keeps its posts", async ({
+ page,
+}) => {
+ await resetData("empty");
+ const OLD_X = "misspeled-x";
+ const NEW_X = "misspelled-x";
+ await writeChannelConfig(OLD_X, {
+ handling: "transcribe",
+ sourceKind: "social",
+ platform: "twitter",
+ postFetcher: "x-gallery-dl",
+ socialHandle: "faketester",
+ name: "Fake Tester (X)",
+ url: "https://x.com/faketester",
+ });
+ const posts = resolvePath(`test-transcripts/channels/${OLD_X}/posts`);
+ await mkdir(posts, { recursive: true });
+ await writeFile(
+ `${posts}/2026-05.jsonl`,
+ JSON.stringify({
+ id: "1001",
+ slug: `${OLD_X}/1001`,
+ channelSlug: OLD_X,
+ author: "faketester",
+ createdAt: "2026-05-01T00:00:00.000Z",
+ uploadDate: "20260501",
+ text: "a post",
+ url: "https://x.com/faketester/status/1001",
+ platform: "x",
+ isReply: false,
+ isRepost: false,
+ links: [],
+ }) + "\n",
+ );
+
+ // Collapsed on a plain visit, open on the stage link.
+ await page.goto(`/channels/${OLD_X}`);
+ await expect(page.getByText("Danger zone")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Rename channel" })).toBeHidden();
+ await page.goto(channelStage(OLD_X, "danger"));
+ await page.getByLabel("new slug").fill(NEW_X);
+ await page.getByLabel("confirm current slug").fill(OLD_X);
+ await page.getByRole("button", { name: "Rename channel" }).click();
+ await expect(page).toHaveURL(new RegExp(`/channels/${NEW_X}`));
+ // Still a social channel, with its one post.
+ await expect(page.locator("[data-social-panel]")).toContainText("Archived posts");
+ await expect(page.locator("[data-social-panel]")).toContainText("1");
+ expect((await page.request.get(`${baseUrl}/channels/${OLD_X}`)).status()).toBe(404);
+});
+
// RENAMING AND DELETING ARE REFUSED WHILE SOMETHING IS WRITING INTO data/.
//
// The rename always asked, but it asked the JOB REGISTRY alone — and the
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -400,6 +400,92 @@ test("capture-posts refuses what it cannot capture, and an id not in the archive
expect(await listJobIds()).toEqual(before);
});
+// THE CHANNEL'S LIFECYCLE, OVER HTTP. create-channel is the New form,
+// rename-channel and delete-channel the Danger zone's two forms, channel-config
+// grows the Configure form's Sites section and the rack's two exclusion
+// switches, and `get channels` is the list a caller picks a slug from. Each is
+// the form's own action (actions.ts' *FromForm cores), so the files on disk are
+// what the browser would have left.
+test("a channel's lifecycle over ops: create on a site, list, re-site, rename, delete", async ({
+ request,
+}) => {
+ test.setTimeout(120_000);
+ await resetData("empty");
+ await settings();
+ await writeSite("mysite", { channels: [] });
+ const SITE = "test-transcripts/sites/mysite/site.json";
+ const list = async () => {
+ const res = await request.get(`${baseUrl}/api/ops/channels`, { headers: AUTH });
+ expect(res.status()).toBe(200);
+ return ((await res.json()) as {
+ channels: { slug: string; kind: string; sites: string[]; excludeFromBuild?: true }[];
+ }).channels;
+ };
+
+ const made = await ops(request, "create-channel", {
+ slug: "made-x",
+ fields: {
+ name: "Made (X)",
+ handling: "transcribe",
+ url: "https://x.com/made_user",
+ sourceKind: "social",
+ postFetcher: "x-gallery-dl",
+ },
+ sites: [{ siteId: "mysite" }],
+ });
+ expect(made.body).toEqual({ ok: true, slug: "made-x", jobIds: [] });
+ const cfg = await readJson<Record<string, unknown>>(
+ "test-transcripts/channels/made-x/config.json",
+ );
+ expect(cfg.sourceKind).toBe("social");
+ expect(cfg.socialHandle).toBe("made_user");
+ expect(
+ (await readJson<{ channels: { slug: string }[] }>(SITE)).channels.map((c) => c.slug),
+ ).toEqual(["made-x"]);
+ expect(await list()).toEqual([
+ { slug: "made-x", name: "Made (X)", kind: "social", platform: "twitter",
+ handling: "transcribe", url: "https://x.com/made_user", sites: ["mysite"] },
+ ]);
+
+ // Off every site, and out of the build — set, so a second call is a no-op.
+ for (let i = 0; i < 2; i++) {
+ const res = await ops(request, "channel-config", {
+ slug: "made-x",
+ sites: [],
+ excludeFromBuild: true,
+ });
+ expect(res.body).toEqual({ ok: true });
+ }
+ expect((await readJson<{ channels: unknown[] }>(SITE)).channels).toEqual([]);
+ const [row] = await list();
+ expect(row.sites).toEqual([]);
+ expect(row.excludeFromBuild).toBe(true);
+
+ const renamed = await ops(request, "rename-channel", {
+ slug: "made-x",
+ newSlug: "made-renamed-x",
+ });
+ expect(renamed.status).toBe(200);
+ expect((renamed.body as { slug?: string }).slug).toBe("made-renamed-x");
+ expect((await list()).map((c) => c.slug)).toEqual(["made-renamed-x"]);
+ expect(await pathExists("test-transcripts/channels/made-x")).toBe(false);
+
+ const refused = await ops(request, "delete-channel", {
+ slug: "made-renamed-x",
+ confirm: "made-x",
+ });
+ expect(refused.status).toBe(400);
+ expect(refused.body.error).toBe(
+ 'Type the channel slug "made-renamed-x" exactly to confirm deletion',
+ );
+ const gone = await ops(request, "delete-channel", {
+ slug: "made-renamed-x",
+ confirm: "made-renamed-x",
+ });
+ expect(gone.status).toBe(200);
+ expect(await list()).toEqual([]);
+});
+
test("channel-config round-trips a download filter and refuses a bad regex", async ({
page,
request,
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -11,6 +11,7 @@
// pnpm ops <action> [--json '<body>' | --file <path>] [--wait]
// [--wait-timeout <seconds>] [--quiet]
// pnpm ops get channel <slug> [--counts]
+// pnpm ops get channels
// pnpm ops get tags [<tagId>]
// pnpm ops list
//
@@ -31,6 +32,11 @@
// pnpm ops import-video --json '{"slug":"demo-archive","url":"https://archive.org/details/example-item"}'
// pnpm ops import-archive-org --json '{"slug":"demo-archive","item":"example-item","match":"\\.mp4$"}' --wait
// pnpm ops channel-config --json '{"slug":"x","patch":{"downloadFilterExclude":"rerun"}}'
+// pnpm ops channel-config --json '{"slug":"x","sites":[{"siteId":"anilyzer"}]}'
+// pnpm ops channel-config --json '{"slug":"x","sites":[],"excludeFromBuild":true}'
+// pnpm ops create-channel --json '{"fields":{"name":"Example (X)","handling":"transcribe","url":"https://x.com/example"}}'
+// pnpm ops rename-channel --json '{"slug":"old-slug","newSlug":"new-slug"}'
+// pnpm ops delete-channel --json '{"slug":"x","confirm":"x"}'
// pnpm ops channel-priority --json '{"slugs":["x"],"operation":"download","tier":"paused"}'
// pnpm ops lane --json '{"lane":"download","held":true}'
// pnpm ops refresh-report --json '{"all":true}'
@@ -46,6 +52,7 @@
// pnpm ops reports-prepare --json '{"siteId":"demo-site"}' --wait
// pnpm ops reports-export --json '{"siteId":"demo-site","formats":["html","md"]}' --wait
// pnpm ops get channel the-quartering
+// pnpm ops get channels
// pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}'
// pnpm ops tag-videos --file ids.json
// pnpm ops persist-videos --file list.json --wait
@@ -85,6 +92,9 @@ const GETTERS = {
// opt-in for the same reason the route makes it opt-in.
channel: (slug, counts) =>
`/api/ops/channel/${encodeURIComponent(slug)}${counts ? "?counts=1" : ""}`,
+ // Every channel, one line each: slug, name, kind, platform and the sites
+ // that carry it ([] = private to this editor).
+ channels: () => "/api/ops/channels",
// No argument: every definition with its pin/suppression counts. With one: a
// single tag's assignments, each carrying the provenance of the pin.
tags: (tag) =>
@@ -93,11 +103,16 @@ const GETTERS = {
// Nouns whose read takes no argument. `get channel` without a slug is a
// mistake; `get tags` without one is the whole vocabulary.
-const GET_ARG_OPTIONAL = new Set(["tags"]);
+const GET_ARG_OPTIONAL = new Set(["tags", "channels"]);
const ACTIONS = [
"channel-priority",
"channel-config",
+ // The channel's lifecycle: the New channel form, and the channel page's
+ // Danger → Rename and Danger → Delete. Synchronous; no job.
+ "create-channel",
+ "rename-channel",
+ "delete-channel",
"metadata-scan",
// ONE video's metadata.info.json re-read from its source ({slug, id}): no
// subtitles, no media; the rewrite lands in metadata.history.json.
@@ -324,6 +339,7 @@ export function usage() {
"Usage: pnpm ops <action> [--json '<body>' | --file <path>] [--wait]",
" [--wait-timeout <seconds>] [--quiet]",
" pnpm ops get channel <slug> [--counts]",
+ " pnpm ops get channels",
" pnpm ops get tags [<tagId>]",
" pnpm ops list",
"",
@@ -347,6 +363,27 @@ export function usage() {
' report.md and evidence-pack.zip for the site\'s build to publish:',
' {"siteId", "reportId"?, "formats"?: ["html","pdf","md","zip"]}.',
"",
+ 'channel-config changes a channel as its Configure form does: {"slug"} and',
+ ' any of "patch" (form field names; "" clears one), "sites" (the WHOLE',
+ ' membership set: [{"siteId", "groupId"? | "newGroupName"?}], [] = on no',
+ ' site; an unknown site id is refused), "excludeFromBuild" and',
+ ' "excludeFromCleanup" (set to the value given, not toggled).',
+ "",
+ 'create-channel is the New channel form: {"fields": {"name", "handling":',
+ ' "youtube"|"transcribe", "url"?, "platform"?, "sourceKind"?, "postFetcher"?,',
+ ' "socialHandle"?, …}} with channel-config\'s patch keys; "slug"? (else',
+ ' derived from the name), "sites"? (absent = on no site). "fetchPlaylist",',
+ ' "fetchPostsNow" and "prioritizeDownload" are the form\'s checkboxes, OFF',
+ ' unless true; a job they start comes back as jobId(s), so --wait follows it.',
+ "",
+ 'rename-channel moves a channel to a new slug, as Danger → Rename does:',
+ ' {"slug", "newSlug"}. Refused while the channel is busy (a job, a lane unit,',
+ ' media in transition) or when the new slug is taken. Old links break.',
+ "",
+ 'delete-channel removes a channel\'s whole directory, as Danger → Delete does:',
+ ' {"slug", "confirm"} — "confirm" must repeat the slug. No undo outside the',
+ ' transcripts/ repo\'s own history.',
+ "",
'refresh-metadata re-reads ONE video\'s metadata.info.json from its source',
' (no subtitles, no media) on the platform\'s queue: {"slug", "id"}. The',
' job\'s log ends with what the source now says — live_status, formats,',