commit 5f3746a513c4a372a65fba7f26adb9039c8cc312
parent 9f75883f43f3ec2ceeb9d67fa6a0f92e83486b66
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 3 Jul 2026 03:13:59 -0400
Merge feat/channel-rename: change a channel's slug from the Danger zone
Full migration of every slug-keyed store (channel dir, saved-video store +
pointers, site memberships, scheduler state, bookmarks); type-to-confirm
friction; blocked while jobs run.
Diffstat:
11 files changed, 582 insertions(+), 10 deletions(-)
diff --git a/common/controller/channels.ts b/common/controller/channels.ts
@@ -21,6 +21,19 @@ export type ChannelStat = {
downloadCount: number;
};
+// A channel slug is also its directory name under transcripts/channels/, so it
+// must be a safe single path segment. Channel slugs are mixed-case (e.g.
+// "HasanAbiVODs3") — unlike site ids — so this is broader than SITE_ID_RE, but
+// it still forbids slashes, dots-only names, and leading punctuation, which
+// blocks path traversal (no "/", no "..").
+export const CHANNEL_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
+
+export function isValidChannelSlug(slug: unknown): slug is string {
+ return (
+ typeof slug === "string" && slug !== ".." && CHANNEL_SLUG_RE.test(slug)
+ );
+}
+
async function exists(p: string): Promise<boolean> {
try {
await stat(p);
diff --git a/common/controller/renameChannel.test.ts b/common/controller/renameChannel.test.ts
@@ -0,0 +1,159 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import type { Paths } from "../lib/paths";
+import type { ChannelConfig } from "../lib/channelConfig";
+import {
+ channelExists,
+ readChannelConfig,
+ writeChannelConfig,
+} from "./channels";
+import { savedVideoDir } from "../lib/savedVideo";
+import {
+ persistSourceVideo,
+ resolveSavedVideo,
+} from "../lib/savedVideo-server";
+import { getSite } from "../lib/site";
+import {
+ emptyChannelSyncState,
+ readSchedulerState,
+ writeSchedulerState,
+} from "../jobs/syncSchedulerState";
+import { readBookmarks } from "../jobs/bookmarks";
+import { renameChannel } from "./renameChannel";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/renameChannel.test.ts
+
+async function withPaths(fn: (paths: Paths) => Promise<void>): Promise<void> {
+ const dir = await mkdtemp(path.join(tmpdir(), "ttb-rename-"));
+ const paths = {
+ channelsDir: path.join(dir, "channels"),
+ savedVideosDir: path.join(dir, "saved"),
+ sitesDir: path.join(dir, "sites"),
+ schedulerStateFile: path.join(dir, ".scheduler", "state.json"),
+ bookmarksFile: path.join(dir, ".bookmarks", "bookmarks.json"),
+ } as Paths;
+ try {
+ await fn(paths);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+const config: ChannelConfig = { handling: "youtube", name: "Old Name" };
+
+// Seed a channel "old" with one video that has a persisted saved source, plus a
+// site membership, a scheduler backoff entry, and a bookmark — all keyed by the
+// old slug.
+async function seedOld(paths: Paths): Promise<void> {
+ await writeChannelConfig(paths, "old", config);
+ const videoDir = path.join(paths.channelsDir, "old", "data", "vid1");
+ await mkdir(videoDir, { recursive: true });
+ await writeFile(
+ path.join(videoDir, "metadata.info.json"),
+ JSON.stringify({ id: "vid1", title: "A video" }),
+ );
+ await writeFile(path.join(videoDir, "source-media.mp4"), "SOURCEBYTES");
+ await persistSourceVideo({
+ videoDir,
+ sourceFilename: "source-media.mp4",
+ storeDir: savedVideoDir(paths, config, "old", "vid1"),
+ keepReason: "pin",
+ });
+
+ await mkdir(path.join(paths.sitesDir, "mysite"), { recursive: true });
+ await writeFile(
+ path.join(paths.sitesDir, "mysite", "site.json"),
+ JSON.stringify({ channels: [{ slug: "old" }] }),
+ );
+
+ const state = await readSchedulerState(paths);
+ state.channels["old"] = { ...emptyChannelSyncState(), consecutiveFailures: 5 };
+ await writeSchedulerState(paths, state);
+
+ await mkdir(path.dirname(paths.bookmarksFile), { recursive: true });
+ await writeFile(
+ paths.bookmarksFile,
+ JSON.stringify({
+ v: 1,
+ bookmarks: [
+ {
+ id: "bm1",
+ name: "whisper-all · old",
+ spec: { kind: "whisper-all", slug: "old" },
+ createdAt: 1,
+ },
+ ],
+ }),
+ );
+}
+
+test("renameChannel migrates every slug-keyed store", async () => {
+ await withPaths(async (paths) => {
+ await seedOld(paths);
+
+ const result = await renameChannel(paths, "old", "new", config);
+ assert.deepEqual(result.warnings, []);
+
+ // Channel directory moved.
+ assert.equal(await channelExists(paths, "old"), false);
+ assert.equal(await channelExists(paths, "new"), true);
+ assert.equal((await readChannelConfig(paths, "new"))?.name, "Old Name");
+ await stat(path.join(paths.channelsDir, "new", "data", "vid1", "metadata.info.json"));
+
+ // Saved-video store moved and the pointer rewritten so it resolves.
+ const newVideoDir = path.join(paths.channelsDir, "new", "data", "vid1");
+ const resolved = await resolveSavedVideo(newVideoDir);
+ assert.equal(
+ resolved,
+ path.join(paths.savedVideosDir, "new", "vid1", "source-media.mp4"),
+ );
+ await assert.rejects(() => stat(path.join(paths.savedVideosDir, "old")));
+
+ // Site membership retargeted.
+ assert.deepEqual(
+ getSite("mysite", paths).channels.map((c) => c.slug),
+ ["new"],
+ );
+
+ // Scheduler backoff carried over under the new key.
+ const state = await readSchedulerState(paths);
+ assert.equal(state.channels["old"], undefined);
+ assert.equal(state.channels["new"]?.consecutiveFailures, 5);
+
+ // Bookmark retargeted (auto-name regenerated).
+ const bookmarks = await readBookmarks(paths);
+ assert.equal(bookmarks[0]?.spec.slug, "new");
+ assert.equal(bookmarks[0]?.name, "whisper-all · new");
+ });
+});
+
+test("renameChannel rejects invalid, same, and existing targets", async () => {
+ await withPaths(async (paths) => {
+ await writeChannelConfig(paths, "old", config);
+
+ await assert.rejects(
+ () => renameChannel(paths, "old", "old", config),
+ /same/,
+ );
+ await assert.rejects(
+ () => renameChannel(paths, "old", "bad/slug", config),
+ /not a valid slug/,
+ );
+ await assert.rejects(
+ () => renameChannel(paths, "old", "..", config),
+ /not a valid slug/,
+ );
+
+ await writeChannelConfig(paths, "taken", config);
+ await assert.rejects(
+ () => renameChannel(paths, "old", "taken", config),
+ /already exists/,
+ );
+ // The failed rename must not have moved "old".
+ assert.equal(await channelExists(paths, "old"), true);
+ });
+});
diff --git a/common/controller/renameChannel.ts b/common/controller/renameChannel.ts
@@ -0,0 +1,170 @@
+import path from "node:path";
+import { readdir, rename, stat } from "node:fs/promises";
+import type { Paths } from "../lib/paths";
+import type { ChannelConfig } from "../lib/channelConfig";
+import { channelExists, isValidChannelSlug } from "./channels";
+import { savedVideoRoot } from "../lib/savedVideo";
+import { rewriteSavedVideoDir } from "../lib/savedVideo-server";
+import { getSite, listSiteIds, writeSite } from "../lib/site";
+import {
+ readSchedulerState,
+ writeSchedulerState,
+} from "../jobs/syncSchedulerState";
+import { renameChannelInBookmarks } from "../jobs/bookmarks";
+
+// Rename a channel's slug. Because the slug IS the on-disk directory name
+// (transcripts/channels/<slug>/), this moves the channel directory AND migrates
+// every other store that keys by slug and would otherwise be orphaned:
+// - the saved-video store dir + each saved-video.json pointer's absolute `dir`
+// - site.json memberships across all sites
+// - the sync scheduler's per-channel backoff state
+// - job bookmarks whose spec targets this channel
+//
+// The two filesystem moves (channel dir, then store dir) run first and roll back
+// on failure so a channel is never left half-renamed. The metadata updates that
+// follow are each individually atomic and best-effort: a failure there is
+// surfaced as a warning rather than aborting an already-completed directory move.
+//
+// In-memory job registry state is NOT migrated (it's process-local); callers
+// must block rename while a channel has running/queued jobs.
+
+export type RenameChannelResult = {
+ // Non-fatal problems with the post-move metadata migrations. Empty on a clean
+ // rename. The directory move itself always succeeded if this returns.
+ warnings: string[];
+};
+
+async function pathExists(p: string): Promise<boolean> {
+ try {
+ await stat(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function renameChannel(
+ paths: Paths,
+ oldSlug: string,
+ newSlug: string,
+ config: ChannelConfig,
+): Promise<RenameChannelResult> {
+ if (!isValidChannelSlug(newSlug)) {
+ throw new Error(
+ `"${newSlug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`,
+ );
+ }
+ if (oldSlug === newSlug) {
+ throw new Error("The new slug is the same as the current one");
+ }
+ if (!(await channelExists(paths, oldSlug))) {
+ throw new Error(`Channel "${oldSlug}" not found`);
+ }
+ if (await channelExists(paths, newSlug)) {
+ throw new Error(`Channel "${newSlug}" already exists`);
+ }
+
+ const oldChannelDir = path.join(paths.channelsDir, oldSlug);
+ const newChannelDir = path.join(paths.channelsDir, newSlug);
+ // channelExists only checks for config.json; a stray directory at the target
+ // (no config) would make rename() nest instead of replace, so reject it too.
+ if (await pathExists(newChannelDir)) {
+ throw new Error(`A directory already exists at channels/${newSlug}`);
+ }
+
+ const storeRoot = savedVideoRoot(paths, config);
+ const oldStoreDir = path.join(storeRoot, oldSlug);
+ const newStoreDir = path.join(storeRoot, newSlug);
+
+ // 1. Move the channel directory. config.json, data/*, playlist, snapshot.json,
+ // shard-*.json, the failed-* lists, and the saved-video.json pointer
+ // sidecars all live under here and move together.
+ await rename(oldChannelDir, newChannelDir);
+
+ // 2. Move the saved-video store dir when present. On any failure, roll the
+ // channel-dir move back so the operator can retry from a clean state.
+ const hadStore = await pathExists(oldStoreDir);
+ if (hadStore) {
+ try {
+ if (await pathExists(newStoreDir)) {
+ throw new Error(
+ `A saved-video store already exists at ${newStoreDir}`,
+ );
+ }
+ await rename(oldStoreDir, newStoreDir);
+ } catch (err) {
+ await rename(newChannelDir, oldChannelDir).catch(() => {});
+ if ((err as NodeJS.ErrnoException).code === "EXDEV") {
+ throw new Error(
+ `Cannot rename across filesystems: the saved-video store at ${oldStoreDir} is on a different device. Move it manually, then retry.`,
+ );
+ }
+ throw err;
+ }
+ }
+
+ const warnings: string[] = [];
+
+ // 3. Repoint each saved-video.json at the moved store dir. The pointer stores
+ // an absolute `dir` that includes the slug, and resolveSavedVideo trusts it
+ // verbatim, so a stale `dir` makes persisted source videos unresolvable.
+ if (hadStore) {
+ const dataDir = path.join(newChannelDir, "data");
+ const ids = await readdir(dataDir).catch(() => [] as string[]);
+ let failed = 0;
+ for (const id of ids) {
+ try {
+ await rewriteSavedVideoDir(
+ path.join(dataDir, id),
+ path.join(newStoreDir, id),
+ );
+ } catch {
+ failed++;
+ }
+ }
+ if (failed > 0) {
+ warnings.push(
+ `${failed} saved-video pointer(s) could not be updated; their persisted source videos may be unresolvable.`,
+ );
+ }
+ }
+
+ // 4. Rewrite site memberships that reference the old slug.
+ try {
+ for (const siteId of listSiteIds(paths)) {
+ const site = getSite(siteId, paths);
+ if (!site.channels.some((c) => c.slug === oldSlug)) continue;
+ const channels = site.channels.map((c) =>
+ c.slug === oldSlug ? { ...c, slug: newSlug } : c,
+ );
+ await writeSite({ ...site, channels }, paths);
+ }
+ } catch (err) {
+ warnings.push(`Site membership update failed: ${(err as Error).message}`);
+ }
+
+ // 5. Move the scheduler's per-channel backoff entry so auto-sync state carries
+ // over (the historical run log is left as-is — it's observability only).
+ try {
+ const state = await readSchedulerState(paths);
+ const entry = state.channels[oldSlug];
+ if (entry) {
+ state.channels[newSlug] = entry;
+ delete state.channels[oldSlug];
+ await writeSchedulerState(paths, state);
+ }
+ } catch (err) {
+ warnings.push(
+ `Scheduler state migration failed: ${(err as Error).message}`,
+ );
+ }
+
+ // 6. Retarget job bookmarks.
+ try {
+ await renameChannelInBookmarks(paths, oldSlug, newSlug);
+ } catch (err) {
+ warnings.push(`Bookmark migration failed: ${(err as Error).message}`);
+ }
+
+ return { warnings };
+}
diff --git a/common/jobs/bookmarks.ts b/common/jobs/bookmarks.ts
@@ -118,6 +118,29 @@ export async function removeBookmark(
if (next.length !== existing.length) await writeBookmarks(paths, next);
}
+// Rewrite every bookmark whose spec targets `oldSlug` to target `newSlug`. Used
+// when a channel is renamed so its bookmarked jobs keep working. An auto-derived
+// name (still equal to the old spec's default) is regenerated for the new slug;
+// a custom name the operator set is left untouched. Returns the number changed.
+export async function renameChannelInBookmarks(
+ paths: Paths,
+ oldSlug: string,
+ newSlug: string,
+): Promise<number> {
+ const existing = await readBookmarks(paths);
+ let changed = 0;
+ const next = existing.map((b) => {
+ if (b.spec.slug !== oldSlug) return b;
+ changed++;
+ const spec = { ...b.spec, slug: newSlug };
+ const name =
+ b.name === defaultBookmarkName(b.spec) ? defaultBookmarkName(spec) : b.name;
+ return { ...b, spec, name };
+ });
+ if (changed > 0) await writeBookmarks(paths, next);
+ return changed;
+}
+
// Move a bookmark one slot up (dir -1) or down (dir +1) by swapping it with its
// neighbour. Order is the array order — the same order both the management page
// and the compact menu render — so this is the only place reordering lives.
diff --git a/common/lib/savedVideo-server.ts b/common/lib/savedVideo-server.ts
@@ -47,6 +47,22 @@ async function writePointer(
await rename(tmp, file);
}
+// Repoint a video's saved-video.json at a new absolute store dir. Used when a
+// channel is renamed: the pointer's `dir` is stored absolutely and includes the
+// channel slug, so it must be rewritten after the store dir is moved or
+// resolveSavedVideo (which trusts `dir` verbatim) can no longer find the file.
+// No-op (returns false) when there's no pointer.
+export async function rewriteSavedVideoDir(
+ videoDir: string,
+ newDir: string,
+): Promise<boolean> {
+ const pointer = await loadSavedVideo(videoDir);
+ if (!pointer) return false;
+ if (pointer.dir === newDir) return true;
+ await writePointer(videoDir, { ...pointer, dir: newDir });
+ return true;
+}
+
// Move a file, crossing device boundaries safely. A plain rename() works within
// one filesystem; EXDEV (the store is on a different disk) falls back to a
// copy-to-temp + atomic rename + unlink so a crash mid-copy never leaves a
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **You can now change a channel's slug (its id) — deliberately, from the Danger zone.** A channel's slug *is* its on-disk directory name (`transcripts/channels/<slug>/`), so it used to be fixed at creation ("Slug is fixed once a channel is created"). A new **Rename** form in the channel's Danger zone lifts that: enter a new slug and **type the current slug to confirm** (same friction as delete), and the rename is blocked while the channel has running/queued jobs (the in-memory registry keys by slug). Because the slug is a directory name, the rename does a **full migration** of every slug-keyed store so nothing silently breaks: it moves the channel dir (config, data, playlist, snapshot, shards, failed lists) **and** the saved-video store dir — rewriting each `saved-video.json` pointer's absolute `dir` so persisted source videos still resolve — then retargets every site.json membership, the sync scheduler's per-channel backoff state, and any job bookmarks. The two filesystem moves run first and roll back on failure; the metadata updates that follow are atomic and best-effort (surfaced as warnings). Renaming **changes the channel's public URL** (the old one 404s), which the form warns about. The slug grammar is also now validated on create. See `common/controller/renameChannel.ts`, `common/controller/channels.ts` (`isValidChannelSlug`), `common/lib/savedVideo-server.ts` (`rewriteSavedVideoDir`), `common/jobs/bookmarks.ts` (`renameChannelInBookmarks`), `editor/app/channels/{actions.ts,components/RenameChannelForm.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-rename.spec.ts`.
- **New Queue diagnostics page (`/jobs/queue`): see & force-release stuck jobs.** The job system has two sources of truth that can drift — the registry owns each job's `status`, the scheduler owns the running SLOT per queue. A cancel that never finalizes (a child that ignored SIGTERM, a crashed finalizer) leaves a job "cancelled" in the registry while the scheduler still marks its slot running, silently blocking every job behind it on that queue — and the Active Jobs page hides it (it filters to running/queued). The new **Queue** page reconciles the two: it builds from the **scheduler** as the source of truth for slots, cross-checks each against its registry record, and flags a running head as **stuck** when the record is terminal-but-holding-slot, evicted, or (softer) a live job idle past 10 minutes. It **auto-heals** the hard cases on every view/poll (frees terminal/evicted slots), shows a health strip (active queues, running, queued, **stuck**, workers), per-queue cards with the held-for duration / PID (`kill -9` hint) / last log line, and a **Force-release** button per slot (SIGKILLs the child and frees the slot unconditionally) plus a **Reap all stuck** action. Force-release is also available on any running job in Active Jobs, and Active Jobs links to Queue with a stuck-count badge. See `common/jobs/registry.ts` (`forceRelease`), `editor/app/jobs/queue/*`, `editor/app/jobs/{actions.ts,components/ForceReleaseJobButton.tsx}`, and `editor/e2e/queue.spec.ts`.
- **Jobs page: real log retention + pagination (replaces the dead "Clear archived logs" button).** The old button only deleted logs absent from the in-memory registry — which, since the registry keeps the 100 newest finished jobs and sidecars preserve their real status, was almost never anything, so it did nothing. It's replaced by a **Clear logs** dropdown that prunes finished-job logs by age (older than 7 / 30 / 90 days) or all at once; running/queued jobs are never deleted. The `.jobs` directory also **self-trims on job finish** (throttled; keep newest 500, drop >30 days) so it can't grow unbounded. Job ids are now **ULIDs** (lexicographically time-sortable, timestamp decodable from the id), letting the list **paginate** — `listAllJobs` returns one page (default 50, grown by a **Load more** link) and only `stat`s/reads the sidecar for the shown page instead of every file on every load. `jobIdTime()` decodes both ULID and the legacy `<t36>-<rand>` ids, so existing on-disk logs still sort/read correctly. See `common/jobs/{ulid,listJobs,registry,streamCommand}.ts`, `editor/app/jobs/{page.tsx,actions.ts,components/ClearLogsMenu.tsx,[id]/page.tsx}`, and `editor/e2e/jobs.spec.ts`.
- **"Move to top" button on the auto-queue policy editor.** Each reorderable rule/group in the auto-queue policy tree gains a **⤒** button beside the existing ↑/↓ swap controls that jumps the node straight to the front of its sibling list in one click (disabled on the first row, like ↑). Reordering stays local until **Save policy**, matching the swap buttons. See `editor/app/auto-queue/components/PolicyTreeEditor.tsx` and `editor/e2e/auto-queue.spec.ts`.
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -37,6 +37,7 @@ import {
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { ChannelFormClient } from "../components/ChannelFormClient";
import { DeleteChannelForm } from "../components/DeleteChannelForm";
+import { RenameChannelForm } from "../components/RenameChannelForm";
import { RunningJobsList } from "../../jobs/components/RunningJobsList";
import { StagesView } from "./components/StagesView";
import { StatusHeader } from "./components/StatusHeader";
@@ -61,6 +62,7 @@ import {
} from "./lib/stageStatus";
import {
deleteChannelAction,
+ renameChannelAction,
updateChannelAction,
type ActionResult,
} from "../actions";
@@ -135,6 +137,7 @@ export default async function ChannelDetailPage({
const update = updateChannelAction.bind(null, slug);
const del = deleteChannelAction.bind(null, slug);
+ const rename = renameChannelAction.bind(null, slug);
const registry = getRegistry();
const existingQueues = registry.activeQueueNames();
const runningJobs = registry
@@ -300,15 +303,27 @@ export default async function ChannelDetailPage({
/>
),
danger: (
- <DeleteChannelForm
- slug={slug}
- action={
- del as (
- prev: ActionResult,
- formData: FormData,
- ) => Promise<ActionResult>
- }
- />
+ <div className="flex flex-col gap-4">
+ <RenameChannelForm
+ slug={slug}
+ action={
+ rename as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ />
+ <hr className="border-border" />
+ <DeleteChannelForm
+ slug={slug}
+ action={
+ del as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ />
+ </div>
),
};
if (transcodeApplies) {
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -8,12 +8,15 @@ import {
channelExists,
createChannel,
deleteChannel,
+ isValidChannelSlug,
listChannels,
readChannelConfig,
writeChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
+import { renameChannel } from "yt-dlp-transcript-common/controller/renameChannel";
import { generateChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import {
getSite,
isValidSiteId,
@@ -44,6 +47,11 @@ export async function createChannelAction(
if (!slug) {
return { error: "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)`,
+ };
+ }
const paths = getPaths();
if (await channelExists(paths, slug)) {
return { error: `Channel "${slug}" already exists` };
@@ -234,3 +242,71 @@ export async function deleteChannelAction(
revalidatePath("/");
redirect("/channels");
}
+
+// Change a channel's slug (its on-disk directory name). High-friction: the
+// operator must type the CURRENT slug to confirm, mirroring deleteChannelAction.
+// Blocked while the channel has running/queued jobs, since the in-memory job
+// registry keys by slug and those jobs would be orphaned by the move. On success
+// every slug-keyed store is migrated (see renameChannel) and we redirect to the
+// new URL — the old one 404s.
+export async function renameChannelAction(
+ oldSlug: string,
+ _prev: ActionResult,
+ formData: FormData,
+): Promise<ActionResult> {
+ const confirm = String(formData.get("confirmSlug") ?? "").trim();
+ if (confirm !== oldSlug) {
+ return {
+ error: `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" };
+ }
+ if (newSlug === oldSlug) {
+ return { error: "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)`,
+ };
+ }
+ const paths = getPaths();
+ const config = await readChannelConfig(paths, oldSlug);
+ if (!config) return { error: `Channel "${oldSlug}" not found` };
+ if (await channelExists(paths, newSlug)) {
+ return { error: `Channel "${newSlug}" already exists` };
+ }
+
+ const activeJobs = getRegistry()
+ .list()
+ .filter(
+ (j) =>
+ j.channelSlug === oldSlug &&
+ (j.status === "running" || j.status === "queued"),
+ );
+ if (activeJobs.length > 0) {
+ return {
+ error: `Finish or cancel ${activeJobs.length} running/queued job(s) for this channel before renaming.`,
+ };
+ }
+
+ let result;
+ try {
+ result = await renameChannel(paths, oldSlug, newSlug, config);
+ } catch (e) {
+ return { error: (e as Error).message };
+ }
+ // 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).
+ if (result.warnings.length > 0) {
+ console.warn(
+ `Channel rename ${oldSlug} -> ${newSlug} completed with warnings:`,
+ result.warnings.join("; "),
+ );
+ }
+ revalidatePath("/channels");
+ revalidatePath("/");
+ redirect(`/channels/${newSlug}`);
+}
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -60,7 +60,7 @@ export function ChannelForm({
name="slug"
readOnly
defaultValue={initial!.slug}
- hint="Slug is fixed once a channel is created."
+ hint="To change the slug, use Rename in the Danger zone below."
/>
) : (
<Field
diff --git a/editor/app/channels/components/RenameChannelForm.tsx b/editor/app/channels/components/RenameChannelForm.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useActionState } from "react";
+import type { ActionResult } from "../actions";
+
+type Props = {
+ slug: string;
+ action: (prev: ActionResult, formData: FormData) => Promise<ActionResult>;
+};
+
+export function RenameChannelForm({ slug, action }: Props) {
+ const [state, formAction] = useActionState<ActionResult, FormData>(
+ action,
+ undefined,
+ );
+ return (
+ <form action={formAction} className="flex flex-col gap-2">
+ <p className="text-sm text-muted-foreground">
+ Renaming moves <code>transcripts/channels/{slug}/</code> (and its saved
+ videos, site memberships and scheduled-sync state) to the new slug, and{" "}
+ <strong>changes this channel's public URL</strong> — existing links
+ to the old slug will break. Type the current slug to confirm.
+ </p>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">New slug</span>
+ <input
+ name="newSlug"
+ required
+ placeholder="new-channel-slug"
+ aria-label="new slug"
+ className="rounded border border-border bg-card px-2 py-1 text-sm font-mono max-w-xs"
+ />
+ </label>
+ <div className="flex gap-2 items-start">
+ <input
+ name="confirmSlug"
+ required
+ placeholder={slug}
+ aria-label="confirm current slug"
+ className="rounded border border-border bg-card px-2 py-1 text-sm font-mono"
+ />
+ <button
+ type="submit"
+ className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90"
+ >
+ Rename channel
+ </button>
+ </div>
+ {state?.error && (
+ <p className="text-sm text-destructive">{state.error}</p>
+ )}
+ </form>
+ );
+}
diff --git a/editor/e2e/channel-rename.spec.ts b/editor/e2e/channel-rename.spec.ts
@@ -0,0 +1,45 @@
+import { test, expect } from "@playwright/test";
+import { readJson, resetData, writeSite } from "./helpers";
+
+// Renaming a channel's slug lives in the Danger zone and requires typing the
+// current slug to confirm (mirroring delete). On success it migrates slug-keyed
+// state (here: a site membership) and redirects to the new URL; the old URL 404s.
+
+const OLD = "test-youtube";
+const NEW = "renamed-youtube";
+
+test("rename requires the exact current slug and then moves the channel", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel");
+ // A site that lists the channel — its membership must follow the rename.
+ await writeSite("mysite", { channels: [{ slug: OLD }] });
+
+ await page.goto(`/channels/${OLD}`);
+
+ const newSlug = page.getByLabel("new slug");
+ const confirm = page.getByLabel("confirm current slug");
+ const submit = page.getByRole("button", { name: "Rename channel" });
+
+ // Wrong confirmation is rejected and stays put.
+ await newSlug.fill(NEW);
+ await confirm.fill("not-the-slug");
+ await submit.click();
+ await expect(page.getByText(/Type the channel slug/)).toBeVisible();
+ await expect(page).toHaveURL(new RegExp(`/channels/${OLD}$`));
+
+ // Correct confirmation renames and redirects to the new URL.
+ await confirm.fill(OLD);
+ await submit.click();
+ await expect(page).toHaveURL(new RegExp(`/channels/${NEW}`));
+
+ // The old URL now 404s.
+ const resp = await page.goto(`/channels/${OLD}`);
+ expect(resp?.status()).toBe(404);
+
+ // The site membership was retargeted to the new slug.
+ const site = await readJson<{ channels: { slug: string }[] }>(
+ "test-transcripts/sites/mysite/site.json",
+ );
+ expect(site.channels.map((c) => c.slug)).toEqual([NEW]);
+});