"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import slugify from "@sindresorhus/slugify"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { detectPlatform, queueKeyForUrl, type Platform, } from "yt-dlp-transcript-common/lib/platform"; import { probeChannelMeta } from "yt-dlp-transcript-common/ytdlp/runYtdlp"; import { detectSocialFetcher } from "yt-dlp-transcript-common/social/fetchers"; import { channelExists, createChannel, deleteChannel, isValidChannelSlug, listChannelConfigs, patchChannelConfig, readChannelConfig, } from "yt-dlp-transcript-common/controller/channels"; import { renameChannel } from "yt-dlp-transcript-common/controller/renameChannel"; import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { channelMediaBusyReason } from "./lib/mediaBusy"; import { REFRESH_REPORT_ACTIVE, refreshReportWaitNotice, requestChannelSnapshot, requestRefreshReport, startRefreshReport, waitForRefreshReport, } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { siteChannelIndex, type Site, } from "yt-dlp-transcript-common/lib/site"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { LANES, type AutoQueueKind, } from "yt-dlp-transcript-common/lib/autoQueueTypes"; import { channelPriorityFromLegacy, compileLanes, DEFAULT_CHANNEL_TIER, effectiveTier, hasCompiledLaneRoots, isChannelPaused, clearAutoPause, isDefaultChannelPriority, rankOf, renameChannelInPriority, resolveFocusSlugs, sanitizeChannelPriority, tierOrder, type ChannelFocus, type ChannelPriority, type ChannelPriorityEntry, type PriorityOperation, type SiteChannelIndex, type StoredChannelTier, } from "yt-dlp-transcript-common/lib/channelPriority"; import { startAutoRunner } from "yt-dlp-transcript-common/controller/autoRunner"; import { activeSyncSlugs } from "yt-dlp-transcript-common/jobs/syncJobs"; import { readSchedulerState, writeSchedulerState, } from "yt-dlp-transcript-common/jobs/syncSchedulerState"; import { CHANNEL_FORM_FIELDS, parseChannelForm, } from "./components/parseChannelForm"; import { applySiteWrites, parseSiteMembershipsField, planSiteMembershipWrites, } from "./lib/siteMemberships"; import { queueForSlugs, type QueueOutcome } from "./lib/queueForSlugs"; import { storePlaylistAction, syncAction } from "./[slug]/pipelineActions"; import { fetchPostsAction } from "./[slug]/socialActions"; import { formValues, type FormErrorState } from "../lib/formState"; // `undefined` is success; a refusal says why, and a form's refusal carries // what was submitted so the form keeps what was typed (lib/formState.ts). export type ActionResult = FormErrorState; // Import-for-side-effect, deferred to call time so the heavy fetcher modules // never enter the module graph a client component imports. async function registerBuiltinSocialFetchers(): Promise { await import("yt-dlp-transcript-common/social/blueskyFetcher"); await import("yt-dlp-transcript-common/social/xGalleryDlFetcher"); await import("yt-dlp-transcript-common/social/xPlaywrightFetcher"); await import("yt-dlp-transcript-common/social/xNitterFetcher"); await import("yt-dlp-transcript-common/social/xenforoFetcher"); } export type ProbeChannelResult = | { ok: true; // Display-name candidate yt-dlp printed (null when it printed nothing // usable). The client slugifies this to seed the slug field. name: string | null; // What detectPlatform() makes of the URL offline (null for a host it // doesn't recognize but yt-dlp still handled). platformDetected: Platform | null; // The serial queue this channel's downloads will land on (e.g. // "platform:youtube" or, for an unknown host, "platform:vimeo.com"). queueKey: string; // False when the host is unknown to the app — surfaced as "(new)" in the // form hint. A successful probe on an unknown host is exactly the // "yt-dlp supports it, the app didn't know" case. queueKnown: boolean; // Set when the URL resolved to a social account rather than a video // channel: the form switches to its social branch and stores these. sourceKind?: "social"; postFetcher?: string; socialHandle?: string; } | { ok: false; error: string }; // Opt-in yt-dlp probe for the URL-first new-channel flow. Runs a single // flat-playlist metadata read (no download) to prove the URL is fetchable and // pull a display-name candidate — including for hosts detectPlatform() doesn't // recognize but yt-dlp does, which is how an unknown-platform channel is // created (its downloads route to a per-domain serial queue). No writes. export async function probeChannelUrlAction( url: string, ): Promise { const trimmed = (url ?? "").trim(); if (!/^https?:\/\/\S+/i.test(trimmed)) { return { ok: false, error: "Enter a valid channel or playlist URL (http/https).", }; } const platformDetected = detectPlatform(trimmed); const queueKey = queueKeyForUrl(trimmed); // Social accounts never go through yt-dlp: it cannot enumerate a timeline on // either platform (no twitter:user extractor exists). Route them to the // fetcher's own probe instead — for Bluesky that is a free, instant // resolveHandle + getProfile. // Register the built-in fetchers lazily, INSIDE the action body. A top-level // side-effect import would put xGalleryDlFetcher (which pulls execa + node // builtins) into this module's graph, and ChannelForm — a client component — // imports this file for the action reference, which breaks the client bundle. await registerBuiltinSocialFetchers(); const socialFetcher = detectSocialFetcher(trimmed); if (socialFetcher) { const result = await socialFetcher.probe(trimmed); if (!result.ok) { return { ok: false, error: `${socialFetcher.label} probe failed: ${result.error ?? "unknown error"}`, }; } return { ok: true, name: result.name ?? null, platformDetected, queueKey, queueKnown: platformDetected !== null, sourceKind: "social", postFetcher: socialFetcher.id, socialHandle: result.handle, }; } try { const meta = await probeChannelMeta({ url: trimmed, paths: getPaths() }); return { ok: true, name: meta.name, platformDetected, queueKey, queueKnown: platformDetected !== null, }; } catch (e) { return { ok: false, error: `yt-dlp probe failed: ${(e as Error).message}` }; } } // 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); export async function createChannelAction( _prev: ActionResult, formData: FormData, ): Promise { 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 { 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 fail((e as Error).message); } const { name, config } = parsed; const slug = parsed.slug || slugify(name); if (!slug) { return fail("Could not derive a slug from the name"); } if (!isValidChannelSlug(slug)) { 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 // BEFORE the channel exists, so a rejected submit can be corrected and // resubmitted without hitting "already exists". A null parse result (field // absent — zero sites configured) skips membership work entirely. let siteWrites: Site[] = []; try { const requests = parseSiteMembershipsField( formData.get("siteMembershipsJson"), ); if (requests) { siteWrites = planSiteMembershipWrites(paths, slug, requests); } } catch (e) { return fail((e as Error).message); } if (await channelExists(paths, slug)) { return fail(`Channel "${slug}" already exists`); } await createChannel(paths, slug, config); // THE COMPILED TREES NAME THEIR CHANNELS. A channel created since the last // priority write has no leaf of its own and falls to the trailing catch-all // — safe (bottom priority) and self-healing, but only on the next write. The // recompile IS that write, through the one writer. Best-effort: the channel // exists either way, and the runner compiles per tick regardless. try { await recompileChannelPriorityAction(); } catch { /* best-effort — the channel was created regardless */ } try { await applySiteWrites(siteWrites, paths); } catch (e) { // The channel itself was created; don't redirect as if nothing happened. 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 // the snapshot + auto-queue. Needs a URL to do anything; storePlaylistAction // no-ops (returns !ok) otherwise, which we ignore. Fire-and-forget — cancel // the stream so buffered chunks GC while the job runs server-side. if (config.url && formData.get("fetchPlaylist") != null) { try { const res = await storePlaylistAction(slug); if (res.ok) { void res.stream.cancel(); jobIds.push(res.jobId); } } catch { /* best-effort — the channel was created regardless */ } } // Social equivalent of "Fetch playlist now": kick off the first post fetch so // the account's history starts archiving immediately. Same fire-and-forget // shape — the channel exists either way. if (config.url && formData.get("fetchPostsNow") != null) { try { const res = await fetchPostsAction(slug); if (res.ok) { void res.stream.cancel(); jobIds.push(res.jobId); } } catch { /* best-effort — the channel was created regardless */ } } // Optional "Add to top of auto-queue": give the channel the first rank in the // priority document, enable the download lane and start its runner, for a // channel you want auto-downloading fast. The local writer rather than // operations/actions.ts' wrapper, which imports this file — a cycle between // two "use server" modules is not worth one `startAutoRunner` call. if (config.url && formData.get("prioritizeDownload") != null) { try { await prioritizeChannelDownloadPriorityAction(slug); await startAutoRunner("download"); } catch { /* best-effort — the channel was created regardless */ } } revalidatePath("/channels"); revalidatePath("/sites"); revalidatePath("/"); return { ok: true, slug, jobIds, warnings: [] }; } export async function updateChannelAction( slug: string, _prev: ActionResult, formData: FormData, ): Promise { const values = formValues(formData); let parsed; try { parsed = parseChannelForm(formData); } catch (e) { return { error: (e as Error).message, values }; } const paths = getPaths(); const existing = await readChannelConfig(paths, slug); if (!existing) return { error: `Channel "${slug}" not found`, values }; // Plan the site-membership writes (Sites section) before touching anything, // so bad input errors out with no partial write. Null = field absent // (zero sites configured / legacy submit) → leave memberships alone. let siteWrites: Site[] = []; try { const requests = parseSiteMembershipsField( formData.get("siteMembershipsJson"), ); if (requests) { siteWrites = planSiteMembershipWrites(paths, slug, requests); } } catch (e) { return { error: (e as Error).message, values }; } // The form parser only emits keys whose form value is meaningful, so a // cleared input is absent from `parsed.config`. A plain spread would keep // the stale value. So the patch UNSETS every form-managed key first, then // layers the parsed config — non-form fields like subLangs, the sync-state // stamps (CHANNEL_SYNC_STATE_KEYS) and excludeFromBuild are preserved, and // are re-read at write time, so a sync that stamped lastSyncedAt while the // form was open is not reverted. const written = await patchChannelConfig(paths, slug, parsed.config, { unset: CHANNEL_FORM_FIELDS, }); if (!written) return { error: `Channel "${slug}" not found`, values }; try { await applySiteWrites(siteWrites, paths); } catch (e) { return { error: (e as Error).message, values }; } // Config changes (e.g. audioFormat / handling) feed snapshot buckets, so // refresh the report through the global debounced scheduler. requestChannelSnapshot(paths, slug); revalidatePath("/channels"); revalidatePath(`/channels/${slug}`); revalidatePath("/sites"); revalidatePath("/"); return undefined; } export async function gotoVideoAction( slug: string, formData: FormData, ): Promise { const id = String(formData.get("id") ?? "").trim(); if (!id) redirect(`/channels/${slug}`); redirect(`/channels/${slug}/videos/${encodeURIComponent(id)}`); } // What a channel's Refresh report answers: nothing when the report is on disk, // `{ error }` when it could not be made, `{ notice }` when it is still queued. export type RefreshSnapshotResult = ActionResult | { notice: string }; export async function refreshChannelSnapshotAction( slug: string, ): Promise { const paths = getPaths(); if (!(await channelExists(paths, slug))) { return { error: `Channel "${slug}" not found` }; } // THROUGH THE REFRESH-REPORT QUEUE, like every other walk (release 17 slice // D0): a walk run here, in the request, beside a queued one was two walks // side by side — half of the 2026-10-01 outage — and could land an older // read over a newer one. The job is the one this click starts, or the one // already queued for the channel (it has not started reading, so it is as // fresh). // // A BOUNDED WAIT (REFRESH_REPORT_WAIT_MS, 15 s). The queue is serial: behind // a 3,000-video walk, or during Update all reports, this channel's report may // be minutes away. Past the bound the action answers with where the job is // ("Queued behind 3 report regenerations — …, job …") and the page catches // up when it runs (the scheduler's generation moves /api/pulse). // // A FAILED WALK IS AN ERROR, whoever started it: generateChannelSnapshot // throws on a channel whose media is not reachable (guard 3) rather than // writing a snapshot that says every video is undownloaded, and that // sentence — the job log's `[error]` line — is what comes back, not a // digest-only "an error occurred". Every sibling in this file returns // { error }; so does this. const requested = await requestRefreshReport(paths, slug); if (!requested.ok) return { error: requested.error }; const wait = await waitForRefreshReport(paths, requested.jobId); if (wait.state === "failed") return { error: wait.error }; if (wait.state === "waiting") return { notice: refreshReportWaitNotice(wait) }; revalidatePath(`/channels/${slug}`); // The Report column on /channels is read off this snapshot, and the row // action sits next to the marker it flips — so revalidate the list too, not // just the channel page. The operation pages draw the same census. revalidatePath("/channels"); revalidatePath("/operations/[id]", "page"); return undefined; } // EVERY CHANNEL'S REPORT, from the page that owns the reports. Lives here // rather than beside the actionable census because it is a /channels control: // the header button next to "Sync all", and the per-row refresh under it. export type RefreshAllResult = { queued: string[]; skipped: { slug: string; reason: string }[]; // The ids of the jobs started, parallel to `queued`. Same reason // `QueueOutcome` grew one: without it an HTTP caller could not tell a // fan-out that started work from an action that started none, so // `pnpm ops refresh-report --json '{"all":true}' --wait` returned the moment // the response arrived. The action answers once the jobs are QUEUED (release // 17: they run one at a time), so the ids are what `--wait` follows. jobIds: string[]; }; export async function refreshAllChannelSnapshotsAction(): Promise { const paths = getPaths(); const channels = await listChannelConfigs(paths); const queued: string[] = []; const jobIds: string[] = []; const skipped: { slug: string; reason: string }[] = []; for (const c of channels) { // The scheduler's one entry point: the refresh-report queue (one // regeneration at a time — they used to run all at once, in parallel) and // its per-slug dedup, shared with the debounced regeneration. const result = await startRefreshReport(paths, c.slug); if (!result.ok) { skipped.push({ slug: c.slug, reason: result.error === REFRESH_REPORT_ACTIVE ? "already queued" : result.error, }); continue; } // Nothing reads the stream (the log is on disk). void result.stream.cancel(); queued.push(c.slug); jobIds.push(result.jobId); } // ANSWERS ONCE QUEUED, NOT ONCE DONE. The regenerations run one at a time, // so waiting for them was waiting for every channel's walk added up — // minutes on a real corpus — behind a button and an ops call that time out. // The pages catch up as each lands: every regeneration moves the // scheduler's generation, which /api/pulse carries. return { queued, jobIds, skipped }; } export async function toggleChannelBuildInclusionAction( slug: string, ): Promise { const paths = getPaths(); const existing = await readChannelConfig(paths, slug); if (!existing) return { error: `Channel "${slug}" not found` }; await (existing.excludeFromBuild ? patchChannelConfig(paths, slug, {}, { unset: ["excludeFromBuild"] }) : patchChannelConfig(paths, slug, { excludeFromBuild: true })); revalidatePath("/channels"); 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 { 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 // channel's inclusion in the running total. export async function toggleChannelCleanupInclusionAction( slug: string, ): Promise { const paths = getPaths(); const existing = await readChannelConfig(paths, slug); if (!existing) return { error: `Channel "${slug}" not found` }; await (existing.excludeFromCleanup ? patchChannelConfig(paths, slug, {}, { unset: ["excludeFromCleanup"] }) : patchChannelConfig(paths, slug, { excludeFromCleanup: true })); revalidatePath("/cleanup"); revalidatePath("/channels"); return undefined; } // Unranked sorts AFTER every ranked sibling — `orderWithin`'s rule in the // compiler, not a plain numeric compare, which would put a missing rank first. function compareSyncRank(a: number | null, b: number | null): number { if (a === b) return 0; if (a === null) return 1; if (b === null) return -1; return a - b; } export type SyncAllResult = QueueOutcome; // Queue a sync for every eligible channel. Each sync decides for itself whether // it is due for a full sweep, so "Sync all" surfaces upstream deletions on // whichever channels are due with no extra clicks — pass fullSweep to force the // deep pass on every channel instead. export async function syncAllChannelsAction( opts?: { fullSweep?: boolean }, ): Promise { const paths = getPaths(); const channels = await listChannelConfigs(paths); const bySlug = new Map(channels.map((c) => [c.slug, c.config])); const active = activeSyncSlugs(); // THE SAME ANSWER THE SCHEDULER GIVES, asked of the `sync` OPERATION. // // A manual pool sweep and the automatic one must agree about which channels // are in the pool: the group Sync buttons ask the priority document through // `stationWorkFor` and the scheduler asks it in `selectDueChannels`, so this // loop asks the same question. It is the ONLY question now — S5 deleted // `excludeFromSync` and migrated the 15 channels that carried it. const priority = getSettings().channelPriority; const slugs = channels.map((c) => c.slug); const focus = new Set( resolveFocusSlugs(priority, siteChannelIndex(paths), slugs), ); // ORDER: focus, then tier, then rank, then slug. `queueForSlugs` runs the // list in order and each sync takes a slot on the platform queue, so on a // 68-channel pool the order IS the priority — the focus channels' syncs are // the ones that land first. const order = [...slugs].sort( (a, b) => tierOrder(focus.has(a) ? "focus" : effectiveTier(priority, a, "sync")) - tierOrder( focus.has(b) ? "focus" : effectiveTier(priority, b, "sync"), ) || compareSyncRank(rankOf(priority, a), rankOf(priority, b)) || a.localeCompare(b), ); const outcome = await queueForSlugs(order, { // AN UNMOUNTED DRIVE IS NOT AN EMPTY CHANNEL, and a sync is exactly the job // that acts on that mistake. Every enumerator of `data/` swallows ENOENT as // "no videos" (AGENTS.md), so a sync of a channel whose platter is not // there reads the whole back catalogue as undownloaded and hands the // download lane an instruction to re-fetch hundreds of gigabytes onto the // volume that was too full to hold them. // // `inspectChannelMedia` is the one module that can tell the two apart. Two // stats and at most one small JSON read per channel — cheap enough for a // 68-channel pool, and the reason `queueForSlugs.skip` may be async. // // ok and in-place are the two healthy answers: the media is where config // says, or there is no relocation at all. Everything else — `unreachable`, // `in-transition`, `inconsistent` — is a skip with the inspector's own // sentence, so the bulk bar names the drive rather than reporting a // successful sweep over a channel nothing could read. skip: async (slug) => { const config = bySlug.get(slug); if (!config?.url) return "no url"; if (isChannelPaused(priority, slug, "sync")) return "paused for sync"; if (active.has(slug)) return "already running"; const media = await inspectChannelMedia(paths, slug, config); if (media.status !== "ok" && media.status !== "in-place") { return `media ${media.status}: ${media.detail ?? "not reachable"}`; } return null; }, run: (slug) => syncAction(slug, undefined, opts?.fullSweep), }); // Record the sweep's freshness marker for the monitor widget's last-sync // readout. Read-modify-write right before the write keeps the clobber window // vs. a concurrent scheduler tick minimal (single-user editor — acceptable). // // Deliberately OUTSIDE queueForSlugs: a per-GROUP sweep runs the same loop but // is not a full sweep, and must not claim one in that readout. const state = await readSchedulerState(paths); state.lastSyncAllAt = Date.now(); await writeSchedulerState(paths, state); return outcome; } export async function deleteChannelAction( slug: string, _prev: ActionResult, formData: FormData, ): Promise { 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 { 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 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 // pulls the directory out from under a writer — a download's `.part`, a // transcribe's sidecar, a digest unit's JSON — and the lane units make no job // record at all, so the registry alone never saw them. `deleteChannel` then // races the writer for the tree and whichever loses reports an ENOENT nobody // asked about. const busy = channelMediaBusyReason(slug, "deleting it"); 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` 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 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 // harmless to dispatch and confusing to read. try { await recompileChannelPriorityAction(); } catch { /* best-effort — the channel is gone either way */ } revalidatePath("/channels"); revalidatePath("/"); return { ok: true, slug, jobIds: [], warnings: [] }; } // 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 { 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 { 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 fail(`Type the channel slug "${oldSlug}" exactly to confirm the rename`); } const newSlug = String(formData.get("newSlug") ?? "").trim(); if (!newSlug) { return fail("Enter a new slug"); } if (newSlug === oldSlug) { return fail("The new slug is the same as the current one"); } if (!isValidChannelSlug(newSlug)) { 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 fail(`Channel "${oldSlug}" not found`); if (await channelExists(paths, newSlug)) { return fail(`Channel "${newSlug}" already exists`); } // THE REGISTRY IS HALF THE TRUTH, and this check used to be the other half's // ancestor: it counted running/queued JOBS only. The auto-queue lanes run // their per-video units in-process and make no job record (the omnimirror // incident, lib/mediaBusy.ts), so a digest unit writing a sidecar into // `data/` was invisible here — and renaming moves the directory out from // 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 fail(busy); let result; try { result = await renameChannel(paths, oldSlug, newSlug, config); } catch (e) { 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 // longer exists — silently, because nothing can tell a stale entry from a // deliberate one — a `{kind:"channels"}` focus stops naming it, and every // compiled root keeps a `prio-*-` leaf matching nothing. // // Through the one writer, like create and delete: the re-key is the pure // `renameChannelInPriority`, and the writer recompiles the four roots in the // same `writeSettings`. Best-effort — the directory has already moved, and // reporting a settings failure as a rename failure would be a lie. try { await saveChannelPriorityAction({ kind: "rename", from: oldSlug, to: newSlug, }); } catch (e) { console.warn( `Channel rename ${oldSlug} -> ${newSlug}: channel priority not updated:`, (e as Error).message, ); } // The directory move succeeded; any warnings are non-fatal metadata-migration // 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:`, result.warnings.join("; "), ); } revalidatePath("/channels"); revalidatePath("/"); return { ok: true, slug: newSlug, jobIds: [], warnings: result.warnings }; } // --------------------------------------------------------------------------- // CHANNEL PRIORITY — the one writer // --------------------------------------------------------------------------- // EVERY WRITE OF `settings.channelPriority` GOES THROUGH `saveChannelPriorityAction`. // // One writer, for the same reason `withGateHeld` is the one writer of a lane's // `held`: the document is not the only thing a priority change produces. The // four `autoQueue[lane].root` trees are COMPILED from it (common/lib/ // channelPriority.ts), so a second writer would leave the model and the trees // disagreeing until whoever wrote next happened to recompile. The recompile // therefore happens HERE, in the same `writeSettings` call that persists the // document, and every control on /channels funnels through the edit vocabulary // below rather than assembling a `ChannelPriority` of its own. // // The edit is a SERIALIZABLE union, not a callback: a server action's arguments // cross the network boundary, so "apply this function to the current document" // is not expressible. Each variant is one operator gesture. export type ChannelPriorityEdit = // Set the BASE tier of one or more channels. Overrides survive; the sanitizer // drops any that now equal the base. | { kind: "tier"; slugs: string[]; tier: StoredChannelTier } // Pin ONE operation to a tier, or clear the pin (`tier: null` = inherit). | { kind: "operation"; slugs: string[]; operation: PriorityOperation; tier: StoredChannelTier | null } // The two presets. "sync-only" is `{tier:"paused", overrides:{sync:"normal"}}` // — keep the playlist current, dispatch nothing. "clear" returns the channel // to the default (normal, unranked, unpinned) by dropping its entry. | { kind: "preset"; slugs: string[]; preset: "sync-only" | "clear" } // The corpus-wide focus selector, including `{kind:"none"}` (End focus). | { kind: "focus"; focus: ChannelFocus } // "Add to the top of the download queue" — the channel takes the FIRST rank // and everything ranked at or below it shifts down one. Its base tier is // forced to `normal` because the gesture is "run this next" and a `low` or // `paused` channel ranked first is still behind (or absent from) every // normal one. See prioritizeChannelDownloadAction. | { kind: "promote"; slug: string } // NOT AN EDIT: the channel POPULATION changed (a channel was created or // deleted), so the four trees have to be re-derived from an unchanged // document. Never seeds — see the writer. | { kind: "recompile" } // A channel was RENAMED. The document keys by slug, so the entry and any // focus naming it move with it. Never seeds, for the same reason a // recompile does not: a rename is not a statement about priority. | { kind: "rename"; from: string; to: string }; function entryFor( model: ChannelPriority, slug: string, ): ChannelPriorityEntry { const existing = model.channels[slug]; return existing ? { ...existing, overrides: { ...(existing.overrides ?? {}) } } : { tier: DEFAULT_CHANNEL_TIER }; } // Pure. The sanitizer is what normalises the result — an override equal to the // base is dropped there, and so is an entry that says nothing the default does // not — so this only has to state the gesture. function applyPriorityEdit( model: ChannelPriority, edit: ChannelPriorityEdit, ): ChannelPriority { if (edit.kind === "recompile") return model; if (edit.kind === "rename") { return renameChannelInPriority(model, edit.from, edit.to); } if (edit.kind === "focus") return { ...model, focus: edit.focus }; if (edit.kind === "promote") { const slug = edit.slug.trim(); if (!slug) return model; const channels: Record = {}; // The rank to take: the smallest one in use, or 0 when nothing is ranked. // Everything at or below it shifts down one, so the promoted channel is // strictly first and the existing order below is preserved exactly. let top = Number.POSITIVE_INFINITY; for (const [s, entry] of Object.entries(model.channels)) { if (s !== slug && entry.rank !== undefined) { top = Math.min(top, entry.rank); } } const rank = Number.isFinite(top) ? top : 0; for (const [s, entry] of Object.entries(model.channels)) { channels[s] = s !== slug && entry.rank !== undefined && entry.rank >= rank ? { ...entry, rank: entry.rank + 1 } : { ...entry }; } channels[slug] = clearAutoPause({ ...entryFor(model, slug), tier: "normal", rank, }); return { ...model, channels }; } const channels: Record = { ...model.channels }; for (const raw of edit.slugs) { const slug = raw.trim(); if (!slug) continue; if (edit.kind === "tier") { // THE OPERATOR'S WORD WINS over the machine's. Setting a tier by hand // clears any auto-pause record, so a drive coming back later cannot // un-pause a channel a person paused in the meantime — nor re-pause one // they deliberately resumed while the drive was still away. channels[slug] = clearAutoPause({ ...entryFor(model, slug), tier: edit.tier, }); continue; } if (edit.kind === "operation") { const entry = entryFor(model, slug); const overrides = { ...(entry.overrides ?? {}) }; if (edit.tier === null) delete overrides[edit.operation]; else overrides[edit.operation] = edit.tier; channels[slug] = { ...entry, overrides }; continue; } if (edit.preset === "clear") { delete channels[slug]; continue; } // "sync-only": paused everywhere, normal for sync. Its rank survives — // the sync scheduler still orders it. const entry = entryFor(model, slug); channels[slug] = clearAutoPause({ ...entry, tier: "paused", overrides: { sync: "normal" }, }); } return { ...model, channels }; } // THE ONE WRITER. Reads the current settings, applies one edit through // `sanitizeChannelPriority`, recompiles the four lane roots from the result and // persists both in a single `writeSettings`. // // Each policy is SPREAD rather than rebuilt, so `enabled`, `held`, `order`, // `snoozeUntil`, `maxWorkers` and `replaceAutoSubs` survive a priority change — // the rule `saveAutoQueueAction` states: a focus must never start a stopped lane // or unhold a held one. export async function saveChannelPriorityAction( edit: ChannelPriorityEdit, // Lanes to ENABLE in the same write. The one thing a caller may ask for // beside the document, and only because "Add to the top of the download // queue" has always meant both: prioritise AND turn the lane on. Two writes // would race each other on one settings file. It only ever sets `enabled` // true — nothing here can hold, unhold or snooze a lane. opts?: { enableLanes?: readonly AutoQueueKind[] }, ): Promise { const paths = getPaths(); const settings = getSettings(); const stored = settings.channelPriority; const configs = await listChannelConfigs(paths); const slugs = configs.map((c) => c.slug); // THE LEGACY SEED, and it is the difference between this feature shipping // and this feature destroying the two hand-made 9+9 lane orders on its first // click (the S2/S3 review, finding 3, restated with its mechanism in 8). // // `laneDispatchRoot` is all-or-nothing on `isDefaultChannelPriority`: the // moment a document says ANYTHING, the stored trees stop being dispatched // from and the compiled ones take over. A first click that set one tier and // nothing else would therefore compile a tree in which no channel has a rank // — every one of them alphabetical inside `prio-normal` — and the operator's // order would be gone with no way back, because the trees it was written in // have just been overwritten. // // So: when the stored document says nothing AND the stored trees are not // already compiled, derive the document the migration would have produced // and apply the edit on top of THAT. The corpus's existing order survives a // first click by an operator who never ran the migration. // // NOT ON A `recompile` OR A `rename`: creating or renaming a channel is not // an operator's statement about priority, and neither must silently switch a // corpus from its stored trees to compiled ones. const base = edit.kind !== "recompile" && edit.kind !== "rename" && isDefaultChannelPriority(stored) && !hasCompiledLaneRoots(settings.autoQueue) ? channelPriorityFromLegacy( // THE SEED READS THE LANE ORDERS, NOT THE FLAG. `excludeFromSync` is // deleted and `parseChannelConfig` drops the key, so nothing the // editor can read still carries it — turning those 15 channels into // `overrides: {sync:"paused"}` is the migration script's half, off // the raw config.json. What this seed is for is the ORDER, and the // order lives in the stored trees. configs.map((c) => ({ slug: c.slug, config: {} })), settings.autoQueue, ) : stored; const next = sanitizeChannelPriority(applyPriorityEdit(base, edit)); // WHEN A DEFAULT DOCUMENT STILL HAS TO COMPILE, and the two halves of the // question are different corpora. // // The runner's bypass means a default document dispatches from the STORED // tree, so what matters here is what that stored tree is: // // never compiled (hand-made, or the shipped default) -> LEAVE IT. This is // the same rule `laneDispatchRoot` applies, and compiling over it would // replace an operator's tree with an all-normal one at the moment the last // priority was cleared. // // already compiled -> RECOMPILE, even for a default document. Ending a // focus leaves the document saying nothing while every stored root still // opens with `prio-focus` — and the runner, back on its bypass, would // dispatch from exactly that tree and go on holding the channels the focus // was just ended for. There is no hand-made tree left to protect on a // corpus whose roots the compiler already wrote. const autoQueue = { ...settings.autoQueue }; if ( !isDefaultChannelPriority(next) || hasCompiledLaneRoots(settings.autoQueue) ) { const focusSlugs = resolveFocusSlugs(next, siteChannelIndex(paths), slugs); const roots = compileLanes(next, slugs, focusSlugs); for (const lane of LANES) { autoQueue[lane] = { ...settings.autoQueue[lane], root: roots[lane] }; } } for (const lane of opts?.enableLanes ?? []) { autoQueue[lane] = { ...autoQueue[lane], enabled: true }; } try { // BOTH BLOCKS IN ONE WRITE — the reason saveSettings takes a whole-settings // patch rather than one block. `channelPriority` is a full document (its // `channels` map replaces), and `autoQueue` carries all four lanes. await saveSettings({ channelPriority: next, autoQueue }); } catch (e) { return { error: (e as Error).message }; } revalidatePath("/channels"); revalidatePath("/operations"); revalidatePath("/operations/[id]", "page"); return undefined; } // The named gestures. Each is one call to the writer above — they exist so a // control names what it does rather than assembling an edit union inline. export async function setChannelTierAction( slugs: string[], tier: StoredChannelTier, ): Promise { return saveChannelPriorityAction({ kind: "tier", slugs, tier }); } export async function setChannelOperationTierAction( slugs: string[], operation: PriorityOperation, tier: StoredChannelTier | null, ): Promise { return saveChannelPriorityAction({ kind: "operation", slugs, operation, tier, }); } export async function applyChannelPriorityPresetAction( slugs: string[], preset: "sync-only" | "clear", ): Promise { return saveChannelPriorityAction({ kind: "preset", slugs, preset }); } export async function focusChannelsAction( slugs: string[], ): Promise { return saveChannelPriorityAction({ kind: "focus", focus: { kind: "channels", slugs }, }); } export async function focusSiteAction(siteId: string): Promise { return saveChannelPriorityAction({ kind: "focus", focus: { kind: "site", siteId }, }); } // THE CHANNEL POPULATION CHANGED. A compiled tree names its channels one leaf // each, so a channel created or deleted since the last write leaves the stored // trees stale — the new one falls to the trailing catch-all (safe: bottom // priority) and the deleted one keeps a leaf that matches nothing. The runner // compiles per tick and is unaffected either way; what goes stale is what is // ON DISK, and therefore what every reader of the stored tree sees. // // One writer, so the recompile is this one too. Best-effort by design: the // channel was created or deleted regardless, and the next priority save heals // the tree anyway. export async function recompileChannelPriorityAction(): Promise { return saveChannelPriorityAction({ kind: "recompile" }); } // "Add to the top of the download queue", as a PRIORITY edit. // // It used to prepend a `prioritize-` leaf straight into // `autoQueue.download.root` (operations/actions.ts), which while a model // exists writes a tree the runner does not dispatch from — a second writer of // `root`, and a silent no-op. It is the same gesture said in the model's // vocabulary: first rank, everything below it shifted down, base tier normal, // and the download lane enabled in the same write. export async function prioritizeChannelDownloadPriorityAction( slug: string, ): Promise { const trimmed = (slug ?? "").trim(); if (!trimmed) return { error: "No channel slug supplied." }; return saveChannelPriorityAction( { kind: "promote", slug: trimmed }, { enableLanes: ["download"] }, ); } export async function endFocusAction(): Promise { return saveChannelPriorityAction({ kind: "focus", focus: { kind: "none" } }); }