Archilyzer · Source

archilyzer

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

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:
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/page.tsx | 27+++++++++++++++++++++++++++
Meditor/app/channels/actions.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Meditor/app/channels/components/ChannelForm.tsx | 36+++++++++++++++++++++---------------
Meditor/app/channels/components/ChannelFormClient.tsx | 26+++++++++++++++++++++-----
Meditor/app/channels/components/DeleteChannelForm.tsx | 1+
Aeditor/app/channels/components/SiteMembershipsSection.tsx | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/lib/siteMemberships.ts | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/new/page.tsx | 14+++++++++++++-
Aeditor/e2e/channel-site-membership.spec.ts | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/channels.spec.ts | 6++++--
Meditor/e2e/site-scope.spec.ts | 6+++++-
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");