Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/channels.ts | 13+++++++++++++
Acommon/controller/renameChannel.test.ts | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/renameChannel.ts | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/bookmarks.ts | 23+++++++++++++++++++++++
Mcommon/lib/savedVideo-server.ts | 16++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/page.tsx | 33++++++++++++++++++++++++---------
Meditor/app/channels/actions.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelForm.tsx | 2+-
Aeditor/app/channels/components/RenameChannelForm.tsx | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/channel-rename.spec.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
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&apos;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]); +});