commit 0a97fc07e198af8cedebed4a98586785e716052c
parent fe845f38f099b0791e14ded305221a3fd6da012a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 21 Jul 2026 23:50:29 -0400
Merge feat/channel-site-membership: channel form site membership + group selection
Diffstat:
12 files changed, 671 insertions(+), 46 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Channel forms now edit site membership directly — pick sites, groups, and create groups inline.** A channel's site membership used to be editable only from the Site form (`/sites/<id>`), and creating a channel silently appended it to the active site (a hidden `activeSite` field) with no group choice and no visibility. Both the **New channel** and channel **Configure** forms now carry a **Sites** section: every configured site listed with a membership checkbox and a compact group dropdown — `(default)`, any existing group, or **"+ New group…"**, which reveals a name input and creates the group on that site (id slugified from the name, reused if it already exists) as part of the save. On create, the active site (`?site=`) is pre-checked, reproducing the old behavior but visibly and overridably; on edit, current memberships pre-check with their groups, and unchecking removes the membership. The checked sites serialize into one hidden `siteMembershipsJson` field (the SiteForm hidden-JSON precedent); the server plans all writes up front — validating group ids, preserving other channels' entries and this channel's `order`, erroring clearly on a since-deleted group, skipping since-deleted sites, and rewriting only sites that actually changed. See the new `editor/app/channels/{lib/siteMemberships.ts,components/SiteMembershipsSection.tsx}`, `editor/app/channels/{actions.ts,components/{ChannelForm,ChannelFormClient}.tsx,new/page.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-site-membership.spec.ts`.
- **The monitor widget can now start a sync and shows sync freshness + scheduler health — each behind its own flag.** The widget was start-a-sync-less and said nothing about how fresh your channels were. Five new opt-in URL flags, all toggleable in the builder and the in-widget gear (all default off, so existing links are unchanged): **(1) a channel-aware Sync button** (`sync=1`) — pinned to one channel (`channel=X`) it runs that channel's streaming sync; otherwise it sweeps all channels (`syncAllChannelsAction`), briefly showing `Queued X · skipped Y`. **(2) A last-sync readout** (`lastsync=1`) — `Last full sync: …` from a new persisted `lastSyncAllAt` marker written at the end of each Sync-all sweep, plus a `Last channel sync: …` line when an individual channel synced more recently. **(3) A scheduler-status strip** (`sched=1`) — `Auto-sync on/off · next … · last run …`, derived from the same `buildScheduleView` the `/scheduler` page uses. **(4) An absolute-time toggle** (`abstime=1`) — readouts show locale timestamps instead of relative "5m ago". **(5) A Sync-all confirm** (`syncask=1`) — a `window.confirm` before a full sweep. The two readouts poll a new lightweight `/api/widget/sync` route (a few scalars, 15s floor) and, with the Sync button/controls, keep the widget from collapsing to "Idle" — sync health is exactly what you check when nothing's running. See `editor/app/widget/lib/config.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls,WidgetConfigForm}.tsx`, the new `editor/app/api/widget/sync/route.ts`, `common/jobs/syncSchedulerState.ts` (`lastSyncAllAt`), `editor/app/channels/actions.ts`, and `editor/e2e/widget.spec.ts`.
- **The audio-integrity check now tightens its probe interval when a source starts serving corruption, then relaxes as it stabilises.** Previously the integrity probe ran on a fixed cadence (default 60s) for the whole download, so up to ~60s of bytes were downloaded — and discarded — between a corruption event and the checkpoint that caught it. The interval is now adaptive (AIMD, like TCP congestion control, inverted): each **malformed** checkpoint **halves** the live interval (60→30→15→10s, floored at the existing `AUDIO_CHECK_INTERVAL_MIN_SECONDS` of 10s), so a misbehaving source gets probed more aggressively and wastes fewer bytes per rollback; a run of clean checkpoints then **steps it back up** additively (+15s after every 2 clean probes) toward the configured interval. The reduced cadence persists across yt-dlp relaunches for the rest of the download run. Fully backward compatible — a clean download never leaves the configured interval. Tunable via constants in `common/lib/channelConfig.ts` (`AUDIO_CHECK_INTERVAL_BACKOFF_FACTOR_DEFAULT`, `AUDIO_CHECK_INTERVAL_RECOVER_STEP_SECONDS`, `AUDIO_CHECK_INTERVAL_RECOVER_AFTER_CLEAN`) plus test-only env overrides. See `common/ytdlp/audioCheckCadence.ts` (pure AIMD math + `audioCheckCadence.test.ts`) and `common/ytdlp/audioCheckedDownload.ts` (`resolveKnobs`, the watcher loop, and the advance/malformed checkpoint branches).
- **Docker build mode is now real: build every site in parallel, then deploy them serially.** The `Docker` build mode (Settings → Build pipeline) was previously a stub that fell back to the basic build. It now runs a proper pipeline, driven by a new **Build all sites** control on the Deploy page (one job, one log, one Cancel). The shared, corpus-scale work — the search index, the per-site staging, and the downloadable archive zips — runs **once on the host**; then each site's `compose + next build` runs in its **own container in parallel** (capped by the **Max parallel builds** setting), each writing an isolated per-site `out/` under `export/.export-builds/<siteId>/`; then the built sites **deploy serially** on the host (R2 upload + `wrangler pages deploy`), tolerant of a single site failing. Containers are read-only over the shared corpus/index/archive cache and run as your host user so outputs aren't root-owned. The image (`Dockerfile.build`, tag from **Build image**) is built/reused via Docker layer caching; when no container engine is available the action falls back to a serial host build+deploy. New env knobs: `DOCKER_BIN` (e.g. `podman`), `DOCKER_BUILD_MEMORY`/`DOCKER_BUILD_CPUS` (per-container caps). See `editor/app/deploy/buildDeployCore.ts` (`runDockerBuildAllPhase`/`runDockerDeployAllPhase`), `editor/app/build/buildAction.ts` (`buildAndDeployAllSitesAction`/`buildAllSitesAction`), `editor/app/deploy/components/BuildAllSitesButton.tsx`, `Dockerfile.build`, `docker/build-site.sh`, `common/bin/build-archives.ts`, and **[DEPLOY_DOCKER.md](../DEPLOY_DOCKER.md)**.
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -35,7 +35,13 @@ import {
TRANSCRIPTION_QUEUE,
} from "yt-dlp-transcript-common/lib/platform";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { listSites } from "yt-dlp-transcript-common/lib/site";
+import { sortGroups } from "yt-dlp-transcript-common/lib/channelGroups";
import { ChannelFormClient } from "../components/ChannelFormClient";
+import type {
+ InitialMembership,
+ SiteMembershipOption,
+} from "../components/SiteMembershipsSection";
import { DeleteChannelForm } from "../components/DeleteChannelForm";
import { RenameChannelForm } from "../components/RenameChannelForm";
import { RunningJobsList } from "../../jobs/components/RunningJobsList";
@@ -219,6 +225,25 @@ export default async function ChannelDetailPage({
"danger",
];
+ // Sites membership section of the configure form: every configured site plus
+ // this channel's current membership (and group) on each.
+ const allSites = listSites(paths);
+ const siteOptions: SiteMembershipOption[] = allSites.map((s) => ({
+ siteId: s.siteId,
+ siteTitle: s.siteTitle,
+ defaultGroupId: s.defaultGroupId,
+ groups: sortGroups(s.groups).map((g) => ({ id: g.id, name: g.name })),
+ }));
+ const initialMemberships: InitialMembership[] = allSites.flatMap((s) => {
+ const m = s.channels.find((c) => c.slug === slug);
+ if (!m) return [];
+ return [
+ m.groupId
+ ? { siteId: s.siteId, groupId: m.groupId }
+ : { siteId: s.siteId },
+ ];
+ });
+
const panels: Partial<Record<StageId, ReactNode>> = {
configure: (
<ChannelFormClient
@@ -230,6 +255,8 @@ export default async function ChannelDetailPage({
}
initial={{ slug, config }}
submitLabel="Save changes"
+ sites={siteOptions}
+ initialMemberships={initialMemberships}
/>
),
playlist: (
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -17,12 +17,7 @@ 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,
- listSiteIds,
- writeSite,
-} from "yt-dlp-transcript-common/lib/site";
+import type { Site } from "yt-dlp-transcript-common/lib/site";
import { activeSyncSlugs } from "yt-dlp-transcript-common/jobs/syncJobs";
import {
readSchedulerState,
@@ -32,6 +27,11 @@ import {
CHANNEL_FORM_FIELDS,
parseChannelForm,
} from "./components/parseChannelForm";
+import {
+ applySiteWrites,
+ parseSiteMembershipsField,
+ planSiteMembershipWrites,
+} from "./lib/siteMemberships";
import { syncAction } from "./[slug]/pipelineActions";
export type ActionResult = { error: string } | undefined;
@@ -57,28 +57,35 @@ export async function createChannelAction(
};
}
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 { error: (e as Error).message };
+ }
if (await channelExists(paths, slug)) {
return { error: `Channel "${slug}" already exists` };
}
await createChannel(paths, slug, config);
- // When created under a specific active site, add the new channel to that
- // site's membership so it's visible in the scoped Channels list. No-op under
- // "all sites" (the hidden field is omitted by the form in that case).
- const activeSite = formData.get("activeSite");
- if (
- typeof activeSite === "string" &&
- isValidSiteId(activeSite) &&
- listSiteIds(paths).includes(activeSite)
- ) {
- const site = getSite(activeSite, paths);
- if (!site.channels.some((c) => c.slug === slug)) {
- await writeSite(
- { ...site, channels: [...site.channels, { slug }] },
- paths,
- );
- }
+ try {
+ await applySiteWrites(siteWrites, paths);
+ } catch (e) {
+ // The channel itself was created; don't redirect as if nothing happened.
+ return {
+ error: `Channel "${slug}" was created, but updating site memberships failed: ${(e as Error).message}. Open its Configure panel to retry.`,
+ };
}
revalidatePath("/channels");
+ revalidatePath("/sites");
revalidatePath("/");
redirect(`/channels/${slug}`);
}
@@ -97,6 +104,20 @@ export async function updateChannelAction(
const paths = getPaths();
const existing = await readChannelConfig(paths, slug);
if (!existing) return { error: `Channel "${slug}" not found` };
+ // 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 };
+ }
// 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 from `existing`. Clear every form-managed key from the
@@ -107,11 +128,18 @@ export async function updateChannelAction(
for (const key of CHANNEL_FORM_FIELDS) delete merged[key];
Object.assign(merged, parsed.config);
await writeChannelConfig(paths, slug, merged);
+ try {
+ await applySiteWrites(siteWrites, paths);
+ } catch (e) {
+ return { error: (e as Error).message };
+ }
// 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;
}
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -15,15 +15,23 @@ import {
AUDIO_CHECK_MAX_ROLLBACKS_MIN,
} from "yt-dlp-transcript-common/lib/channelConfig";
import { SYNC_INTERVAL_PRESETS } from "../../scheduler/intervalPresets";
+import {
+ SiteMembershipsSection,
+ type InitialMembership,
+ type SiteMembershipOption,
+} from "./SiteMembershipsSection";
type Props = {
action: string | ((formData: FormData) => void | Promise<void>);
initial?: { slug: string; config: ChannelConfig };
submitLabel: string;
errorMessage?: string;
- // Extra hidden fields submitted with the form (e.g. the active site so a newly
- // created channel can be added to that site's membership).
- hiddenFields?: Record<string, string>;
+ // All configured sites, for the Sites membership section.
+ sites: SiteMembershipOption[];
+ // Edit mode: the channel's current site memberships.
+ initialMemberships?: InitialMembership[];
+ // Create mode: the active site (from ?site=) to pre-check.
+ activeSiteId?: string | null;
};
export function ChannelForm({
@@ -31,16 +39,14 @@ export function ChannelForm({
initial,
submitLabel,
errorMessage,
- hiddenFields,
+ sites,
+ initialMemberships,
+ activeSiteId,
}: Props) {
const isEdit = !!initial;
const c = initial?.config;
return (
<form action={action} className="flex flex-col gap-4 max-w-xl">
- {hiddenFields &&
- Object.entries(hiddenFields).map(([name, value]) => (
- <input key={name} type="hidden" name={name} defaultValue={value} />
- ))}
{errorMessage && (
<div className="rounded border border-destructive/30 bg-destructive-soft px-3 py-2 text-sm text-destructive">
{errorMessage}
@@ -69,13 +75,13 @@ export function ChannelForm({
placeholder="kebab-case (auto-derived from name if blank)"
/>
)}
- <p className="text-xs text-muted-foreground">
- Channel grouping is now configured per site under{" "}
- <a href="/sites" className="underline">
- Sites
- </a>
- , where each site picks its own channels and their groups.
- </p>
+ </Section>
+ <Section title="Sites">
+ <SiteMembershipsSection
+ sites={sites}
+ initialMemberships={initialMemberships}
+ activeSiteId={activeSiteId}
+ />
</Section>
<Section title="Source">
<fieldset className="flex flex-col gap-2">
diff --git a/editor/app/channels/components/ChannelFormClient.tsx b/editor/app/channels/components/ChannelFormClient.tsx
@@ -5,6 +5,10 @@ import { ChannelForm } from "./ChannelForm";
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
import type { ActionResult } from "../actions";
import { ALL_SITES } from "../../lib/activeSite";
+import type {
+ InitialMembership,
+ SiteMembershipOption,
+} from "./SiteMembershipsSection";
type Props = {
action: (
@@ -13,16 +17,26 @@ type Props = {
) => Promise<ActionResult>;
initial?: { slug: string; config: ChannelConfig };
submitLabel: string;
+ sites: SiteMembershipOption[];
+ initialMemberships?: InitialMembership[];
};
-export function ChannelFormClient({ action, initial, submitLabel }: Props) {
+export function ChannelFormClient({
+ action,
+ initial,
+ submitLabel,
+ sites,
+ initialMemberships,
+}: Props) {
const [state, formAction] = useActionState<ActionResult, FormData>(
action,
undefined,
);
// The active site is mirrored into the `?site=` URL param by SiteScopeSelect.
// Read it here (avoiding useSearchParams so this form needn't be wrapped in a
- // Suspense boundary) so a newly created channel joins that site's membership.
+ // Suspense boundary) so a newly created channel pre-checks that site in the
+ // Sites membership section. Only meaningful in create mode — an existing
+ // channel's memberships come from initialMemberships.
const [activeSite, setActiveSite] = useState<string | null>(null);
useEffect(() => {
try {
@@ -31,15 +45,17 @@ export function ChannelFormClient({ action, initial, submitLabel }: Props) {
/* ignore */
}
}, []);
- const hiddenFields =
- activeSite && activeSite !== ALL_SITES ? { activeSite } : undefined;
+ const activeSiteId =
+ !initial && activeSite && activeSite !== ALL_SITES ? activeSite : null;
return (
<ChannelForm
action={formAction}
initial={initial}
submitLabel={submitLabel}
errorMessage={state?.error}
- hiddenFields={hiddenFields}
+ sites={sites}
+ initialMemberships={initialMemberships}
+ activeSiteId={activeSiteId}
/>
);
}
diff --git a/editor/app/channels/components/DeleteChannelForm.tsx b/editor/app/channels/components/DeleteChannelForm.tsx
@@ -27,6 +27,7 @@ export function DeleteChannelForm({ slug, action }: Props) {
name="confirmSlug"
required
placeholder={slug}
+ aria-label="confirm slug to delete"
className="rounded border border-border bg-card px-2 py-1 text-sm font-mono"
/>
<button
diff --git a/editor/app/channels/components/SiteMembershipsSection.tsx b/editor/app/channels/components/SiteMembershipsSection.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+// The channel form's per-site membership picker: every configured site with a
+// checkbox (member or not) plus a group dropdown, including a "+ New group…"
+// option that reveals a name input. Serializes the checked sites into the
+// hidden `siteMembershipsJson` field consumed by lib/siteMemberships.ts —
+// same hidden-JSON-field pattern as SiteForm's groupsJson/channelsJson.
+
+export type SiteMembershipOption = {
+ siteId: string;
+ siteTitle: string;
+ defaultGroupId: string;
+ // Pre-sorted server-side via sortGroups.
+ groups: { id: string; name: string }[];
+};
+
+export type InitialMembership = { siteId: string; groupId?: string };
+
+// Sentinel select value for "+ New group…". Can never collide with a real
+// group id — underscores fail isValidGroupId (same trick as ALL_SITES).
+const NEW_GROUP = "__new__";
+
+type Row = { groupId: string; newGroupName: string };
+
+type Props = {
+ sites: SiteMembershipOption[];
+ // Edit mode: the channel's current memberships (pre-checked).
+ initialMemberships?: InitialMembership[];
+ // Create mode: the active site from `?site=` to pre-check. Arrives via a
+ // mount effect in ChannelFormClient, before any user interaction.
+ activeSiteId?: string | null;
+};
+
+export function SiteMembershipsSection({
+ sites,
+ initialMemberships,
+ activeSiteId,
+}: Props) {
+ // Presence in the map = checked. groupId "" = the site's default group.
+ const [selected, setSelected] = useState<Map<string, Row>>(
+ () =>
+ new Map(
+ (initialMemberships ?? []).map((m) => [
+ m.siteId,
+ { groupId: m.groupId ?? "", newGroupName: "" },
+ ]),
+ ),
+ );
+
+ // Create-mode pre-check of the active site (mirrors the old hidden
+ // `activeSite` field's behavior). Fires before user interaction, so no
+ // clobber guard beyond "already checked" is needed.
+ useEffect(() => {
+ if (!activeSiteId || !sites.some((s) => s.siteId === activeSiteId)) return;
+ setSelected((prev) => {
+ if (prev.has(activeSiteId)) return prev;
+ const next = new Map(prev);
+ next.set(activeSiteId, { groupId: "", newGroupName: "" });
+ return next;
+ });
+ }, [activeSiteId, sites]);
+
+ if (sites.length === 0) {
+ // No hidden field at all: the actions skip membership reconciliation.
+ return (
+ <p className="text-xs text-muted-foreground">
+ No sites configured — create one under{" "}
+ <a href="/sites" className="underline">
+ Sites
+ </a>
+ .
+ </p>
+ );
+ }
+
+ const toggle = (siteId: string, on: boolean) =>
+ setSelected((prev) => {
+ const next = new Map(prev);
+ if (on) next.set(siteId, prev.get(siteId) ?? { groupId: "", newGroupName: "" });
+ else next.delete(siteId);
+ return next;
+ });
+ const setGroup = (siteId: string, groupId: string) =>
+ setSelected((prev) => {
+ const next = new Map(prev);
+ const row = prev.get(siteId) ?? { groupId: "", newGroupName: "" };
+ next.set(siteId, { ...row, groupId });
+ return next;
+ });
+ const setNewGroupName = (siteId: string, newGroupName: string) =>
+ setSelected((prev) => {
+ const next = new Map(prev);
+ const row = prev.get(siteId) ?? { groupId: "", newGroupName: "" };
+ next.set(siteId, { ...row, newGroupName });
+ return next;
+ });
+
+ // One entry per CHECKED site; an unchecked site is simply absent (= remove
+ // membership on edit). See MembershipRequest in lib/siteMemberships.ts.
+ const membershipsJson = JSON.stringify(
+ sites
+ .filter((s) => selected.has(s.siteId))
+ .map((s) => {
+ const row = selected.get(s.siteId)!;
+ if (row.groupId === NEW_GROUP) {
+ const name = row.newGroupName.trim();
+ // The visible name input is `required`, so an empty name never
+ // submits; falling back to default is belt-and-braces only.
+ return name
+ ? { siteId: s.siteId, newGroupName: name }
+ : { siteId: s.siteId };
+ }
+ return row.groupId
+ ? { siteId: s.siteId, groupId: row.groupId }
+ : { siteId: s.siteId };
+ }),
+ );
+
+ return (
+ <div className="flex flex-col gap-1">
+ <input type="hidden" name="siteMembershipsJson" value={membershipsJson} />
+ <p className="text-xs text-muted-foreground">
+ Pick the sites this channel appears on, and the group it belongs to on
+ each.
+ </p>
+ {sites.map((s) => {
+ const row = selected.get(s.siteId);
+ const on = !!row;
+ const label = s.siteTitle || s.siteId;
+ return (
+ <div
+ key={s.siteId}
+ className="flex flex-col gap-1 py-1 border-b border-border last:border-b-0"
+ >
+ <div className="flex items-center gap-2 text-sm">
+ <label className="flex items-center gap-2 flex-1 min-w-0">
+ <input
+ type="checkbox"
+ checked={on}
+ onChange={(e) => toggle(s.siteId, e.target.checked)}
+ aria-label={`Include on ${label}`}
+ />
+ <span className="truncate">
+ {label}{" "}
+ <code className="text-xs text-muted-foreground">
+ {s.siteId}
+ </code>
+ </span>
+ </label>
+ {on && (
+ <select
+ value={row.groupId}
+ onChange={(e) => setGroup(s.siteId, e.target.value)}
+ aria-label={`Group for ${label}`}
+ className="rounded border border-border bg-card px-1.5 py-0.5 text-xs shrink-0"
+ >
+ <option value="">(default)</option>
+ {s.groups.map((g) => (
+ <option key={g.id} value={g.id}>
+ {g.name || g.id}
+ </option>
+ ))}
+ <option value={NEW_GROUP}>+ New group…</option>
+ </select>
+ )}
+ </div>
+ {on && row.groupId === NEW_GROUP && (
+ <input
+ type="text"
+ value={row.newGroupName}
+ onChange={(e) => setNewGroupName(s.siteId, e.target.value)}
+ required
+ placeholder="New group name"
+ aria-label={`New group name for ${label}`}
+ className="ml-6 rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ )}
+ </div>
+ );
+ })}
+ <p className="text-xs text-muted-foreground">
+ Full group management (rename, reorder, defaults) lives under{" "}
+ <a href="/sites" className="underline">
+ Sites
+ </a>
+ .
+ </p>
+ </div>
+ );
+}
diff --git a/editor/app/channels/lib/siteMemberships.ts b/editor/app/channels/lib/siteMemberships.ts
@@ -0,0 +1,150 @@
+import slugify from "@sindresorhus/slugify";
+import { isValidGroupId } from "yt-dlp-transcript-common/lib/channelGroups";
+import type { Paths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getSite,
+ listSiteIds,
+ writeSite,
+ type Site,
+ type SiteChannelMembership,
+} from "yt-dlp-transcript-common/lib/site";
+
+// Server-side half of the channel form's "Sites" membership section (see
+// SiteMembershipsSection.tsx). The form serializes one entry per CHECKED site
+// into the hidden `siteMembershipsJson` field; a site absent from the array
+// means "not a member" (removal on edit). The field being absent entirely
+// (zero sites configured, or a legacy submit) means "don't touch memberships".
+
+export type MembershipRequest = {
+ siteId: string;
+ // Explicit existing group on that site; absent = the site's default group.
+ groupId?: string;
+ // Mutually exclusive with groupId: create this group (id = slugify(name))
+ // on the site and assign the channel to it.
+ newGroupName?: string;
+};
+
+// Parse the raw hidden-field value. Returns null when the field is absent so
+// callers can skip membership reconciliation entirely; throws on a payload
+// that isn't a JSON array (never produced by our form).
+export function parseSiteMembershipsField(
+ raw: FormDataEntryValue | null,
+): MembershipRequest[] | null {
+ if (typeof raw !== "string") return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new Error("Malformed site memberships payload");
+ }
+ if (!Array.isArray(parsed)) {
+ throw new Error("Malformed site memberships payload");
+ }
+ const out: MembershipRequest[] = [];
+ const seen = new Set<string>();
+ for (const entry of parsed) {
+ if (!entry || typeof entry !== "object") continue;
+ const r = entry as Record<string, unknown>;
+ if (typeof r.siteId !== "string" || !r.siteId || seen.has(r.siteId)) {
+ continue;
+ }
+ seen.add(r.siteId);
+ const req: MembershipRequest = { siteId: r.siteId };
+ // newGroupName wins when both are present (the form never emits both).
+ if (typeof r.newGroupName === "string" && r.newGroupName.trim()) {
+ req.newGroupName = r.newGroupName.trim();
+ } else if (typeof r.groupId === "string" && r.groupId.trim()) {
+ req.groupId = r.groupId.trim();
+ }
+ out.push(req);
+ }
+ return out;
+}
+
+// Compute the Site objects that need rewriting so `slug`'s membership matches
+// `requests`. Pure planning: reads every configured site, validates the
+// requests (throws a user-facing Error on bad input), and returns ONLY the
+// sites whose groups/membership actually changed — a no-op save rewrites
+// nothing. Requests naming unknown siteIds are silently skipped (the site was
+// deleted since the form loaded).
+export function planSiteMembershipWrites(
+ paths: Paths,
+ slug: string,
+ requests: MembershipRequest[],
+): Site[] {
+ const bySiteId = new Map(requests.map((r) => [r.siteId, r]));
+ const changed: Site[] = [];
+ for (const siteId of listSiteIds(paths)) {
+ const site = getSite(siteId, paths);
+ const request = bySiteId.get(siteId);
+ const existing = site.channels.find((c) => c.slug === slug);
+
+ // Resolve the target group (possibly creating one) for a checked site.
+ let groups = site.groups;
+ let groupId: string | undefined;
+ if (request) {
+ if (request.newGroupName) {
+ const id = slugify(request.newGroupName);
+ if (!isValidGroupId(id)) {
+ throw new Error(
+ `Could not derive a valid group id from "${request.newGroupName}" (site "${siteId}")`,
+ );
+ }
+ // Same slug as an existing group -> reuse it (idempotent resubmits).
+ if (!groups.some((g) => g.id === id)) {
+ // Matches SiteForm's addGroup default: new groups start unselected.
+ groups = [
+ ...groups,
+ { id, name: request.newGroupName, selectedByDefault: false },
+ ];
+ }
+ groupId = id;
+ } else if (request.groupId) {
+ // Explicit group must still exist — writeSite would silently drop an
+ // unknown groupId from the membership, so fail loudly instead.
+ if (!groups.some((g) => g.id === request.groupId)) {
+ throw new Error(
+ `Group "${request.groupId}" no longer exists on site "${siteId}"`,
+ );
+ }
+ groupId = request.groupId;
+ }
+ }
+
+ // Next membership list: preserve every other channel's entry untouched and
+ // keep this channel's existing extra fields (notably `order`).
+ let channels: SiteChannelMembership[];
+ if (!request) {
+ channels = existing
+ ? site.channels.filter((c) => c.slug !== slug)
+ : site.channels;
+ } else if (existing) {
+ channels = site.channels.map((c) => {
+ if (c.slug !== slug) return c;
+ if (groupId) return { ...c, groupId };
+ const { groupId: _drop, ...rest } = c;
+ return rest;
+ });
+ } else {
+ channels = [...site.channels, groupId ? { slug, groupId } : { slug }];
+ }
+
+ const groupsChanged = groups !== site.groups;
+ const membershipChanged = request
+ ? !existing || (existing.groupId ?? undefined) !== groupId
+ : !!existing;
+ if (groupsChanged || membershipChanged) {
+ changed.push({ ...site, groups, channels });
+ }
+ }
+ return changed;
+}
+
+export async function applySiteWrites(
+ sites: Site[],
+ paths: Paths,
+): Promise<void> {
+ for (const site of sites) {
+ await writeSite(site, paths);
+ }
+}
diff --git a/editor/app/channels/new/page.tsx b/editor/app/channels/new/page.tsx
@@ -1,11 +1,22 @@
import type { Metadata } from "next";
import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listSites } from "yt-dlp-transcript-common/lib/site";
+import { sortGroups } from "yt-dlp-transcript-common/lib/channelGroups";
import { ChannelFormClient } from "../components/ChannelFormClient";
+import type { SiteMembershipOption } from "../components/SiteMembershipsSection";
import { createChannelAction } from "../actions";
+export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "New channel — Channels" };
-export default function NewChannelPage() {
+export default async function NewChannelPage() {
+ const sites: SiteMembershipOption[] = listSites(getPaths()).map((s) => ({
+ siteId: s.siteId,
+ siteTitle: s.siteTitle,
+ defaultGroupId: s.defaultGroupId,
+ groups: sortGroups(s.groups).map((g) => ({ id: g.id, name: g.name })),
+ }));
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -19,6 +30,7 @@ export default function NewChannelPage() {
<ChannelFormClient
action={createChannelAction}
submitLabel="Create channel"
+ sites={sites}
/>
</div>
);
diff --git a/editor/e2e/channel-site-membership.spec.ts b/editor/e2e/channel-site-membership.spec.ts
@@ -0,0 +1,186 @@
+import { test, expect, type Page } from "@playwright/test";
+import { readJson, resetData, writeSite } from "./helpers";
+
+// The channel form's "Sites" membership section: per-site checkbox + group
+// dropdown (+ inline "+ New group…") on both the create and edit screens.
+
+type SiteFile = {
+ groups: { id: string; name: string; selectedByDefault: boolean }[];
+ channels: { slug: string; groupId?: string; order?: number }[];
+};
+
+// Sentinel select value for "+ New group…" (SiteMembershipsSection.tsx).
+const NEW_GROUP = "__new__";
+
+// Two sites over the shared two-channel pool. Alpha has a second group and
+// slow-b sits in it with an explicit order; beta has just the default group.
+async function seed() {
+ await resetData("two-slow-channels");
+ await writeSite("alpha", {
+ siteTitle: "Alpha",
+ groups: [
+ { id: "default", name: "All channels", selectedByDefault: true },
+ { id: "news", name: "News", selectedByDefault: false },
+ ],
+ channels: [
+ { slug: "slow-a" },
+ { slug: "slow-b", groupId: "news", order: 5 },
+ ],
+ });
+ await writeSite("beta", { siteTitle: "Beta" });
+}
+
+const readSite = (id: string) =>
+ readJson<SiteFile>(`test-transcripts/sites/${id}/site.json`);
+
+// The membership inputs are controlled React state that the edit page also
+// server-renders, so a pre-hydration click can be silently swallowed (see the
+// charts-metadata lost-click incident). Both helpers verify each interaction
+// against React-rendered output (the group select / new-group input only
+// exist when state says so) and retry until it sticks.
+async function setSiteChecked(page: Page, site: string, on: boolean) {
+ const groupSelect = page.getByLabel(`Group for ${site}`);
+ await expect(async () => {
+ if (((await groupSelect.count()) > 0) !== on) {
+ await page.getByLabel(`Include on ${site}`).click();
+ }
+ await expect(groupSelect).toHaveCount(on ? 1 : 0, { timeout: 1_000 });
+ }).toPass({ timeout: 15_000 });
+}
+
+async function selectGroup(page: Page, site: string, value: string) {
+ const groupSelect = page.getByLabel(`Group for ${site}`);
+ const nameInput = page.getByLabel(`New group name for ${site}`);
+ // Selecting "+ New group…" reveals a React-rendered input — proof the change
+ // handler ran. Once that sticks, picking the real value is safe.
+ await expect(async () => {
+ await groupSelect.selectOption(NEW_GROUP);
+ await expect(nameInput).toBeVisible({ timeout: 1_000 });
+ }).toPass({ timeout: 15_000 });
+ if (value !== NEW_GROUP) {
+ await groupSelect.selectOption(value);
+ await expect(nameInput).toHaveCount(0);
+ }
+}
+
+test("create: active site pre-checked, explicit group + second site", async ({
+ page,
+}) => {
+ await seed();
+ await page.goto("/channels/new?site=alpha");
+ // The pre-check happens client-side only (mount effect), so this assertion
+ // doubles as a hydration gate for the plain interactions below.
+ await expect(page.getByLabel("Include on Alpha")).toBeChecked();
+ await expect(page.getByLabel("Include on Beta")).not.toBeChecked();
+
+ await page.getByLabel("Group for Alpha").selectOption("news");
+ await page.getByLabel("Include on Beta").check();
+ await page.getByLabel(/^name/i).fill("Gamma Channel");
+ await page.getByLabel(/^slug/i).fill("gamma");
+ await page.getByRole("button", { name: /create channel/i }).click();
+ await page.waitForURL("**/channels/gamma", { timeout: 10_000 });
+
+ const alpha = await readSite("alpha");
+ expect(alpha.channels).toContainEqual({ slug: "gamma", groupId: "news" });
+ const beta = await readSite("beta");
+ expect(beta.channels).toContainEqual({ slug: "gamma" });
+});
+
+test("create: + New group creates the group and assigns the channel", async ({
+ page,
+}) => {
+ await seed();
+ await page.goto("/channels/new?site=alpha");
+ await expect(page.getByLabel("Include on Alpha")).toBeChecked();
+
+ await selectGroup(page, "Alpha", NEW_GROUP);
+ await page.getByLabel("New group name for Alpha").fill("Interviews");
+ await page.getByLabel(/^name/i).fill("Delta Channel");
+ await page.getByLabel(/^slug/i).fill("delta");
+ await page.getByRole("button", { name: /create channel/i }).click();
+ await page.waitForURL("**/channels/delta", { timeout: 10_000 });
+
+ const alpha = await readSite("alpha");
+ expect(alpha.groups).toContainEqual({
+ id: "interviews",
+ name: "Interviews",
+ selectedByDefault: false,
+ });
+ expect(alpha.channels).toContainEqual({
+ slug: "delta",
+ groupId: "interviews",
+ });
+});
+
+test("edit: switching news → (default) keeps order, sibling untouched", async ({
+ page,
+}) => {
+ await seed();
+ await page.goto("/channels/slow-b");
+ await expect(page.getByLabel("Include on Alpha")).toBeChecked();
+ await expect(page.getByLabel("Group for Alpha")).toHaveValue("news");
+
+ await selectGroup(page, "Alpha", "");
+ await page.getByRole("button", { name: /save changes/i }).click();
+
+ await expect
+ .poll(async () =>
+ (await readSite("alpha")).channels.find((c) => c.slug === "slow-b"),
+ )
+ .toEqual({ slug: "slow-b", order: 5 });
+ const alpha = await readSite("alpha");
+ expect(alpha.channels).toContainEqual({ slug: "slow-a" });
+});
+
+test("edit: unchecking removes the membership, sibling intact", async ({
+ page,
+}) => {
+ await seed();
+ await page.goto("/channels/slow-b");
+ await expect(page.getByLabel("Include on Alpha")).toBeChecked();
+
+ await setSiteChecked(page, "Alpha", false);
+ await page.getByRole("button", { name: /save changes/i }).click();
+
+ await expect
+ .poll(async () => (await readSite("alpha")).channels.map((c) => c.slug))
+ .toEqual(["slow-a"]);
+});
+
+test("edit: + New group on another site", async ({ page }) => {
+ await seed();
+ await page.goto("/channels/slow-a");
+ await expect(page.getByLabel("Include on Alpha")).toBeChecked();
+ await expect(page.getByLabel("Include on Beta")).not.toBeChecked();
+
+ await setSiteChecked(page, "Beta", true);
+ await selectGroup(page, "Beta", NEW_GROUP);
+ await page.getByLabel("New group name for Beta").fill("Interviews");
+ await page.getByRole("button", { name: /save changes/i }).click();
+
+ await expect
+ .poll(async () => (await readSite("beta")).groups)
+ .toContainEqual({
+ id: "interviews",
+ name: "Interviews",
+ selectedByDefault: false,
+ });
+ const beta = await readSite("beta");
+ expect(beta.channels).toContainEqual({
+ slug: "slow-a",
+ groupId: "interviews",
+ });
+});
+
+test("zero sites: informational note, create still succeeds", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await page.goto("/channels/new");
+ await expect(page.getByText(/no sites configured/i)).toBeVisible();
+
+ await page.getByLabel(/^name/i).fill("Solo Channel");
+ await page.getByLabel(/^slug/i).fill("solo");
+ await page.getByRole("button", { name: /create channel/i }).click();
+ await page.waitForURL("**/channels/solo", { timeout: 10_000 });
+});
diff --git a/editor/e2e/channels.spec.ts b/editor/e2e/channels.spec.ts
@@ -74,12 +74,14 @@ test("requires typed-confirmation to delete", async ({ page }) => {
await resetData("one-youtube-channel");
await page.goto("/channels/test-youtube");
- await page.getByPlaceholder("test-youtube").fill("wrong-slug");
+ // getByPlaceholder would be ambiguous: the rename form's confirm input
+ // shares the slug placeholder.
+ await page.getByLabel("confirm slug to delete").fill("wrong-slug");
await page.getByRole("button", { name: /delete channel/i }).click();
await expect(page.getByText(/type the channel slug/i)).toBeVisible();
expect(await pathExists("test-transcripts/channels/test-youtube")).toBe(true);
- await page.getByPlaceholder("test-youtube").fill("test-youtube");
+ await page.getByLabel("confirm slug to delete").fill("test-youtube");
await page.getByRole("button", { name: /delete channel/i }).click();
await page.waitForURL("**/channels", { timeout: 10_000 });
expect(await pathExists("test-transcripts/channels/test-youtube")).toBe(false);
diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts
@@ -76,8 +76,9 @@ test("deploy targets the active site and is disabled under all sites", async ({
await expect(
page.getByText(/select a specific site from the sidebar to build & deploy\./i),
).toBeVisible();
+ // exact: the "Build & deploy all sites" batch button also matches otherwise.
await expect(
- page.getByRole("button", { name: "Build & deploy" }),
+ page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeDisabled();
await page.goto("/deploy?site=alpha");
@@ -104,6 +105,9 @@ test("creating a channel under a site adds it to that site's membership", async
}) => {
await twoSites();
await page.goto("/channels/new?site=alpha");
+ // The Sites section pre-checks the active site — that's what carries the
+ // membership now (the old hidden activeSite field is gone).
+ await expect(page.getByLabel("Include on Alpha")).toBeChecked();
await page.getByLabel(/^name/i).fill("Gamma Channel");
await page.getByLabel(/^slug/i).fill("gamma");
await page.getByLabel(/^url/i).fill("https://www.youtube.com/@gamma/videos");