commit 4d61c8d58ad75f52f6898a0eacc644587f4697cf
parent 90fd8452e28d057895650bf68e67df69a5f9f3f1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 30 May 2026 02:01:38 -0400
multi-site support
Diffstat:
56 files changed, 2478 insertions(+), 1104 deletions(-)
diff --git a/common/bin/build-chart-templates.ts b/common/bin/build-chart-templates.ts
@@ -1,10 +1,20 @@
#!/usr/bin/env tsx
-// Bakes the editor-authored chart templates into the static export's public
-// dir as chart-templates.json. Falls back to built-in presets when no
-// templates file has been authored.
+// Bakes each site's editor-authored chart templates into its export staging dir
+// as chart-templates.json, composed into the site's bundle by build:site. Falls
+// back to built-in presets when a site has authored no templates file.
import { getPaths } from "../lib/paths";
-import { syncTemplatesToExport, exportTemplatesPath } from "../lib/chartsStore";
+import { listSiteIds } from "../lib/site";
+import {
+ syncTemplatesToExport,
+ siteTemplatesStagingPath,
+} from "../lib/chartsStore";
const paths = getPaths();
-syncTemplatesToExport(paths);
-console.log(`Wrote ${exportTemplatesPath(paths)}`);
+const siteIds = listSiteIds(paths);
+if (siteIds.length === 0) {
+ console.log("No sites configured; nothing to do.");
+}
+for (const siteId of siteIds) {
+ syncTemplatesToExport(paths, siteId);
+ console.log(`Wrote ${siteTemplatesStagingPath(paths, siteId)}`);
+}
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -0,0 +1,113 @@
+#!/usr/bin/env tsx
+// Composes the served export public dir for ONE site (SITE_ID) ahead of
+// `next build`. Next.js serves a fixed `public/` dir, so per-site bundles are
+// assembled into it one at a time:
+//
+// public/summaries/* <- sites/<id>/summaries/ (per-site)
+// public/subs/manifest.json <- sites/<id>/subs/manifest.json (per-site)
+// public/subs/<slug>/ <- shared/subs/<slug>/ (site's members)
+// public/transcripts/<slug>/ <- shared/transcripts/<slug>/ (site's members)
+// public/stats/* <- sites/<id>/stats/ (per-site)
+// public/chart-templates.json <- sites/<id>/chart-templates.json (per-site)
+//
+// Only the site's member channels are composed, so each deployed bundle holds
+// only that site's data. Checked-in static assets in public/ are left intact.
+
+import path from "node:path";
+import { cp, mkdir, rm, readdir, access } from "node:fs/promises";
+import { getPaths } from "../lib/paths";
+import { getSite } from "../lib/site";
+
+async function exists(p: string): Promise<boolean> {
+ try {
+ await access(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function replaceDir(src: string, dest: string): Promise<void> {
+ await rm(dest, { recursive: true, force: true });
+ if (await exists(src)) {
+ await mkdir(path.dirname(dest), { recursive: true });
+ await cp(src, dest, { recursive: true });
+ }
+}
+
+async function main(): Promise<void> {
+ const siteId = process.env.SITE_ID;
+ if (!siteId) {
+ console.error("compose-site: SITE_ID env var is required");
+ process.exit(1);
+ }
+ const paths = getPaths();
+ const site = getSite(siteId, paths);
+ const memberSlugs = site.channels.map((c) => c.slug);
+
+ // --- per-site aggregates (whole-dir / single-file swaps) ---
+ await replaceDir(
+ path.join(paths.exportSitesIndexDir, siteId, "summaries"),
+ paths.exportSummariesDir,
+ );
+ await replaceDir(
+ path.join(paths.exportSitesIndexDir, siteId, "stats"),
+ paths.exportStatsDir,
+ );
+
+ // --- shared per-channel trees, filtered to this site's members ---
+ // Transcripts: replace the whole tree with the member subset.
+ await rm(paths.exportTranscriptsDir, { recursive: true, force: true });
+ await mkdir(paths.exportTranscriptsDir, { recursive: true });
+ for (const slug of memberSlugs) {
+ const src = path.join(paths.exportSharedTranscriptsDir, slug);
+ if (await exists(src)) {
+ await cp(src, path.join(paths.exportTranscriptsDir, slug), {
+ recursive: true,
+ });
+ }
+ }
+
+ // Subs: per-channel dirs are shared; the site-level manifest.json is per-site.
+ await rm(paths.exportSubsDir, { recursive: true, force: true });
+ await mkdir(paths.exportSubsDir, { recursive: true });
+ for (const slug of memberSlugs) {
+ const src = path.join(paths.exportSharedSubsDir, slug);
+ if (await exists(src)) {
+ await cp(src, path.join(paths.exportSubsDir, slug), { recursive: true });
+ }
+ }
+ const subsManifestSrc = path.join(
+ paths.exportSitesIndexDir,
+ siteId,
+ "subs",
+ "manifest.json",
+ );
+ if (await exists(subsManifestSrc)) {
+ await cp(subsManifestSrc, path.join(paths.exportSubsDir, "manifest.json"));
+ }
+
+ // --- charts dashboard ---
+ const templatesSrc = path.join(
+ paths.exportSitesIndexDir,
+ siteId,
+ "chart-templates.json",
+ );
+ const templatesDest = path.join(paths.exportPublicDir, "chart-templates.json");
+ await rm(templatesDest, { force: true });
+ if (await exists(templatesSrc)) {
+ await cp(templatesSrc, templatesDest);
+ }
+
+ const channelDirs = (await readdir(paths.exportTranscriptsDir).catch(
+ () => [] as string[],
+ )).length;
+ console.log(
+ `Composed site "${siteId}" into ${paths.exportPublicDir} (${channelDirs} channels).`,
+ );
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/common/bin/migrate-to-sites.ts b/common/bin/migrate-to-sites.ts
@@ -0,0 +1,26 @@
+#!/usr/bin/env tsx
+// One-time migration from the single-site layout into sites/<id>/site.json.
+// Idempotent: a no-op once any site exists. Optional first arg overrides the
+// generated site id.
+import { getPaths } from "../lib/paths";
+import { migrateToSites } from "../controller/migrateToSites";
+
+const siteId = process.argv[2];
+migrateToSites({
+ paths: getPaths(),
+ siteId,
+ onLog: (msg) => console.log(msg),
+})
+ .then((res) => {
+ if (!res.migrated) {
+ console.log(`Nothing to do: ${res.reason}`);
+ } else {
+ console.log(
+ `Migrated into site "${res.siteId}" (${res.channelCount} channels).`,
+ );
+ }
+ })
+ .catch((err) => {
+ console.error(err);
+ process.exit(1);
+ });
diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts
@@ -65,6 +65,7 @@ import {
subsPageFileName,
} from "../lib/manifest";
import { getSettings } from "../lib/settings";
+import { listSites, siteSummariesDir, siteSubsDir } from "../lib/site";
import {
parseChannelConfig,
type ChannelConfig,
@@ -82,7 +83,20 @@ import {
import { loadAvailability } from "../lib/availability-server";
import { AVAILABILITY_FILENAME } from "../lib/availability";
-const SCHEMA_VERSION = 9;
+// v10: multi-site build. Shared per-channel transcript/subs pages are written
+// once; per-site summaries + subs manifests are filtered selections. Bumped to
+// force a clean rebuild into the new shared/ + sites/ staging layout.
+const SCHEMA_VERSION = 10;
+
+// Per-channel subtitle stats, collected while writing the shared subs pages and
+// persisted to LMDB so per-site subs manifests can be assembled on a no-op
+// rebuild without re-encoding every channel's pages.
+type ChannelSubsStat = {
+ name: string;
+ videoCount: number;
+ tracks: string[];
+ liveChatCount: number;
+};
type IndexKey = [string, string, string];
type ChannelKey = [string, string, string];
@@ -289,19 +303,20 @@ export async function buildIndex({
const channelsDir = paths.channelsDir;
const dbPath = paths.lmdbPath;
- const summariesDir = paths.exportSummariesDir;
- const transcriptsOutDir = paths.exportTranscriptsDir;
- const subsOutDir = paths.exportSubsDir;
- const manifestPath = path.join(summariesDir, "manifest.json");
+ // Shared, site-agnostic per-channel page trees (built once). Per-site
+ // summaries + subs manifests are written under exportSitesIndexDir/<siteId>/
+ // in the per-site loop below.
+ const transcriptsOutDir = paths.exportSharedTranscriptsDir;
+ const subsOutDir = paths.exportSharedSubsDir;
await mkdir(path.dirname(dbPath), { recursive: true });
- await mkdir(summariesDir, { recursive: true });
await mkdir(transcriptsOutDir, { recursive: true });
await mkdir(subsOutDir, { recursive: true });
+ await mkdir(paths.exportSitesIndexDir, { recursive: true });
const root = open({
path: dbPath,
- maxDbs: 12,
+ maxDbs: 14,
compression: true,
});
const sums = root.openDB<TranscriptSummary, IndexKey>({
@@ -332,6 +347,12 @@ export async function buildIndex({
name: "subPageHashes",
encoding: "msgpack",
});
+ // slug -> ChannelSubsStat, persisted so per-site subs manifests survive a
+ // no-op rebuild where the shared subs pages aren't re-encoded.
+ const channelStatsDb = root.openDB<ChannelSubsStat, string>({
+ name: "channelStats",
+ encoding: "msgpack",
+ });
const meta = root.openDB<unknown, string>({
name: "meta",
encoding: "msgpack",
@@ -350,6 +371,7 @@ export async function buildIndex({
await byChannel.clearAsync();
await pageHashes.clearAsync();
await subPageHashes.clearAsync();
+ await channelStatsDb.clearAsync();
await meta.put("schema", SCHEMA_VERSION);
}
@@ -390,154 +412,33 @@ export async function buildIndex({
const anyMutations =
added.length > 0 || changed.length > 0 || removed.length > 0;
- const siteSettings = getSettings();
- const {
- maxTranscriptPageBytes: configuredMaxPageBytes,
- groups: settingsGroups,
- defaultGroupId: settingsDefaultGroupId,
- } = siteSettings;
-
- // name -> groupId map, computed once. Uses each channel's optional
- // `group` field, folding unknown/missing onto the configured default.
- const channelGroupByName = new Map<string, string>();
- for (const cfg of channelConfigs.values()) {
- if (!cfg.name) continue;
- channelGroupByName.set(
- cfg.name,
- resolveChannelGroupId(cfg.group, settingsGroups, settingsDefaultGroupId),
- );
- }
- // Force-invalidate the manifest fast-path when the configured groups change
- // even if transcript data is untouched — otherwise the export UI would
- // keep rendering the previous group layout until something else mutates.
- const groupsFingerprint = JSON.stringify({
- g: settingsGroups,
- d: settingsDefaultGroupId,
- });
- const manifestGroupsMatch = (parsed: Manifest): boolean => {
- return (
- JSON.stringify({ g: parsed.groups ?? null, d: parsed.defaultGroupId ?? null }) ===
- groupsFingerprint
- );
- };
- // Catches per-channel `group` field edits in channels/*/config.json, which
- // leave every other diff signal (video mtimes, settings groupsFingerprint)
- // untouched. Without this the fast-path would short-circuit and the export
- // UI would keep rendering the old group layout.
- const manifestChannelGroupsMatch = (
- channels: ReadonlyArray<{ name: string; groupId?: string }>,
- ): boolean => {
- if (channels.length !== channelGroupByName.size) return false;
- for (const ch of channels) {
- const expected = channelGroupByName.get(ch.name);
- if (expected === undefined) return false;
- if ((ch.groupId ?? settingsDefaultGroupId) !== expected) return false;
- }
- return true;
- };
- if (!anyMutations && !schemaBumped) {
- const manifestRaw = await readFile(manifestPath, "utf8").catch(() => null);
- if (manifestRaw) {
+ const { maxTranscriptPageBytes: configuredMaxPageBytes } = getSettings();
+
+ // The shared per-channel page trees (transcripts + subs) only need rewriting
+ // when source data changed, the schema bumped, or a previous shared build is
+ // missing/incomplete. Per-site aggregates are always regenerated (cheaply)
+ // from LMDB further below so they reflect current site config.
+ const sharedManifestsPresent = async (): Promise<boolean> => {
+ for (const channelSlug of channelConfigs.keys()) {
+ const mPath = path.join(transcriptsOutDir, channelSlug, "manifest.json");
+ const raw = await readFile(mPath, "utf8").catch(() => null);
+ if (!raw) return false;
try {
- const parsed = JSON.parse(manifestRaw) as Manifest;
+ const cm = JSON.parse(raw) as ChannelTranscriptsManifest;
if (
- parsed.version === MANIFEST_VERSION &&
- parsed.totalCount === live.length &&
- parsed.pageSize === SUMMARIES_PAGE_SIZE &&
- manifestGroupsMatch(parsed) &&
- manifestChannelGroupsMatch(parsed.channels)
+ cm.version !== TRANSCRIPTS_MANIFEST_VERSION ||
+ cm.maxPageBytes !== configuredMaxPageBytes
) {
- const firstPage = path.join(summariesDir, pageFileName(0));
- const lastPage = path.join(
- summariesDir,
- pageFileName(Math.max(0, parsed.pageCount - 1)),
- );
- let transcriptsIntact = true;
- for (const channelSlug of channelConfigs.keys()) {
- const mPath = path.join(
- transcriptsOutDir,
- channelSlug,
- "manifest.json",
- );
- const raw = await readFile(mPath, "utf8").catch(() => null);
- if (!raw) {
- transcriptsIntact = false;
- break;
- }
- try {
- const cm = JSON.parse(raw) as ChannelTranscriptsManifest;
- if (
- cm.version !== TRANSCRIPTS_MANIFEST_VERSION ||
- cm.maxPageBytes !== configuredMaxPageBytes
- ) {
- transcriptsIntact = false;
- break;
- }
- } catch {
- transcriptsIntact = false;
- break;
- }
- }
- let subsIntact = true;
- const subsManifestPath = path.join(subsOutDir, "manifest.json");
- const subsManifestRaw = await readFile(subsManifestPath, "utf8").catch(
- () => null,
- );
- if (subsManifestRaw) {
- try {
- const sm = JSON.parse(subsManifestRaw) as SubsManifest;
- if (sm.version !== SUBS_MANIFEST_VERSION) {
- subsIntact = false;
- } else {
- // Same staleness concern as the summaries manifest: subs UI
- // also renders grouped channels, so a per-channel group edit
- // must invalidate this manifest too.
- for (const entry of sm.channels) {
- const expected = channelGroupByName.get(entry.name);
- if (expected === undefined) continue;
- if ((entry.groupId ?? settingsDefaultGroupId) !== expected) {
- subsIntact = false;
- break;
- }
- }
- }
- } catch {
- subsIntact = false;
- }
- }
- // A missing subs manifest is fine — channels may simply have no subs.
- // But if it exists at an older version, force a rebuild so the
- // export UI sees the current schema.
- if (
- transcriptsIntact &&
- subsIntact &&
- (await exists(firstPage)) &&
- (await exists(lastPage))
- ) {
- log(
- `Index up to date (${live.length} transcripts, ${parsed.pageCount} pages). Skipping.`,
- );
- await root.close();
- return {
- totalCount: parsed.totalCount,
- pageCount: parsed.pageCount,
- channelCount: parsed.channels.length,
- durationMs: Date.now() - t0,
- pagesWritten: 0,
- pagesSkipped: 0,
- pagesDeleted: 0,
- added: 0,
- changed: 0,
- removed: 0,
- shortCircuited: true,
- };
- }
+ return false;
}
} catch {
- // fall through
+ return false;
}
}
- }
+ return true;
+ };
+ const sharedNeedsBuild =
+ anyMutations || schemaBumped || !(await sharedManifestsPresent());
log(
`Diff: +${added.length} added, ~${changed.length} changed, -${removed.length} removed, ${live.length} total.`,
@@ -835,6 +736,7 @@ export async function buildIndex({
return { push, finish };
};
+ if (sharedNeedsBuild) {
for (const channelSlug of Array.from(channelConfigs.keys()).sort()) {
const channelDir = path.join(transcriptsOutDir, channelSlug);
await mkdir(channelDir, { recursive: true });
@@ -921,6 +823,9 @@ export async function buildIndex({
log(
`Transcript pages: ${pagesWritten} written, ${pagesSkipped} unchanged, ${pagesDeleted} stale removed.`,
);
+ } else {
+ log(`Shared transcript pages up to date; skipping rewrite.`);
+ }
// Build lookups from indexKey → isDeleted / isUnlisted from MtimeRecord, which
// caches the availability check done at mutation-processing time. Previously
@@ -942,233 +847,385 @@ export async function buildIndex({
let subsPagesWritten = 0;
let subsPagesSkipped = 0;
let subsPagesDeleted = 0;
- const subsChannelEntries: SubsChannelEntry[] = [];
let subsTotalCount = 0;
- let subsLiveChatTotalCount = 0;
-
- for (const channelSlug of Array.from(channelConfigs.keys()).sort()) {
- const cfg = channelConfigs.get(channelSlug)!;
- const subsChannelDir = path.join(subsOutDir, channelSlug);
- const tracksInChannel = new Set<string>();
- let videoCount = 0;
- let liveChatCount = 0;
-
- const subWriter = createPageWriter({
- outDir: subsChannelDir,
- fileName: subsPageFileName,
- maxPageBytes: maxTranscriptPageBytes,
- ensureDir: true,
- getPrevHash: (idx) => subPageHashes.get([channelSlug, idx])?.hash,
- setHash: (idx, record) => {
- subPageHashes.put([channelSlug, idx], record);
- },
- onLog: log,
- });
+ // slug -> per-channel subtitle stats (site-agnostic). Populated when the
+ // shared subs pages are (re)written, otherwise loaded from LMDB so per-site
+ // subs manifests survive a no-op rebuild.
+ const channelStats = new Map<string, ChannelSubsStat>();
+
+ if (sharedNeedsBuild) {
+ for (const channelSlug of Array.from(channelConfigs.keys()).sort()) {
+ const cfg = channelConfigs.get(channelSlug)!;
+ const subsChannelDir = path.join(subsOutDir, channelSlug);
+ const tracksInChannel = new Set<string>();
+ let videoCount = 0;
+ let liveChatCount = 0;
+
+ const subWriter = createPageWriter({
+ outDir: subsChannelDir,
+ fileName: subsPageFileName,
+ maxPageBytes: maxTranscriptPageBytes,
+ ensureDir: true,
+ getPrevHash: (idx) => subPageHashes.get([channelSlug, idx])?.hash,
+ setHash: (idx, record) => {
+ subPageHashes.put([channelSlug, idx], record);
+ },
+ onLog: log,
+ });
- for (const { key } of byChannel.getRange({
- start: [channelSlug],
- end: [channelSlug, ""],
- })) {
- const ck = key as ChannelKey;
- if (ck[0] !== channelSlug) continue;
- const indexKey: IndexKey = [ck[1], ck[0], ck[2]];
- const stored = subs.get(indexKey);
- if (!stored || stored.length === 0) continue;
- const summary = sums.get(indexKey);
- if (!summary) continue;
- const subsKey = `${indexKey[0]}\x00${indexKey[1]}\x00${indexKey[2]}`;
- const isDeleted = deletedByIndexKey.has(subsKey);
- const isUnlisted = unlistedByIndexKey.has(subsKey);
- const display = toDisplaySummary(summary, { isDeleted, isUnlisted });
- const tracks: Record<string, Cue[]> = {};
- let hasLiveChat = false;
- for (const t of stored) {
- tracks[t.track] = t.cues;
- tracksInChannel.add(t.track);
- if (t.track === "live_chat") hasLiveChat = true;
+ for (const { key } of byChannel.getRange({
+ start: [channelSlug],
+ end: [channelSlug, ""],
+ })) {
+ const ck = key as ChannelKey;
+ if (ck[0] !== channelSlug) continue;
+ const indexKey: IndexKey = [ck[1], ck[0], ck[2]];
+ const stored = subs.get(indexKey);
+ if (!stored || stored.length === 0) continue;
+ const summary = sums.get(indexKey);
+ if (!summary) continue;
+ const subsKey = `${indexKey[0]}\x00${indexKey[1]}\x00${indexKey[2]}`;
+ const isDeleted = deletedByIndexKey.has(subsKey);
+ const isUnlisted = unlistedByIndexKey.has(subsKey);
+ const display = toDisplaySummary(summary, { isDeleted, isUnlisted });
+ const tracks: Record<string, Cue[]> = {};
+ let hasLiveChat = false;
+ for (const t of stored) {
+ tracks[t.track] = t.cues;
+ tracksInChannel.add(t.track);
+ if (t.track === "live_chat") hasLiveChat = true;
+ }
+ if (hasLiveChat) liveChatCount++;
+ const detail: SubsDetail = { ...display, tracks };
+ const encoded = JSON.stringify(detail);
+ await subWriter.push(encoded, summary.id);
+ videoCount++;
}
- if (hasLiveChat) liveChatCount++;
- const detail: SubsDetail = { ...display, tracks };
- const encoded = JSON.stringify(detail);
- await subWriter.push(encoded, summary.id);
- videoCount++;
- }
- const { pageCount: subPageCount, pagesWritten: chSubsWritten, pagesSkipped: chSubsSkipped, slugToPage: subSlugToPage } =
- await subWriter.finish();
- subsPagesWritten += chSubsWritten;
- subsPagesSkipped += chSubsSkipped;
+ const { pageCount: subPageCount, pagesWritten: chSubsWritten, pagesSkipped: chSubsSkipped, slugToPage: subSlugToPage } =
+ await subWriter.finish();
+ subsPagesWritten += chSubsWritten;
+ subsPagesSkipped += chSubsSkipped;
+
+ if (videoCount === 0) {
+ // No subs for this channel — remove any stale dir + hashes.
+ await rm(subsChannelDir, { recursive: true, force: true });
+ for (const { key } of subPageHashes.getRange({
+ start: [channelSlug],
+ end: [channelSlug, Number.MAX_SAFE_INTEGER],
+ })) {
+ subPageHashes.remove(key as PageHashKey);
+ }
+ continue;
+ }
- if (videoCount === 0) {
- // No subs for this channel — remove any stale dir + hashes.
- await rm(subsChannelDir, { recursive: true, force: true });
+ const subKeep = new Set<string>(["manifest.json"]);
+ for (let i = 0; i < subPageCount; i++) subKeep.add(subsPageFileName(i));
+ const subExisting = await readdir(subsChannelDir).catch(
+ () => [] as string[],
+ );
+ for (const name of subExisting) {
+ if (subKeep.has(name)) continue;
+ await rm(path.join(subsChannelDir, name), { force: true });
+ subsPagesDeleted++;
+ }
for (const { key } of subPageHashes.getRange({
- start: [channelSlug],
+ start: [channelSlug, subPageCount],
end: [channelSlug, Number.MAX_SAFE_INTEGER],
})) {
subPageHashes.remove(key as PageHashKey);
}
- continue;
+
+ const tracksList = Array.from(tracksInChannel).sort();
+ const channelSubsManifest: ChannelSubsManifest = {
+ version: SUBS_CHANNEL_MANIFEST_VERSION,
+ channelSlug,
+ pageCount: subPageCount,
+ maxPageBytes: maxTranscriptPageBytes,
+ generatedAt,
+ tracks: tracksList,
+ slugToPage: subSlugToPage,
+ };
+ await writeJsonAtomic(
+ path.join(subsChannelDir, "manifest.json"),
+ channelSubsManifest,
+ );
+ channelStats.set(channelSlug, {
+ name: cfg.name ?? channelSlug,
+ videoCount,
+ tracks: tracksList,
+ liveChatCount,
+ });
+ subsTotalCount += videoCount;
}
- const subKeep = new Set<string>(["manifest.json"]);
- for (let i = 0; i < subPageCount; i++) subKeep.add(subsPageFileName(i));
- const subExisting = await readdir(subsChannelDir).catch(
- () => [] as string[],
- );
- for (const name of subExisting) {
- if (subKeep.has(name)) continue;
- await rm(path.join(subsChannelDir, name), { force: true });
- subsPagesDeleted++;
+ await subPageHashes.flushed;
+
+ // Top-level cleanup: drop shared sub dirs for channels that no longer exist.
+ const topSubsEntries = await readdir(subsOutDir, {
+ withFileTypes: true,
+ }).catch(() => [] as Dirent[]);
+ const subsChannelSlugSet = new Set(channelStats.keys());
+ for (const e of topSubsEntries) {
+ if (e.isDirectory()) {
+ if (!subsChannelSlugSet.has(e.name)) {
+ await rm(path.join(subsOutDir, e.name), {
+ recursive: true,
+ force: true,
+ });
+ }
+ } else if (e.isFile()) {
+ // The site-level subs manifest is now per-site; the shared subs root
+ // holds only per-channel dirs.
+ await rm(path.join(subsOutDir, e.name), { force: true });
+ }
}
- for (const { key } of subPageHashes.getRange({
- start: [channelSlug, subPageCount],
- end: [channelSlug, Number.MAX_SAFE_INTEGER],
- })) {
- subPageHashes.remove(key as PageHashKey);
+
+ // Persist per-channel subs stats for no-op rebuilds.
+ await channelStatsDb.clearAsync();
+ for (const [slug, statRec] of channelStats) {
+ channelStatsDb.put(slug, statRec);
}
+ await channelStatsDb.flushed;
- const tracksList = Array.from(tracksInChannel).sort();
- const channelSubsManifest: ChannelSubsManifest = {
- version: SUBS_CHANNEL_MANIFEST_VERSION,
- channelSlug,
- pageCount: subPageCount,
- maxPageBytes: maxTranscriptPageBytes,
- generatedAt,
- tracks: tracksList,
- slugToPage: subSlugToPage,
- };
- await writeJsonAtomic(
- path.join(subsChannelDir, "manifest.json"),
- channelSubsManifest,
+ log(
+ `Sub pages: ${subsPagesWritten} written, ${subsPagesSkipped} unchanged, ${subsPagesDeleted} stale removed across ${channelStats.size} channels (${subsTotalCount} sub videos).`,
);
- const subsEntryName = cfg.name ?? channelSlug;
- subsChannelEntries.push({
- name: subsEntryName,
- slug: channelSlug,
- videoCount,
- tracks: tracksList,
- liveChatCount,
- groupId:
- channelGroupByName.get(subsEntryName) ??
- resolveChannelGroupId(cfg.group, settingsGroups, settingsDefaultGroupId),
- });
- subsTotalCount += videoCount;
- subsLiveChatTotalCount += liveChatCount;
+ } else {
+ for (const { key, value } of channelStatsDb.getRange()) {
+ channelStats.set(key as string, value as ChannelSubsStat);
+ }
+ log(`Shared sub pages up to date; ${channelStats.size} channels with subs.`);
}
- await subPageHashes.flushed;
+ // ---------------------------------------------------------------------------
+ // Per-site aggregates: a filtered summaries index (pages + manifest) and a
+ // site-level subs manifest, one bundle per configured site. The heavy
+ // per-channel page trees above are shared; here we only select + regroup.
+ // ---------------------------------------------------------------------------
+ const pageSize = SUMMARIES_PAGE_SIZE;
- // Top-level cleanup: drop sub dirs for channels that no longer exist.
- const topSubsEntries = await readdir(subsOutDir, {
- withFileTypes: true,
- }).catch(() => [] as Dirent[]);
- const subsChannelSlugSet = new Set(subsChannelEntries.map((e) => e.slug));
- for (const e of topSubsEntries) {
- if (e.isDirectory()) {
- if (!subsChannelSlugSet.has(e.name)) {
- await rm(path.join(subsOutDir, e.name), {
- recursive: true,
- force: true,
- });
+ // Generation counter: bumped whenever the underlying video data changed, so a
+ // site's cached fingerprint invalidates when shared data moved even if the
+ // site's own config is untouched.
+ let generation = (meta.get("generation") as number | undefined) ?? 0;
+ if (anyMutations || schemaBumped) {
+ generation++;
+ await meta.put("generation", generation);
+ }
+
+ const sites = listSites(paths);
+ const siteIds = new Set(sites.map((s) => s.siteId));
+ let sitesBuilt = 0;
+ let sitesSkipped = 0;
+ let aggregateSummaryPages = 0;
+ let aggregateSummaries = 0;
+ let representativeChannelCount = 0;
+
+ for (const site of sites) {
+ const memberSlugs = site.channels.map((c) => c.slug);
+ const slugSet = new Set(memberSlugs);
+ const slugGroup = new Map<string, string>();
+ for (const m of site.channels) {
+ slugGroup.set(
+ m.slug,
+ resolveChannelGroupId(m.groupId, site.groups, site.defaultGroupId),
+ );
+ }
+
+ const summariesOut = siteSummariesDir(paths, site.siteId);
+ const subsOut = siteSubsDir(paths, site.siteId);
+ const summariesManifestPath = path.join(summariesOut, "manifest.json");
+ const subsManifestPath = path.join(subsOut, "manifest.json");
+
+ const fingerprint = JSON.stringify({
+ gen: generation,
+ max: configuredMaxPageBytes,
+ groups: site.groups,
+ defaultGroupId: site.defaultGroupId,
+ channels: site.channels,
+ });
+ const fpKey = `siteFp:${site.siteId}`;
+ if (
+ !schemaBumped &&
+ (meta.get(fpKey) as string | undefined) === fingerprint &&
+ (await exists(summariesManifestPath)) &&
+ (await exists(subsManifestPath))
+ ) {
+ sitesSkipped++;
+ try {
+ const m = JSON.parse(
+ await readFile(summariesManifestPath, "utf8"),
+ ) as Manifest;
+ aggregateSummaryPages += m.pageCount;
+ aggregateSummaries += m.totalCount;
+ representativeChannelCount = Math.max(
+ representativeChannelCount,
+ m.channels.length,
+ );
+ } catch {
+ // ignore
}
- } else if (e.isFile() && e.name !== "manifest.json") {
- await rm(path.join(subsOutDir, e.name), { force: true });
+ log(`Site ${site.siteId}: up to date; skipping.`);
+ continue;
}
- }
- const subsManifestOut: SubsManifest = {
- version: SUBS_MANIFEST_VERSION,
- channels: subsChannelEntries.sort((a, b) => a.name.localeCompare(b.name)),
- totalCount: subsTotalCount,
- liveChatTotalCount: subsLiveChatTotalCount,
- generatedAt,
- groups: settingsGroups,
- defaultGroupId: settingsDefaultGroupId,
- };
- await writeJsonAtomic(
- path.join(subsOutDir, "manifest.json"),
- subsManifestOut,
- );
+ await mkdir(summariesOut, { recursive: true });
+ await mkdir(subsOut, { recursive: true });
+
+ // --- summaries pages (filtered to member channels) ---
+ // Keyed by slug so we can attach the per-site groupId; member channels with
+ // no data yet still appear (count 0).
+ const chan = new Map<
+ string,
+ { name: string; count: number; groupId: string }
+ >();
+ for (const slug of memberSlugs) {
+ const name = channelConfigs.get(slug)?.name;
+ if (!name) continue;
+ chan.set(slug, {
+ name,
+ count: 0,
+ groupId: slugGroup.get(slug) ?? site.defaultGroupId,
+ });
+ }
- log(
- `Sub pages: ${subsPagesWritten} written, ${subsPagesSkipped} unchanged, ${subsPagesDeleted} stale removed across ${subsChannelEntries.length} channels (${subsTotalCount} sub videos).`,
- );
+ let pageIndex = 0;
+ let buffer: DisplaySummary[] = [];
+ let total = 0;
+ const flushPage = async () => {
+ if (buffer.length === 0) return;
+ await writeJsonAtomic(
+ path.join(summariesOut, pageFileName(pageIndex)),
+ buffer,
+ );
+ buffer = [];
+ pageIndex++;
+ };
- const pageSize = SUMMARIES_PAGE_SIZE;
- const channelCounts = new Map<string, number>();
- for (const cfg of channelConfigs.values()) {
- if (cfg.name) channelCounts.set(cfg.name, 0);
+ for (const { key, value } of sums.getRange({ reverse: true })) {
+ const ik = key as IndexKey;
+ if (!slugSet.has(ik[1])) continue;
+ const s = value as TranscriptSummary;
+ const acc = chan.get(ik[1]);
+ if (acc) acc.count++;
+ const summaryKey = `${ik[0]}\x00${ik[1]}\x00${ik[2]}`;
+ buffer.push(
+ toDisplaySummary(s, {
+ isDeleted: deletedByIndexKey.has(summaryKey),
+ isUnlisted: unlistedByIndexKey.has(summaryKey),
+ }),
+ );
+ total++;
+ if (buffer.length >= pageSize) await flushPage();
+ }
+ await flushPage();
+
+ const expectedPages = new Set<string>();
+ for (let i = 0; i < pageIndex; i++) expectedPages.add(pageFileName(i));
+ for (const name of await readdir(summariesOut).catch(() => [] as string[])) {
+ if (!name.startsWith("page-")) continue;
+ if (!expectedPages.has(name)) {
+ await rm(path.join(summariesOut, name), { force: true });
+ }
+ }
+
+ const channelList: ChannelEntry[] = Array.from(chan.values())
+ .map(({ name, count, groupId }) => ({ name, count, groupId }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ const summariesManifest: Manifest = {
+ version: MANIFEST_VERSION,
+ totalCount: total,
+ pageSize,
+ pageCount: pageIndex,
+ generatedAt: new Date().toISOString(),
+ channels: channelList,
+ groups: site.groups,
+ defaultGroupId: site.defaultGroupId,
+ siteId: site.siteId,
+ };
+ await writeJsonAtomic(summariesManifestPath, summariesManifest);
+
+ // --- site-level subs manifest (filtered to member channels) ---
+ const subsEntries: SubsChannelEntry[] = [];
+ let subsTotalForSite = 0;
+ let liveChatTotalForSite = 0;
+ for (const slug of memberSlugs) {
+ const statRec = channelStats.get(slug);
+ if (!statRec) continue;
+ subsEntries.push({
+ name: statRec.name,
+ slug,
+ videoCount: statRec.videoCount,
+ tracks: statRec.tracks,
+ liveChatCount: statRec.liveChatCount,
+ groupId: slugGroup.get(slug) ?? site.defaultGroupId,
+ });
+ subsTotalForSite += statRec.videoCount;
+ liveChatTotalForSite += statRec.liveChatCount;
+ }
+ const siteSubsManifest: SubsManifest = {
+ version: SUBS_MANIFEST_VERSION,
+ channels: subsEntries.sort((a, b) => a.name.localeCompare(b.name)),
+ totalCount: subsTotalForSite,
+ liveChatTotalCount: liveChatTotalForSite,
+ generatedAt: new Date().toISOString(),
+ groups: site.groups,
+ defaultGroupId: site.defaultGroupId,
+ siteId: site.siteId,
+ };
+ await writeJsonAtomic(subsManifestPath, siteSubsManifest);
+
+ await meta.put(fpKey, fingerprint);
+ sitesBuilt++;
+ aggregateSummaryPages += pageIndex;
+ aggregateSummaries += total;
+ representativeChannelCount = Math.max(
+ representativeChannelCount,
+ channelList.length,
+ );
+ log(
+ `Site ${site.siteId}: ${total} summaries across ${pageIndex} pages; ${channelList.length} channels.`,
+ );
}
- let pageIndex = 0;
- let buffer: DisplaySummary[] = [];
- let total = 0;
-
- const flushPage = async () => {
- if (buffer.length === 0) return;
- const p = path.join(summariesDir, pageFileName(pageIndex));
- await writeJsonAtomic(p, buffer);
- buffer = [];
- pageIndex++;
- };
- for (const { key, value } of sums.getRange({ reverse: true })) {
- const ik = key as IndexKey;
- const s = value as TranscriptSummary;
- if (s.channel) {
- channelCounts.set(s.channel, (channelCounts.get(s.channel) ?? 0) + 1);
+ // Drop staging dirs + fingerprints for sites that no longer exist.
+ const stagedSiteDirs = await readdir(paths.exportSitesIndexDir, {
+ withFileTypes: true,
+ }).catch(() => [] as Dirent[]);
+ for (const e of stagedSiteDirs) {
+ if (e.isDirectory() && !siteIds.has(e.name)) {
+ await rm(path.join(paths.exportSitesIndexDir, e.name), {
+ recursive: true,
+ force: true,
+ });
}
- const summaryKey = `${ik[0]}\x00${ik[1]}\x00${ik[2]}`;
- const isDeleted = deletedByIndexKey.has(summaryKey);
- const isUnlisted = unlistedByIndexKey.has(summaryKey);
- buffer.push(toDisplaySummary(s, { isDeleted, isUnlisted }));
- total++;
- if (buffer.length >= pageSize) await flushPage();
}
- await flushPage();
-
- const expectedPages = new Set<string>();
- for (let i = 0; i < pageIndex; i++) expectedPages.add(pageFileName(i));
- const existing = await readdir(summariesDir).catch(() => [] as string[]);
- for (const name of existing) {
- if (!name.startsWith("page-")) continue;
- if (!expectedPages.has(name)) {
- await rm(path.join(summariesDir, name), { force: true });
+ const staleFpKeys: string[] = [];
+ for (const { key } of meta.getRange()) {
+ const k = key as string;
+ if (
+ typeof k === "string" &&
+ k.startsWith("siteFp:") &&
+ !siteIds.has(k.slice("siteFp:".length))
+ ) {
+ staleFpKeys.push(k);
}
}
-
- const channelList: ChannelEntry[] = Array.from(channelCounts.entries())
- .map(([name, count]) => ({
- name,
- count,
- groupId:
- channelGroupByName.get(name) ??
- resolveChannelGroupId(undefined, settingsGroups, settingsDefaultGroupId),
- }))
- .sort((a, b) => a.name.localeCompare(b.name));
-
- const manifest: Manifest = {
- version: MANIFEST_VERSION,
- totalCount: total,
- pageSize,
- pageCount: pageIndex,
- generatedAt: new Date().toISOString(),
- channels: channelList,
- groups: settingsGroups,
- defaultGroupId: settingsDefaultGroupId,
- };
- await writeJsonAtomic(manifestPath, manifest);
+ for (const k of staleFpKeys) meta.remove(k);
+ await meta.flushed;
await root.close();
const durationMs = Date.now() - t0;
log(
- `Done in ${(durationMs / 1000).toFixed(2)}s. ${total} transcripts across ${pageIndex} pages; ${channelList.length} channels.`,
+ `Done in ${(durationMs / 1000).toFixed(2)}s. ${sites.length} site(s): ${sitesBuilt} built, ${sitesSkipped} up to date; ${live.length} transcripts in pool.`,
);
return {
- totalCount: total,
- pageCount: pageIndex,
- channelCount: channelList.length,
+ totalCount: aggregateSummaries,
+ pageCount: aggregateSummaryPages,
+ channelCount: representativeChannelCount,
durationMs,
pagesWritten,
pagesSkipped,
@@ -1176,6 +1233,6 @@ export async function buildIndex({
added: added.length,
changed: changed.length,
removed: removed.length,
- shortCircuited: false,
+ shortCircuited: !sharedNeedsBuild && sitesBuilt === 0,
};
}
diff --git a/common/controller/buildStats.ts b/common/controller/buildStats.ts
Binary files differ.
diff --git a/common/controller/migrateToSites.ts b/common/controller/migrateToSites.ts
@@ -0,0 +1,155 @@
+// One-time migration from the pre-multi-site layout (a single global
+// settings.json holding branding + channel groups + social links, with each
+// channel carrying a `group` field, and a root chart-templates.json) into the
+// multi-site layout (sites/<id>/site.json + per-site chart-templates.json, with
+// settings.json reduced to operational config). Idempotent: a no-op once any
+// site exists.
+
+import fs from "node:fs";
+import path from "node:path";
+import type { Paths } from "../lib/paths";
+import { getPaths } from "../lib/paths";
+import { listSiteIds, writeSite, siteChartTemplatesFile, type Site } from "../lib/site";
+import {
+ parseChannelGroups,
+ resolveChannelGroupId,
+ resolveDefaultGroupId,
+ FALLBACK_GROUP,
+ type ChannelGroup,
+} from "../lib/channelGroups";
+import {
+ parseSocialLinks,
+ writeSettings,
+ getSettings,
+ DEFAULT_ADMIN_TITLE,
+} from "../lib/settings";
+
+export type MigrateToSitesOptions = {
+ paths?: Paths;
+ // Target site id for the migrated config. Defaults to a slug derived from the
+ // old siteTitle, falling back to "default".
+ siteId?: string;
+ onLog?: (msg: string) => void;
+};
+
+export type MigrateToSitesResult =
+ | { migrated: false; reason: string }
+ | { migrated: true; siteId: string; channelCount: number };
+
+function slugify(input: string): string {
+ const s = input
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return s || "default";
+}
+
+function readRawSettings(file: string): Record<string, unknown> {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
+ return parsed && typeof parsed === "object"
+ ? (parsed as Record<string, unknown>)
+ : {};
+ } catch {
+ return {};
+ }
+}
+
+export async function migrateToSites(
+ opts: MigrateToSitesOptions = {},
+): Promise<MigrateToSitesResult> {
+ const paths = opts.paths ?? getPaths();
+ const log = opts.onLog ?? (() => {});
+
+ if (listSiteIds(paths).length > 0) {
+ return { migrated: false, reason: "Sites already configured" };
+ }
+
+ const raw = readRawSettings(paths.settingsFile);
+ const siteTitle =
+ typeof raw.siteTitle === "string" && raw.siteTitle.trim()
+ ? raw.siteTitle.trim()
+ : "Transcript Browser";
+ const headerTitle =
+ typeof raw.headerTitle === "string" && raw.headerTitle.trim()
+ ? raw.headerTitle.trim()
+ : siteTitle;
+ const siteDescription =
+ typeof raw.siteDescription === "string" ? raw.siteDescription : "";
+ const homeTagline =
+ typeof raw.homeTagline === "string" ? raw.homeTagline : "";
+
+ const parsedGroups = parseChannelGroups(raw.groups);
+ const groups: ChannelGroup[] =
+ parsedGroups.length > 0 ? parsedGroups : [{ ...FALLBACK_GROUP }];
+ const defaultGroupId = resolveDefaultGroupId(raw.defaultGroupId, groups);
+ const socialLinks = parseSocialLinks(raw.socialLinks);
+
+ const siteId = opts.siteId ?? slugify(siteTitle);
+
+ // Build channel membership from each channel's old `group` field (read raw,
+ // since parseChannelConfig no longer surfaces it).
+ const channels: Site["channels"] = [];
+ let channelEntries: fs.Dirent[] = [];
+ try {
+ channelEntries = fs.readdirSync(paths.channelsDir, { withFileTypes: true });
+ } catch {
+ channelEntries = [];
+ }
+ for (const e of channelEntries.sort((a, b) => a.name.localeCompare(b.name))) {
+ if (!e.isDirectory()) continue;
+ const cfgPath = path.join(paths.channelsDir, e.name, "config.json");
+ let cfgRaw: Record<string, unknown> = {};
+ try {
+ cfgRaw = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
+ } catch {
+ continue; // no valid config -> not a real channel
+ }
+ const oldGroup =
+ typeof cfgRaw.group === "string" ? cfgRaw.group : undefined;
+ const groupId = resolveChannelGroupId(oldGroup, groups, defaultGroupId);
+ channels.push({ slug: e.name, groupId });
+ }
+
+ const site: Site = {
+ siteId,
+ siteTitle,
+ siteDescription,
+ headerTitle,
+ homeTagline,
+ socialLinks,
+ groups,
+ defaultGroupId,
+ channels,
+ };
+ await writeSite(site, paths);
+ log(`Wrote site "${siteId}" with ${channels.length} channels.`);
+
+ // Move the global chart-templates.json into the site, if present.
+ try {
+ if (fs.existsSync(paths.chartsConfigFile)) {
+ const dest = siteChartTemplatesFile(paths, siteId);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(paths.chartsConfigFile, dest);
+ fs.rmSync(paths.chartsConfigFile, { force: true });
+ log(`Moved chart-templates.json into site "${siteId}".`);
+ }
+ } catch (err) {
+ log(`Chart templates migration skipped: ${String(err)}`);
+ }
+
+ // Reduce settings.json to operational config (+ adminTitle). getSettings()
+ // already ignores the removed fields; writeSettings persists only the new
+ // operational shape.
+ const current = getSettings();
+ await writeSettings({
+ ...current,
+ adminTitle:
+ current.adminTitle && current.adminTitle !== DEFAULT_ADMIN_TITLE
+ ? current.adminTitle
+ : `${headerTitle} Admin`,
+ });
+ log("Reduced settings.json to operational config.");
+
+ return { migrated: true, siteId, channelCount: channels.length };
+}
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -16,9 +16,6 @@ export type ChannelConfig = {
platform?: Platform;
name?: string;
url?: string;
- // ID of the ChannelGroup this channel belongs to (see SiteSettings.groups).
- // Omitted -> falls into SiteSettings.defaultGroupId at render/index time.
- group?: string;
audioFormat?: AudioFormat;
keepSourceVideo?: boolean;
ytdlpExtraArgs?: string[];
@@ -85,9 +82,6 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
}
if (typeof r.name === "string") config.name = r.name;
if (typeof r.url === "string") config.url = r.url;
- if (typeof r.group === "string" && r.group.trim()) {
- config.group = r.group.trim();
- }
if (
r.audioFormat === "m4a" ||
r.audioFormat === "mp3" ||
diff --git a/common/lib/chartsStore.ts b/common/lib/chartsStore.ts
@@ -1,10 +1,12 @@
-// Server-side persistence for editor-authored chart templates. The canonical
-// file is getPaths().chartsConfigFile; the export build copies it into
-// exportPublicDir as chart-templates.json so the static viewer can fetch it.
+// Server-side persistence for editor-authored chart templates. Each site owns
+// its dashboard at sites/<siteId>/chart-templates.json; the export build copies
+// that site's file into its staging dir so build:site composes it into the
+// bundle as /chart-templates.json for the static viewer to fetch.
import path from "node:path";
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import type { Paths } from "./paths";
+import { siteChartTemplatesFile, siteIndexDir } from "./site";
import {
defaultTemplates,
type ChartTemplates,
@@ -13,8 +15,10 @@ import {
const EXPORT_TEMPLATES_FILE = "chart-templates.json";
-export function exportTemplatesPath(paths: Paths): string {
- return path.join(paths.exportPublicDir, EXPORT_TEMPLATES_FILE);
+// Staging path (NOT served). Composed into a site's bundle as
+// /chart-templates.json by build:site.
+export function siteTemplatesStagingPath(paths: Paths, siteId: string): string {
+ return path.join(siteIndexDir(paths, siteId), EXPORT_TEMPLATES_FILE);
}
function coerce(raw: unknown): ChartTemplates {
@@ -30,9 +34,11 @@ function coerce(raw: unknown): ChartTemplates {
};
}
-export function readTemplates(paths: Paths): ChartTemplates {
+export function readTemplates(paths: Paths, siteId: string): ChartTemplates {
try {
- return coerce(JSON.parse(readFileSync(paths.chartsConfigFile, "utf8")));
+ return coerce(
+ JSON.parse(readFileSync(siteChartTemplatesFile(paths, siteId), "utf8")),
+ );
} catch {
return defaultTemplates();
}
@@ -45,14 +51,17 @@ function writeJsonAtomic(filePath: string, value: unknown): void {
renameSync(tmp, filePath);
}
-export function writeTemplates(paths: Paths, templates: ChartTemplates): void {
- writeJsonAtomic(paths.chartsConfigFile, templates);
+export function writeTemplates(
+ paths: Paths,
+ siteId: string,
+ templates: ChartTemplates,
+): void {
+ writeJsonAtomic(siteChartTemplatesFile(paths, siteId), templates);
}
-// Copy the canonical templates into the served export dir. Called by the
-// export build (build:templates) and by the editor on save so the dev viewer
-// reflects changes immediately.
-export function syncTemplatesToExport(paths: Paths): void {
- const templates = readTemplates(paths);
- writeJsonAtomic(exportTemplatesPath(paths), templates);
+// Copy a site's canonical templates into its staging dir. Called by the export
+// build (build:templates) for each site.
+export function syncTemplatesToExport(paths: Paths, siteId: string): void {
+ const templates = readTemplates(paths, siteId);
+ writeJsonAtomic(siteTemplatesStagingPath(paths, siteId), templates);
}
diff --git a/common/lib/manifest.ts b/common/lib/manifest.ts
@@ -21,9 +21,14 @@ export type Manifest = {
// compat — clients fall back to a synthesized single group.
groups?: ChannelGroup[];
defaultGroupId?: string;
+ // The site this manifest was built for. The channel/group set is now
+ // site-scoped (a subset of the global pool). Optional for backwards compat.
+ siteId?: string;
};
-export const MANIFEST_VERSION = 2;
+// v3: manifests are now per-site (channels/groups are a site selection, not the
+// whole pool). Bumped so a stale single-site manifest forces a rebuild.
+export const MANIFEST_VERSION = 3;
export const SUMMARIES_PAGE_SIZE = 1000;
export function pageFileName(index: number): string {
@@ -45,7 +50,8 @@ export type ChannelTranscriptsManifest = {
slugToPage: Record<string, number>;
};
-export const SUBS_MANIFEST_VERSION = 3;
+// v4: per-site subs manifest (channels/groups are a site selection).
+export const SUBS_MANIFEST_VERSION = 4;
export type SubsChannelEntry = {
name: string;
@@ -68,6 +74,7 @@ export type SubsManifest = {
generatedAt: string;
groups?: ChannelGroup[];
defaultGroupId?: string;
+ siteId?: string;
};
export const SUBS_CHANNEL_MANIFEST_VERSION = 1;
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -6,14 +6,31 @@ export type Paths = {
monorepoRoot: string;
transcriptsDir: string;
channelsDir: string;
+ // Per-site config lives under sitesDir/<siteId>/site.json (+ chart-templates.json).
+ // See common/lib/site.ts. A "site" is a selection + presentation layer over the
+ // single global channel pool; channel downloads are never duplicated per site.
+ sitesDir: string;
jobsDir: string;
lmdbPath: string;
exportDir: string;
+ // The dir Next.js serves at "/" (also holds checked-in static assets). For
+ // multi-site builds this is composed one site at a time from the staging dirs
+ // below: per-site summaries/subs-manifest/stats/chart-templates plus the
+ // site's subset of the shared transcripts/subs trees.
exportPublicDir: string;
exportSummariesDir: string;
exportTranscriptsDir: string;
exportSubsDir: string;
exportStatsDir: string;
+ // Staging area (NOT served) where the shared index + per-site aggregates are
+ // built before composition. Shared per-channel transcript/subs pages are
+ // identical across sites and built once under exportSharedDir; per-site
+ // summaries/subs-manifest/stats land under exportSitesIndexDir/<siteId>/.
+ exportIndexDir: string;
+ exportSharedDir: string;
+ exportSharedTranscriptsDir: string;
+ exportSharedSubsDir: string;
+ exportSitesIndexDir: string;
settingsFile: string;
chartsConfigFile: string;
ytdlpBin: string;
@@ -33,10 +50,18 @@ export function getPaths(): Paths {
const exportDir = path.join(monorepoRoot, "export");
const exportPublicDir =
process.env.EXPORT_PUBLIC_DIR ?? path.join(exportDir, "public");
+ // Staging sibling of the served public dir (so it lands inside the test data
+ // root when EXPORT_PUBLIC_DIR is overridden). Not served; composed into
+ // exportPublicDir per site by the build:site step.
+ const exportIndexDir =
+ process.env.EXPORT_INDEX_DIR ??
+ path.join(path.dirname(exportPublicDir), ".export-index");
+ const exportSharedDir = path.join(exportIndexDir, "shared");
cached = {
monorepoRoot,
transcriptsDir,
channelsDir: path.join(transcriptsDir, "channels"),
+ sitesDir: process.env.SITES_DIR ?? path.join(transcriptsDir, "sites"),
jobsDir: path.join(transcriptsDir, ".jobs"),
lmdbPath: path.join(transcriptsDir, "index.mdb"),
exportDir,
@@ -45,6 +70,11 @@ export function getPaths(): Paths {
exportTranscriptsDir: path.join(exportPublicDir, "transcripts"),
exportSubsDir: path.join(exportPublicDir, "subs"),
exportStatsDir: path.join(exportPublicDir, "stats"),
+ exportIndexDir,
+ exportSharedDir,
+ exportSharedTranscriptsDir: path.join(exportSharedDir, "transcripts"),
+ exportSharedSubsDir: path.join(exportSharedDir, "subs"),
+ exportSitesIndexDir: path.join(exportIndexDir, "sites"),
settingsFile:
process.env.SETTINGS_FILE ?? path.join(monorepoRoot, "settings.json"),
chartsConfigFile:
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -1,18 +1,14 @@
import fs from "node:fs";
-import {
- DEFAULT_GROUP_FALLBACK_ID,
- FALLBACK_GROUP,
- parseChannelGroups,
- resolveDefaultGroupId,
- type ChannelGroup,
-} from "./channelGroups";
import { getPaths } from "./paths";
+// Global, OPERATIONAL settings shared across every site this editor powers.
+// Per-site presentation (branding, social links, channel groups, membership)
+// lives in sites/<siteId>/site.json — see common/lib/site.ts.
export type SiteSettings = {
- siteTitle: string;
- siteDescription: string;
- headerTitle: string;
- homeTagline: string;
+ // Title for the EDITOR admin shell only (the editor manages all sites and so
+ // is not tied to any one site's branding). Public sites get their own titles
+ // from site.json.
+ adminTitle: string;
maxTranscriptPageBytes: number;
transcribeBin: string;
transcribeArgs: string[];
@@ -31,15 +27,6 @@ export type SiteSettings = {
// (default), audio is left for the next "Transcribe missing" pass so a
// batch download finishes faster and whisper can parallelize.
inlineTranscribeOnFallback: boolean;
- // Cosmetic groupings the export UI uses to organize channel checkboxes.
- // See common/lib/channelGroups.ts. defaultGroupId must reference an id
- // present in `groups` (validated on read+write).
- groups: ChannelGroup[];
- defaultGroupId: string;
- // Footer social-media links rendered in the export app. Admin-provided
- // SVGs are normalized by `normalizeSocialSvg` on write so they can be
- // styled with CSS (currentColor) and are safe to inline.
- socialLinks: SocialLink[];
};
export type SocialLink = {
@@ -75,13 +62,12 @@ export const DEFAULT_TRANSCRIBE_ARGS: ReadonlyArray<string> = [
TRANSCRIBE_PLACEHOLDER_AUDIO,
];
+export const DEFAULT_ADMIN_TITLE = "Transcript Browser Admin";
+
function defaults(): SiteSettings {
const paths = getPaths();
return {
- siteTitle: "Transcript Browser",
- siteDescription: "Browse and search video transcripts",
- headerTitle: "Transcript Browser",
- homeTagline: "",
+ adminTitle: DEFAULT_ADMIN_TITLE,
maxTranscriptPageBytes: TRANSCRIPT_PAGE_DEFAULT_BYTES,
transcribeBin: paths.whisperBin,
transcribeArgs: [...DEFAULT_TRANSCRIBE_ARGS],
@@ -89,12 +75,6 @@ function defaults(): SiteSettings {
cookiesFromBrowser: "",
sleepBetweenDownloadsSeconds: SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS,
inlineTranscribeOnFallback: false,
- // A single default group that's selected by default keeps installs
- // without explicit group config behaving exactly like the pre-grouping
- // UI (one bucket, everything checked).
- groups: [{ ...FALLBACK_GROUP }],
- defaultGroupId: DEFAULT_GROUP_FALLBACK_ID,
- socialLinks: [],
};
}
@@ -170,7 +150,18 @@ export function getSettings(): SiteSettings {
} catch {
parsed = {};
}
- const merged: SiteSettings = { ...defaults(), ...parsed };
+ // Pick only known operational keys — a pre-multi-site settings.json may still
+ // carry siteTitle/groups/socialLinks, which now live per-site in site.json.
+ const merged: SiteSettings = { ...defaults() };
+ const knownKeys = Object.keys(merged) as (keyof SiteSettings)[];
+ for (const key of knownKeys) {
+ if (parsed[key] !== undefined) {
+ (merged as Record<string, unknown>)[key] = parsed[key];
+ }
+ }
+ if (typeof merged.adminTitle !== "string" || !merged.adminTitle.trim()) {
+ merged.adminTitle = DEFAULT_ADMIN_TITLE;
+ }
merged.maxTranscriptPageBytes = clampPageBytes(merged.maxTranscriptPageBytes);
if (!Array.isArray(merged.transcribeArgs) || merged.transcribeArgs.length === 0) {
merged.transcribeArgs = [...DEFAULT_TRANSCRIBE_ARGS];
@@ -190,15 +181,6 @@ export function getSettings(): SiteSettings {
if (typeof merged.inlineTranscribeOnFallback !== "boolean") {
merged.inlineTranscribeOnFallback = false;
}
- const parsedGroups = parseChannelGroups((parsed as Record<string, unknown>).groups);
- merged.groups = parsedGroups.length > 0 ? parsedGroups : [{ ...FALLBACK_GROUP }];
- merged.defaultGroupId = resolveDefaultGroupId(
- (parsed as Record<string, unknown>).defaultGroupId,
- merged.groups,
- );
- merged.socialLinks = parseSocialLinks(
- (parsed as Record<string, unknown>).socialLinks,
- );
return merged;
}
@@ -240,28 +222,19 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
if (!next.transcribeBin.trim()) {
throw new Error("Transcribe binary is required");
}
- const groups = parseChannelGroups(next.groups);
- if (groups.length === 0) {
- throw new Error("At least one channel group is required");
- }
- const defaultGroupId = resolveDefaultGroupId(next.defaultGroupId, groups);
- if (defaultGroupId !== next.defaultGroupId) {
- throw new Error(
- `Default group "${String(next.defaultGroupId)}" is not in the configured groups`,
- );
- }
const file = getPaths().settingsFile;
- const socialLinks: SocialLink[] = [];
- for (const link of parseSocialLinks(next.socialLinks)) {
- const svg = normalizeSocialSvg(link.svg);
- if (svg === null) {
- throw new Error(`Social link "${link.label}" has an invalid SVG`);
- }
- socialLinks.push({ ...link, svg });
- }
+ // Build the output from ONLY the known operational fields. Do NOT spread
+ // `next`: getSettings() spreads the raw file, so a settings.json still
+ // carrying pre-multi-site keys (siteTitle/groups/socialLinks) would otherwise
+ // smuggle those stale keys back onto disk on every save.
const merged: SiteSettings = {
- ...next,
+ adminTitle:
+ typeof next.adminTitle === "string" && next.adminTitle.trim()
+ ? next.adminTitle.trim()
+ : DEFAULT_ADMIN_TITLE,
maxTranscriptPageBytes: clampPageBytes(next.maxTranscriptPageBytes),
+ transcribeBin: next.transcribeBin,
+ transcribeModel: next.transcribeModel,
transcribeArgs: next.transcribeArgs.map((a) => String(a)),
cookiesFromBrowser:
typeof next.cookiesFromBrowser === "string"
@@ -270,9 +243,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
sleepBetweenDownloadsSeconds: clampSleepBetweenDownloadsSeconds(
next.sleepBetweenDownloadsSeconds,
),
- groups,
- defaultGroupId,
- socialLinks,
+ inlineTranscribeOnFallback: next.inlineTranscribeOnFallback === true,
};
const tmp = `${file}.tmp-${process.pid}`;
await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n");
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -0,0 +1,272 @@
+import fs from "node:fs";
+import path from "node:path";
+import {
+ DEFAULT_GROUP_FALLBACK_ID,
+ FALLBACK_GROUP,
+ parseChannelGroups,
+ resolveDefaultGroupId,
+ type ChannelGroup,
+} from "./channelGroups";
+import { getPaths, type Paths } from "./paths";
+import {
+ normalizeSocialSvg,
+ parseSocialLinks,
+ type SocialLink,
+} from "./settings";
+
+// A Site is a selection + presentation layer over the single global channel
+// pool. Branding, social links, the channel grouping layout AND which channels
+// are members all live here (sites/<siteId>/site.json), so one editor install
+// can power several public sites that overlap on channels without duplicating
+// any downloads. Operational config (transcribe bin, cookies, rate limits)
+// stays global in settings.json — see common/lib/settings.ts.
+
+export type SiteChannelMembership = {
+ // Channel slug (directory name under transcripts/channels/).
+ slug: string;
+ // Group this channel belongs to WITHIN this site. Resolved against
+ // site.groups at index-build time; the same channel can sit in different
+ // groups on different sites. Falls back to site.defaultGroupId when unknown.
+ groupId?: string;
+ // Optional explicit ordering hint within the site (lower first).
+ order?: number;
+};
+
+export type Site = {
+ siteId: string;
+ siteTitle: string;
+ siteDescription: string;
+ headerTitle: string;
+ homeTagline: string;
+ socialLinks: SocialLink[];
+ // Channel grouping layout for THIS site (cosmetic + default-selection
+ // buckets the export UI renders). Mirrors what used to live in
+ // SiteSettings.groups but is now per-site.
+ groups: ChannelGroup[];
+ defaultGroupId: string;
+ // The channels this site exposes. A channel absent from this list is not
+ // built or deployed for this site even though its data exists in the pool.
+ channels: SiteChannelMembership[];
+ // Cloudflare Pages project name this site deploys to
+ // (`wrangler pages deploy out --project-name <cloudflareProject>`).
+ cloudflareProject?: string;
+};
+
+// siteId shares the group-id grammar: lowercase slug, used as a directory name.
+export const SITE_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
+
+export function isValidSiteId(id: unknown): id is string {
+ return typeof id === "string" && SITE_ID_RE.test(id);
+}
+
+export function siteDir(paths: Paths, siteId: string): string {
+ return path.join(paths.sitesDir, siteId);
+}
+
+export function siteConfigFile(paths: Paths, siteId: string): string {
+ return path.join(siteDir(paths, siteId), "site.json");
+}
+
+export function siteChartTemplatesFile(paths: Paths, siteId: string): string {
+ return path.join(siteDir(paths, siteId), "chart-templates.json");
+}
+
+// Staging output dirs (NOT served) where per-site aggregates are built before
+// composition into export/public. See common/lib/paths.ts.
+export function siteIndexDir(paths: Paths, siteId: string): string {
+ return path.join(paths.exportSitesIndexDir, siteId);
+}
+
+export function siteSummariesDir(paths: Paths, siteId: string): string {
+ return path.join(siteIndexDir(paths, siteId), "summaries");
+}
+
+export function siteSubsDir(paths: Paths, siteId: string): string {
+ return path.join(siteIndexDir(paths, siteId), "subs");
+}
+
+export function siteStatsDir(paths: Paths, siteId: string): string {
+ return path.join(siteIndexDir(paths, siteId), "stats");
+}
+
+function defaults(siteId: string): Site {
+ return {
+ siteId,
+ siteTitle: "Transcript Browser",
+ siteDescription: "Browse and search video transcripts",
+ headerTitle: "Transcript Browser",
+ homeTagline: "",
+ socialLinks: [],
+ // A single default group selected by default keeps a site with no explicit
+ // group config behaving like the pre-grouping UI (one bucket, all checked).
+ groups: [{ ...FALLBACK_GROUP }],
+ defaultGroupId: DEFAULT_GROUP_FALLBACK_ID,
+ channels: [],
+ };
+}
+
+export function parseSiteChannels(input: unknown): SiteChannelMembership[] {
+ if (!Array.isArray(input)) return [];
+ const out: SiteChannelMembership[] = [];
+ const seen = new Set<string>();
+ for (const raw of input) {
+ if (!raw || typeof raw !== "object") continue;
+ const r = raw as Record<string, unknown>;
+ const slug = typeof r.slug === "string" ? r.slug.trim() : "";
+ if (!slug || seen.has(slug)) continue;
+ seen.add(slug);
+ const entry: SiteChannelMembership = { slug };
+ if (typeof r.groupId === "string" && r.groupId.trim()) {
+ entry.groupId = r.groupId.trim();
+ }
+ if (typeof r.order === "number" && Number.isFinite(r.order)) {
+ entry.order = Math.floor(r.order);
+ }
+ out.push(entry);
+ }
+ return out;
+}
+
+// Parse a raw site.json object into a fully-resolved Site, applying defaults
+// and validating groups/defaultGroupId/channel membership. Mirrors getSettings.
+export function parseSite(siteId: string, raw: unknown): Site {
+ const base = defaults(siteId);
+ const r =
+ raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
+ const str = (key: keyof Site, fallback: string): string =>
+ typeof r[key] === "string" ? (r[key] as string) : fallback;
+
+ const groups = parseChannelGroups(r.groups);
+ const resolvedGroups = groups.length > 0 ? groups : [{ ...FALLBACK_GROUP }];
+ const defaultGroupId = resolveDefaultGroupId(r.defaultGroupId, resolvedGroups);
+
+ // Drop memberships whose groupId is unknown to a concrete group (fold to
+ // default at render time via resolveChannelGroupId, but keep the explicit
+ // value when valid so the editor round-trips it).
+ const channels = parseSiteChannels(r.channels).map((c) => {
+ if (c.groupId && !resolvedGroups.some((g) => g.id === c.groupId)) {
+ const { groupId: _drop, ...rest } = c;
+ return rest;
+ }
+ return c;
+ });
+
+ return {
+ siteId,
+ siteTitle: str("siteTitle", base.siteTitle),
+ siteDescription: str("siteDescription", base.siteDescription),
+ headerTitle: str("headerTitle", base.headerTitle),
+ homeTagline: str("homeTagline", base.homeTagline),
+ socialLinks: parseSocialLinks(r.socialLinks),
+ groups: resolvedGroups,
+ defaultGroupId,
+ channels,
+ cloudflareProject:
+ typeof r.cloudflareProject === "string" && r.cloudflareProject.trim()
+ ? r.cloudflareProject.trim()
+ : undefined,
+ };
+}
+
+export function getSite(siteId: string, paths: Paths = getPaths()): Site {
+ if (!isValidSiteId(siteId)) {
+ throw new Error(`Invalid site id: ${String(siteId)}`);
+ }
+ let parsed: unknown = {};
+ try {
+ parsed = JSON.parse(fs.readFileSync(siteConfigFile(paths, siteId), "utf8"));
+ } catch {
+ parsed = {};
+ }
+ return parseSite(siteId, parsed);
+}
+
+// List the ids of all configured sites (directories under sitesDir that hold a
+// site.json), sorted for stable iteration.
+export function listSiteIds(paths: Paths = getPaths()): string[] {
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(paths.sitesDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ const ids: string[] = [];
+ for (const e of entries) {
+ if (!e.isDirectory()) continue;
+ if (!isValidSiteId(e.name)) continue;
+ if (!fs.existsSync(siteConfigFile(paths, e.name))) continue;
+ ids.push(e.name);
+ }
+ return ids.sort((a, b) => a.localeCompare(b));
+}
+
+export function listSites(paths: Paths = getPaths()): Site[] {
+ return listSiteIds(paths).map((id) => getSite(id, paths));
+}
+
+// The site selectors and the export dev server fall back to this when no site
+// is explicitly chosen: the lone site if there's exactly one, else null.
+export function defaultSiteId(paths: Paths = getPaths()): string | null {
+ const ids = listSiteIds(paths);
+ return ids.length === 1 ? ids[0] : null;
+}
+
+export async function writeSite(
+ site: Site,
+ paths: Paths = getPaths(),
+): Promise<void> {
+ if (!isValidSiteId(site.siteId)) {
+ throw new Error(`Invalid site id: ${String(site.siteId)}`);
+ }
+ const groups = parseChannelGroups(site.groups);
+ if (groups.length === 0) {
+ throw new Error("At least one channel group is required");
+ }
+ const defaultGroupId = resolveDefaultGroupId(site.defaultGroupId, groups);
+ if (defaultGroupId !== site.defaultGroupId) {
+ throw new Error(
+ `Default group "${String(site.defaultGroupId)}" is not in the configured groups`,
+ );
+ }
+ const socialLinks: SocialLink[] = [];
+ for (const link of parseSocialLinks(site.socialLinks)) {
+ const svg = normalizeSocialSvg(link.svg);
+ if (svg === null) {
+ throw new Error(`Social link "${link.label}" has an invalid SVG`);
+ }
+ socialLinks.push({ ...link, svg });
+ }
+ const channels = parseSiteChannels(site.channels).filter(
+ (c) => !c.groupId || groups.some((g) => g.id === c.groupId),
+ );
+ const merged: Site = {
+ siteId: site.siteId,
+ siteTitle: site.siteTitle,
+ siteDescription: site.siteDescription,
+ headerTitle: site.headerTitle,
+ homeTagline: site.homeTagline,
+ socialLinks,
+ groups,
+ defaultGroupId,
+ channels,
+ ...(site.cloudflareProject && site.cloudflareProject.trim()
+ ? { cloudflareProject: site.cloudflareProject.trim() }
+ : {}),
+ };
+ const dir = siteDir(paths, site.siteId);
+ await fs.promises.mkdir(dir, { recursive: true });
+ const file = siteConfigFile(paths, site.siteId);
+ const tmp = `${file}.tmp-${process.pid}`;
+ await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n");
+ await fs.promises.rename(tmp, file);
+}
+
+export async function deleteSite(
+ siteId: string,
+ paths: Paths = getPaths(),
+): Promise<void> {
+ if (!isValidSiteId(siteId)) {
+ throw new Error(`Invalid site id: ${String(siteId)}`);
+ }
+ await fs.promises.rm(siteDir(paths, siteId), { recursive: true, force: true });
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Multi-site support (major change).** One editor instance can now power several public sites (e.g. "Jeralyzer" and "Rekietalyzer") over a single shared channel pool — a channel's downloads are stored once and reused by every site that includes it. A new **Sites** area (sidebar) manages each site's `sites/<id>/site.json`: its branding (title, header, description, tagline), social links, channel-group layout, and **which channels it exposes** (with a per-site group for each member, so the same channel can sit in different groups on different sites) plus its Cloudflare Pages project. Site-level branding/groups/social have moved **out** of global Settings, which now holds only operational config plus a new **Admin title** for the editor's own shell; the per-channel "Group" field is gone (grouping is configured per site). The **Charts** tab is per-site (pick the site at the top; each site has its own dashboard at `sites/<id>/chart-templates.json`). **Build index** / **Build stats dataset** now build the shared per-channel data once and a filtered bundle per site; the Deploy page's **Build static export** and **Deploy** gained a site selector and deploy each site to its own Cloudflare project. Upgrading an existing single-site install: the Sites page shows a one-click **Migrate** button (also a `migrate-to-sites` CLI) that lifts your current branding, channel groups, per-channel group assignments, and chart dashboard into a first site and reduces `settings.json` to operational config.
- **"Audio + Whisper (skip pipeline)" no longer mistakes a live-chat sidecar for audio.** yt-dlp writes the live-chat track as `audio.live_chat.json` (it ignores the subtitle output path), so an interrupted download could leave an `audio.live_chat.json.part` behind. One-click whisper saw the `audio.` prefix, decided audio was already on disk, skipped the download, and then failed with "no audio file found". Live-chat sidecars (`.part` or completed) are now excluded everywhere audio is detected, so whisper downloads the real audio and transcribes as expected.
- **Cleanup tasks on `/actionable`.** The global Actionable view now surfaces two housekeeping sections alongside the download/transcribe backlog: **channels with cleanable transcribed audio** (videos that have a whisper transcript but still keep their audio on disk) and **channels with extra audio formats** (videos with leftover audio files beside the channel's configured format), each with a per-channel count and an inline button. Because these delete files, the buttons pop a confirm dialog before queuing. The dashboard "Needs attention" card is unchanged — it still tracks only download/transcribe work.
- **Per-video "do not clean" archive toggle.** Each video detail page has a new **Archive media** section to mark a video "do not clean". Marked videos are skipped by both cleanup jobs (their audio is preserved for archiving) and excluded from the cleanup counts on `/actionable`. The marker is reversible from the same toggle, and an "archived" badge shows on the video page while it's set.
diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts
@@ -55,9 +55,13 @@ export async function buildStatsAction(
}
export async function buildExportAction(
+ siteId: string,
queueKey?: string,
): Promise<StreamActionResult> {
const paths = getPaths();
+ if (!siteId.trim()) {
+ return { ok: false, error: "Select a site to build" };
+ }
return runManagedCommand({
kind: "build-export",
queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
@@ -70,6 +74,7 @@ export async function buildExportAction(
NODE_ENV: "production",
TRANSCRIPTS_DIR: paths.transcriptsDir,
EXPORT_PUBLIC_DIR: paths.exportPublicDir,
+ SITE_ID: siteId.trim(),
},
});
}
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -21,7 +21,6 @@ import {
} from "yt-dlp-transcript-common/controller/shard";
import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import {
platformQueueKey,
queueKeyForUrl,
@@ -128,7 +127,6 @@ export default async function ChannelDetailPage({
const update = updateChannelAction.bind(null, slug);
const del = deleteChannelAction.bind(null, slug);
- const { groups: channelGroups, defaultGroupId } = getSettings();
const registry = getRegistry();
const existingQueues = registry.activeQueueNames();
const runningJobs = registry
@@ -216,8 +214,6 @@ export default async function ChannelDetailPage({
}
initial={{ slug, config }}
submitLabel="Save changes"
- groups={channelGroups}
- defaultGroupId={defaultGroupId}
/>
),
playlist: (
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -10,15 +10,12 @@ import {
AUDIO_CHECK_MAX_ROLLBACKS_MAX,
AUDIO_CHECK_MAX_ROLLBACKS_MIN,
} from "yt-dlp-transcript-common/lib/channelConfig";
-import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups";
type Props = {
action: string | ((formData: FormData) => void | Promise<void>);
initial?: { slug: string; config: ChannelConfig };
submitLabel: string;
errorMessage?: string;
- groups: ChannelGroup[];
- defaultGroupId: string;
};
export function ChannelForm({
@@ -26,8 +23,6 @@ export function ChannelForm({
initial,
submitLabel,
errorMessage,
- groups,
- defaultGroupId,
}: Props) {
const isEdit = !!initial;
const c = initial?.config;
@@ -61,36 +56,13 @@ export function ChannelForm({
placeholder="kebab-case (auto-derived from name if blank)"
/>
)}
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Group</span>
- <select
- name="group"
- defaultValue={c?.group ?? ""}
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
- >
- <option value="">
- (use default group
- {(() => {
- const dg = groups.find((g) => g.id === defaultGroupId);
- if (!dg) return "";
- const label = dg.name || dg.id;
- return `: ${label}`;
- })()}
- )
- </option>
- {groups.map((g) => (
- <option key={g.id} value={g.id}>
- {g.name || g.id}
- {g.selectedByDefault ? " (on by default)" : ""}
- </option>
- ))}
- </select>
- <span className="text-xs text-zinc-500">
- Controls how this channel is grouped in the export UI checkboxes.
- Leave on the default group unless this channel belongs in a
- separate cluster.
- </span>
- </label>
+ <p className="text-xs text-zinc-500">
+ 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="Source">
<fieldset className="flex flex-col gap-2">
diff --git a/editor/app/channels/components/ChannelFormClient.tsx b/editor/app/channels/components/ChannelFormClient.tsx
@@ -3,7 +3,6 @@
import { useActionState } from "react";
import { ChannelForm } from "./ChannelForm";
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
-import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups";
import type { ActionResult } from "../actions";
type Props = {
@@ -13,17 +12,9 @@ type Props = {
) => Promise<ActionResult>;
initial?: { slug: string; config: ChannelConfig };
submitLabel: string;
- groups: ChannelGroup[];
- defaultGroupId: string;
};
-export function ChannelFormClient({
- action,
- initial,
- submitLabel,
- groups,
- defaultGroupId,
-}: Props) {
+export function ChannelFormClient({ action, initial, submitLabel }: Props) {
const [state, formAction] = useActionState<ActionResult, FormData>(
action,
undefined,
@@ -34,8 +25,6 @@ export function ChannelFormClient({
initial={initial}
submitLabel={submitLabel}
errorMessage={state?.error}
- groups={groups}
- defaultGroupId={defaultGroupId}
/>
);
}
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -27,7 +27,6 @@ export type ParsedChannelForm = {
export const CHANNEL_FORM_FIELDS = [
"platform",
"url",
- "group",
"audioFormat",
"keepSourceVideo",
"ytdlpExtraArgs",
@@ -49,7 +48,6 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
const slug = String(formData.get("slug") ?? "").trim();
const url = stringOrUndef(formData, "url");
- const group = stringOrUndef(formData, "group");
const platformRaw = String(formData.get("platform") ?? "");
const platform: Platform | undefined = PLATFORM_VALUES.includes(
platformRaw as Platform,
@@ -146,7 +144,6 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
};
if (platform) config.platform = platform;
if (url) config.url = url;
- if (group) config.group = group;
if (audioFormat) config.audioFormat = audioFormat;
if (keepSourceVideo) config.keepSourceVideo = true;
if (ytdlpExtraArgs?.length) config.ytdlpExtraArgs = ytdlpExtraArgs;
diff --git a/editor/app/channels/new/page.tsx b/editor/app/channels/new/page.tsx
@@ -1,13 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { ChannelFormClient } from "../components/ChannelFormClient";
import { createChannelAction } from "../actions";
export const metadata: Metadata = { title: "New channel — Channels" };
export default function NewChannelPage() {
- const { groups, defaultGroupId } = getSettings();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-zinc-500">
@@ -21,8 +19,6 @@ export default function NewChannelPage() {
<ChannelFormClient
action={createChannelAction}
submitLabel="Create channel"
- groups={groups}
- defaultGroupId={defaultGroupId}
/>
</div>
);
diff --git a/editor/app/charts/EditorChartsClient.tsx b/editor/app/charts/EditorChartsClient.tsx
@@ -12,13 +12,22 @@ import {
} from "yt-dlp-transcript-common/lib/chartConfig";
import { saveDefaultDashboardAction } from "./actions";
-function Inner({ templates }: { templates: ChartTemplates }) {
+function Inner({
+ templates,
+ siteId,
+}: {
+ templates: ChartTemplates;
+ siteId: string;
+}) {
const { stats, ready, loadedPages, pageCount, manifest, error } = useStats();
const { summaries } = useSummaries();
- const onPersist = useCallback((dashboard: Dashboard) => {
- void saveDefaultDashboardAction(dashboard);
- }, []);
+ const onPersist = useCallback(
+ (dashboard: Dashboard) => {
+ void saveDefaultDashboardAction(siteId, dashboard);
+ },
+ [siteId],
+ );
if (error) {
return (
@@ -54,10 +63,16 @@ function Inner({ templates }: { templates: ChartTemplates }) {
);
}
-export function EditorChartsClient({ templates }: { templates: ChartTemplates }) {
+export function EditorChartsClient({
+ templates,
+ siteId,
+}: {
+ templates: ChartTemplates;
+ siteId: string;
+}) {
return (
<QueryProvider>
- <Inner templates={templates} />
+ <Inner templates={templates} siteId={siteId} />
</QueryProvider>
);
}
diff --git a/editor/app/charts/actions.ts b/editor/app/charts/actions.ts
@@ -11,11 +11,12 @@ import type { Dashboard } from "yt-dlp-transcript-common/lib/chartConfig";
// Persist the authored default dashboard. Keeps the gallery as-is and mirrors
// the result into the served export dir so the static viewer picks it up.
export async function saveDefaultDashboardAction(
+ siteId: string,
dashboard: Dashboard,
): Promise<{ ok: true }> {
const paths = getPaths();
- const templates = readTemplates(paths);
- writeTemplates(paths, { ...templates, defaultDashboard: dashboard });
- syncTemplatesToExport(paths);
+ const templates = readTemplates(paths, siteId);
+ writeTemplates(paths, siteId, { ...templates, defaultDashboard: dashboard });
+ syncTemplatesToExport(paths, siteId);
return { ok: true };
}
diff --git a/editor/app/charts/page.tsx b/editor/app/charts/page.tsx
@@ -1,28 +1,79 @@
import type { Metadata } from "next";
+import Link from "next/link";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { readTemplates } from "yt-dlp-transcript-common/lib/chartsStore";
+import { listSiteIds, defaultSiteId } from "yt-dlp-transcript-common/lib/site";
import { EditorChartsClient } from "./EditorChartsClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Charts" };
-export default function ChartsPage() {
- const templates = readTemplates(getPaths());
+export default async function ChartsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ site?: string }>;
+}) {
+ const paths = getPaths();
+ const { site } = await searchParams;
+ const siteIds = listSiteIds(paths);
+
+ if (siteIds.length === 0) {
+ return (
+ <div className="flex flex-col gap-4">
+ <h1 className="text-2xl font-semibold">Charts</h1>
+ <p className="text-sm text-zinc-500">
+ No sites configured. Create one on the{" "}
+ <Link className="underline" href="/sites">
+ Sites
+ </Link>{" "}
+ page first.
+ </p>
+ </div>
+ );
+ }
+
+ const siteId =
+ site && siteIds.includes(site)
+ ? site
+ : (defaultSiteId(paths) ?? siteIds[0]);
+ const templates = readTemplates(paths, siteId);
+
return (
<div className="flex flex-col gap-4">
- <div>
+ <div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-semibold">Charts</h1>
- <p className="mt-1 text-sm text-zinc-500">
- Author the default dashboard shown to viewers. Edits save
- automatically and bake into the next export build. Requires the stats
- dataset — build it on the{" "}
- <a className="underline" href="/build">
- Build
- </a>{" "}
- page.
- </p>
+ {siteIds.length > 1 && (
+ <nav className="flex items-center gap-1 text-sm">
+ {siteIds.map((id) => (
+ <Link
+ key={id}
+ href={`/charts?site=${id}`}
+ className={`px-2 py-1 rounded border text-xs ${
+ id === siteId
+ ? "border-blue-400 bg-blue-50 dark:bg-blue-950"
+ : "border-zinc-300 dark:border-zinc-700"
+ }`}
+ >
+ {id}
+ </Link>
+ ))}
+ </nav>
+ )}
</div>
- <EditorChartsClient templates={templates} />
+ <p className="mt-1 text-sm text-zinc-500">
+ Author the default dashboard for <strong>{siteId}</strong>. Edits save
+ automatically and bake into that site's next export build. Requires
+ the stats dataset — build it on the{" "}
+ <Link className="underline" href="/build">
+ Build
+ </Link>{" "}
+ page.
+ </p>
+ <EditorChartsClient
+ key={siteId}
+ templates={templates}
+ siteId={siteId}
+ />
</div>
);
}
diff --git a/editor/app/deploy/components/BuildExportButton.tsx b/editor/app/deploy/components/BuildExportButton.tsx
@@ -5,30 +5,36 @@ import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActio
import { buildExportAction } from "../../build/buildAction";
import { QueueControl } from "../../components/QueueControl";
import { cancelJobAction } from "../../jobs/actions";
+import { SiteSelect, type SiteOption } from "./SiteSelect";
type Props = {
existingQueues: string[];
+ sites: SiteOption[];
};
const DEFAULT_BUILD_QUEUE = "build";
-export function BuildExportButton({ existingQueues }: Props) {
+export function BuildExportButton({ existingQueues, sites }: Props) {
const [exportQueue, setExportQueue] = useState(DEFAULT_BUILD_QUEUE);
+ const [siteId, setSiteId] = useState(sites[0]?.siteId ?? "");
return (
<StreamActionLog
- trigger={() => buildExportAction(exportQueue)}
+ trigger={() => buildExportAction(siteId, exportQueue)}
cancelAction={cancelJobAction}
buttonLabel="Build static export"
runningLabel="Building static export…"
extraControls={
- <QueueControl
- value={exportQueue}
- onChange={setExportQueue}
- defaultQueueKey={DEFAULT_BUILD_QUEUE}
- existingQueues={existingQueues}
- actionLabel="Build static export"
- />
+ <div className="flex flex-col gap-2">
+ <SiteSelect sites={sites} value={siteId} onChange={setSiteId} />
+ <QueueControl
+ value={exportQueue}
+ onChange={setExportQueue}
+ defaultQueueKey={DEFAULT_BUILD_QUEUE}
+ existingQueues={existingQueues}
+ actionLabel="Build static export"
+ />
+ </div>
}
/>
);
diff --git a/editor/app/deploy/components/DeployButton.tsx b/editor/app/deploy/components/DeployButton.tsx
@@ -1,17 +1,23 @@
"use client";
+import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
import { cancelJobAction } from "../../jobs/actions";
import { deployExportAction } from "../deployAction";
+import { SiteSelect, type SiteOption } from "./SiteSelect";
-export function DeployButton() {
+export function DeployButton({ sites }: { sites: SiteOption[] }) {
+ const [siteId, setSiteId] = useState(sites[0]?.siteId ?? "");
return (
<StreamActionLog
- trigger={() => deployExportAction()}
+ trigger={() => deployExportAction(siteId)}
cancelAction={cancelJobAction}
buttonLabel="Deploy"
runningLabel="Deploying…"
label="Deploy export"
+ extraControls={
+ <SiteSelect sites={sites} value={siteId} onChange={setSiteId} />
+ }
/>
);
}
diff --git a/editor/app/deploy/components/SiteSelect.tsx b/editor/app/deploy/components/SiteSelect.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+export type SiteOption = { siteId: string; siteTitle: string };
+
+export function SiteSelect({
+ sites,
+ value,
+ onChange,
+}: {
+ sites: SiteOption[];
+ value: string;
+ onChange: (siteId: string) => void;
+}) {
+ if (sites.length === 0) {
+ return (
+ <span className="text-xs text-red-600">
+ No sites configured — create one under Sites.
+ </span>
+ );
+ }
+ return (
+ <label className="flex items-center gap-2 text-sm">
+ <span className="text-zinc-500">Site</span>
+ <select
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ aria-label="Site"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ {sites.map((s) => (
+ <option key={s.siteId} value={s.siteId}>
+ {s.siteTitle} ({s.siteId})
+ </option>
+ ))}
+ </select>
+ </label>
+ );
+}
diff --git a/editor/app/deploy/deployAction.ts b/editor/app/deploy/deployAction.ts
@@ -1,6 +1,7 @@
"use server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSite } from "yt-dlp-transcript-common/lib/site";
import {
runManagedCommand,
type StreamActionResult,
@@ -8,20 +9,41 @@ import {
const DEPLOY_QUEUE = "deploy";
-export async function deployExportAction(): Promise<StreamActionResult> {
+export async function deployExportAction(
+ siteId: string,
+): Promise<StreamActionResult> {
const paths = getPaths();
+ if (!siteId.trim()) {
+ return { ok: false, error: "Select a site to deploy" };
+ }
+ const site = getSite(siteId.trim(), paths);
+ if (!site.cloudflareProject) {
+ return {
+ ok: false,
+ error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
+ };
+ }
return runManagedCommand({
kind: "deploy-export",
queueKey: DEPLOY_QUEUE,
paths,
cwd: paths.exportDir,
command: "pnpm",
- args: ["run", "deploy"],
+ args: [
+ "dlx",
+ "wrangler",
+ "pages",
+ "deploy",
+ "out",
+ "--project-name",
+ site.cloudflareProject,
+ ],
env: {
...process.env,
NODE_ENV: "production",
TRANSCRIPTS_DIR: paths.transcriptsDir,
EXPORT_PUBLIC_DIR: paths.exportPublicDir,
+ SITE_ID: site.siteId,
},
});
}
diff --git a/editor/app/deploy/page.tsx b/editor/app/deploy/page.tsx
@@ -8,6 +8,7 @@ import {
suggestNextVersion,
} from "yt-dlp-transcript-common/lib/changelog";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listSites } from "yt-dlp-transcript-common/lib/site";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { BuildExportButton } from "./components/BuildExportButton";
import { CutReleaseForm } from "./components/CutReleaseForm";
@@ -44,6 +45,10 @@ export default function DeployPage() {
const hasUnreleased = unreleased.length > 0;
const lastDeployedAt = getLastDeployedAt();
const existingQueues = getRegistry().activeQueueNames();
+ const sites = listSites(getPaths()).map((s) => ({
+ siteId: s.siteId,
+ siteTitle: s.siteTitle,
+ }));
return (
<div className="flex flex-col gap-8 max-w-3xl">
@@ -58,7 +63,7 @@ export default function DeployPage() {
static site at <code>export/out/</code>.
</p>
</div>
- <BuildExportButton existingQueues={existingQueues} />
+ <BuildExportButton existingQueues={existingQueues} sites={sites} />
</section>
<section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
@@ -113,7 +118,7 @@ export default function DeployPage() {
: "never"}
</p>
</div>
- <DeployButton />
+ <DeployButton sites={sites} />
</section>
</div>
);
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -14,11 +14,10 @@ export async function generateMetadata(): Promise<Metadata> {
const settings = getSettings();
return {
title: {
- default: settings.siteTitle,
- template: `%s — ${settings.siteTitle}`,
+ default: settings.adminTitle,
+ template: `%s — ${settings.adminTitle}`,
},
- description:
- settings.siteDescription || "Editor for yt-dlp-transcript-browser",
+ description: "Editor for yt-dlp-transcript-browser",
};
}
@@ -30,6 +29,7 @@ type NavLink = {
const NAV_LINKS: NavLink[] = [
{ href: "/", label: "Dashboard" },
+ { href: "/sites", label: "Sites" },
{ href: "/channels", label: "Channels" },
{ href: "/actionable", label: "Actionable" },
{ href: "/jobs", label: "Jobs", badgeKey: "jobs" },
@@ -59,7 +59,7 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const settings = getSettings();
- const headerLabel = settings.headerTitle || settings.siteTitle;
+ const headerLabel = settings.adminTitle;
const allJobs = getRegistry().list();
const activeJobs = allJobs.filter(
(j) => j.status === "running" || j.status === "queued",
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -16,7 +16,7 @@ export const dynamic = "force-dynamic";
// page still gets the site name in its document title.
export async function generateMetadata(): Promise<Metadata> {
const settings = getSettings();
- return { title: `Dashboard — ${settings.siteTitle}` };
+ return { title: `Dashboard — ${settings.adminTitle}` };
}
async function getLastBuildTime(paths: ReturnType<typeof getPaths>) {
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -5,17 +5,10 @@ import {
SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS,
TRANSCRIPT_PAGE_HARD_CAP_BYTES,
TRANSCRIPT_PAGE_MIN_BYTES,
- normalizeSocialSvg,
- parseSocialLinks,
validateTranscribeArgs,
writeSettings,
type SiteSettings,
- type SocialLink,
} from "yt-dlp-transcript-common/lib/settings";
-import {
- parseChannelGroups,
- resolveDefaultGroupId,
-} from "yt-dlp-transcript-common/lib/channelGroups";
export type SaveResult = { ok: true } | { ok: false; error: string };
@@ -23,10 +16,7 @@ export async function saveSettingsAction(
_prev: SaveResult | undefined,
formData: FormData,
): Promise<SaveResult> {
- const siteTitle = String(formData.get("siteTitle") ?? "").trim();
- const siteDescription = String(formData.get("siteDescription") ?? "").trim();
- const headerTitle = String(formData.get("headerTitle") ?? "").trim();
- const homeTagline = String(formData.get("homeTagline") ?? "").trim();
+ const adminTitle = String(formData.get("adminTitle") ?? "").trim();
const maxBytesRaw = String(formData.get("maxTranscriptPageBytes") ?? "").trim();
const transcribeBin = String(formData.get("transcribeBin") ?? "").trim();
const transcribeModel = String(formData.get("transcribeModel") ?? "").trim();
@@ -44,8 +34,7 @@ export async function saveSettingsAction(
.map((s) => s.trim())
.filter(Boolean);
- if (!siteTitle) return { ok: false, error: "Site title is required" };
- if (!headerTitle) return { ok: false, error: "Header title is required" };
+ if (!adminTitle) return { ok: false, error: "Admin title is required" };
if (!transcribeBin) {
return { ok: false, error: "Transcribe binary is required" };
}
@@ -84,63 +73,8 @@ export async function saveSettingsAction(
};
}
- const groupsJsonRaw = String(formData.get("groupsJson") ?? "[]");
- let groupsParsedInput: unknown;
- try {
- groupsParsedInput = JSON.parse(groupsJsonRaw);
- } catch {
- return { ok: false, error: "Channel groups payload is malformed" };
- }
- const groups = parseChannelGroups(groupsParsedInput);
- if (groups.length === 0) {
- return {
- ok: false,
- error: "At least one channel group is required (check ID/name fields)",
- };
- }
- const defaultGroupIdRaw = String(
- formData.get("defaultGroupId") ?? "",
- ).trim();
- const defaultGroupId = resolveDefaultGroupId(defaultGroupIdRaw, groups);
- if (defaultGroupIdRaw && defaultGroupIdRaw !== defaultGroupId) {
- return {
- ok: false,
- error: `Default group "${defaultGroupIdRaw}" is not among the configured group IDs`,
- };
- }
-
- const socialLinksRaw = String(formData.get("socialLinksJson") ?? "[]");
- let socialLinksInput: unknown;
- try {
- socialLinksInput = JSON.parse(socialLinksRaw);
- } catch {
- return { ok: false, error: "Social links payload is malformed" };
- }
- const socialLinksParsed = parseSocialLinks(socialLinksInput);
- if (Array.isArray(socialLinksInput) && socialLinksParsed.length !== socialLinksInput.length) {
- return {
- ok: false,
- error:
- "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.",
- };
- }
- const socialLinks: SocialLink[] = [];
- for (const link of socialLinksParsed) {
- const svg = normalizeSocialSvg(link.svg);
- if (svg === null) {
- return {
- ok: false,
- error: `Social link "${link.label}" has an invalid SVG (must include <svg viewBox="…">…</svg> and no script/event-handler attrs).`,
- };
- }
- socialLinks.push({ ...link, svg });
- }
-
const next: SiteSettings = {
- siteTitle,
- siteDescription,
- headerTitle,
- homeTagline,
+ adminTitle,
maxTranscriptPageBytes: parsed,
transcribeBin,
transcribeModel,
@@ -148,9 +82,6 @@ export async function saveSettingsAction(
cookiesFromBrowser,
sleepBetweenDownloadsSeconds: sleepParsed,
inlineTranscribeOnFallback,
- groups,
- defaultGroupId,
- socialLinks,
};
await writeSettings(next);
revalidatePath("/settings");
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -1,151 +1,30 @@
"use client";
-import { useState } from "react";
import { useActionState } from "react";
import {
saveSettingsAction,
type SaveResult,
} from "../actions";
-import type {
- SiteSettings,
- SocialLink,
-} from "yt-dlp-transcript-common/lib/settings";
-import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups";
+import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings";
type Props = {
initial: SiteSettings;
};
-type GroupRow = {
- id: string;
- name: string;
- description: string;
- selectedByDefault: boolean;
-};
-
-type SocialLinkRow = {
- label: string;
- url: string;
- svg: string;
-};
-
-function toRow(g: ChannelGroup): GroupRow {
- return {
- id: g.id,
- name: g.name,
- description: g.description ?? "",
- selectedByDefault: g.selectedByDefault,
- };
-}
-
-function toSocialRow(s: SocialLink): SocialLinkRow {
- return { label: s.label, url: s.url, svg: s.svg };
-}
-
export function SettingsForm({ initial }: Props) {
const [state, formAction] = useActionState<SaveResult | undefined, FormData>(
saveSettingsAction,
undefined,
);
- const [groups, setGroups] = useState<GroupRow[]>(() =>
- initial.groups.map(toRow),
- );
- const [defaultGroupId, setDefaultGroupId] = useState<string>(
- initial.defaultGroupId,
- );
- const [socialLinks, setSocialLinks] = useState<SocialLinkRow[]>(() =>
- initial.socialLinks.map(toSocialRow),
- );
- const socialLinksJson = JSON.stringify(
- socialLinks.map((s) => ({
- label: s.label.trim(),
- url: s.url.trim(),
- svg: s.svg.trim(),
- })),
- );
- const moveSocial = (idx: number, delta: -1 | 1) => {
- setSocialLinks((prev) => {
- const next = prev.slice();
- const j = idx + delta;
- if (j < 0 || j >= next.length) return prev;
- [next[idx], next[j]] = [next[j], next[idx]];
- return next;
- });
- };
- const addSocial = () => {
- setSocialLinks((prev) => [...prev, { label: "", url: "", svg: "" }]);
- };
- const removeSocial = (idx: number) => {
- setSocialLinks((prev) => prev.filter((_, i) => i !== idx));
- };
-
- const groupsJson = JSON.stringify(
- groups.map((g, idx) => ({
- id: g.id.trim(),
- name: g.name.trim(),
- description: g.description.trim() || undefined,
- selectedByDefault: g.selectedByDefault,
- // Persist the row position so the export UI renders groups in the
- // same order shown here. Without this, sortGroups would fall back
- // to alphabetical by name.
- order: idx,
- })),
- );
-
- const moveGroup = (idx: number, delta: -1 | 1) => {
- setGroups((prev) => {
- const next = prev.slice();
- const j = idx + delta;
- if (j < 0 || j >= next.length) return prev;
- [next[idx], next[j]] = [next[j], next[idx]];
- return next;
- });
- };
-
- const addGroup = () => {
- setGroups((prev) => [
- ...prev,
- { id: "", name: "", description: "", selectedByDefault: false },
- ]);
- };
-
- const removeGroup = (idx: number) => {
- setGroups((prev) => {
- const next = prev.filter((_, i) => i !== idx);
- // Keep defaultGroupId valid: if we just removed it, fall back to first.
- if (
- next.length > 0 &&
- !next.some((g) => g.id.trim() === defaultGroupId)
- ) {
- setDefaultGroupId(next[0].id.trim());
- }
- return next;
- });
- };
return (
<form action={formAction} className="flex flex-col gap-4 max-w-xl">
<Field
- label="Site title"
- name="siteTitle"
- defaultValue={initial.siteTitle}
+ label="Admin title"
+ name="adminTitle"
+ defaultValue={initial.adminTitle}
required
- />
- <Field
- label="Header title"
- name="headerTitle"
- defaultValue={initial.headerTitle}
- required
- />
- <Field
- label="Site description"
- name="siteDescription"
- defaultValue={initial.siteDescription}
- />
- <Field
- label="Home tagline"
- name="homeTagline"
- defaultValue={initial.homeTagline}
+ hint="Title for this editor's own shell. Public sites get their titles from each site's config."
/>
<Field
label="Max transcript page bytes"
@@ -227,282 +106,6 @@ export function SettingsForm({ initial }: Props) {
</span>
</span>
</label>
- <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3">
- <legend className="px-1 text-sm font-medium">Channel groups</legend>
- <p className="text-xs text-zinc-500">
- Cosmetic groupings the export UI uses to organize channel checkboxes.
- Channels reference a group via their <code>group</code> field
- (settable in each channel's form). Channels without a group fall
- into the default group selected here.
- </p>
- <input type="hidden" name="groupsJson" value={groupsJson} />
- <input
- type="hidden"
- name="defaultGroupId"
- value={defaultGroupId}
- />
- <div className="flex flex-col gap-3">
- {groups.length === 0 && (
- <div className="text-sm text-zinc-500">
- No groups configured. Add one to get started.
- </div>
- )}
- {groups.map((g, idx) => {
- const idTrim = g.id.trim();
- const isDefault = idTrim !== "" && idTrim === defaultGroupId;
- return (
- <div
- key={idx}
- className="flex flex-col gap-2 border border-zinc-200 dark:border-zinc-800 rounded p-3 bg-zinc-50/50 dark:bg-zinc-900/40"
- >
- <div className="flex items-center justify-between gap-2">
- <label className="flex items-center gap-1.5 text-xs select-none">
- <input
- type="radio"
- checked={isDefault}
- onChange={() => setDefaultGroupId(idTrim)}
- disabled={!idTrim}
- className="accent-blue-600"
- />
- Default group
- </label>
- <div className="flex items-center gap-1">
- <button
- type="button"
- onClick={() => moveGroup(idx, -1)}
- disabled={idx === 0}
- className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
- aria-label="Move group up"
- >
- ↑
- </button>
- <button
- type="button"
- onClick={() => moveGroup(idx, 1)}
- disabled={idx === groups.length - 1}
- className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
- aria-label="Move group down"
- >
- ↓
- </button>
- <button
- type="button"
- onClick={() => removeGroup(idx)}
- className="px-2 py-0.5 rounded border border-red-300 dark:border-red-800 text-xs text-red-700 dark:text-red-300"
- >
- Remove
- </button>
- </div>
- </div>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">ID</span>
- <input
- type="text"
- value={g.id}
- onChange={(e) => {
- const next = e.target.value;
- setGroups((prev) =>
- prev.map((row, i) =>
- i === idx ? { ...row, id: next } : row,
- ),
- );
- // If this row WAS the default and the id changes,
- // follow it so the default stays valid.
- if (idTrim && idTrim === defaultGroupId) {
- setDefaultGroupId(next.trim());
- }
- }}
- placeholder="kebab-case, e.g. chrissie-mayr"
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
- />
- <span className="text-xs text-zinc-500">
- Stable identifier referenced by channel configs.
- Lowercase letters/numbers/hyphens.
- </span>
- </label>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Name</span>
- <input
- type="text"
- value={g.name}
- onChange={(e) =>
- setGroups((prev) =>
- prev.map((row, i) =>
- i === idx ? { ...row, name: e.target.value } : row,
- ),
- )
- }
- placeholder="Display name (optional)"
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
- />
- <span className="text-xs text-zinc-500">
- Shown as the group's heading in the export UI. Leave
- blank to render the group without a heading.
- </span>
- </label>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Description</span>
- <textarea
- value={g.description}
- onChange={(e) =>
- setGroups((prev) =>
- prev.map((row, i) =>
- i === idx
- ? { ...row, description: e.target.value }
- : row,
- ),
- )
- }
- rows={2}
- placeholder="Optional blurb shown under the group label."
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
- />
- </label>
- <label className="flex items-center gap-2 text-sm">
- <input
- type="checkbox"
- checked={g.selectedByDefault}
- onChange={(e) =>
- setGroups((prev) =>
- prev.map((row, i) =>
- i === idx
- ? { ...row, selectedByDefault: e.target.checked }
- : row,
- ),
- )
- }
- className="accent-blue-600"
- />
- Selected by default
- </label>
- </div>
- );
- })}
- <div>
- <button
- type="button"
- onClick={addGroup}
- className="px-3 py-1.5 rounded border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800"
- >
- + Add group
- </button>
- </div>
- </div>
- </fieldset>
- <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3">
- <legend className="px-1 text-sm font-medium">Social links</legend>
- <p className="text-xs text-zinc-500">
- Icon-only links rendered in the export footer. SVGs are normalized
- on save: <code>width</code>/<code>height</code> are stripped and{" "}
- <code>fill</code> defaults to <code>currentColor</code> so CSS can
- style hover color. SVG must include a <code>viewBox</code>.
- </p>
- <input type="hidden" name="socialLinksJson" value={socialLinksJson} />
- <div className="flex flex-col gap-3">
- {socialLinks.length === 0 && (
- <div className="text-sm text-zinc-500">
- No social links configured.
- </div>
- )}
- {socialLinks.map((s, idx) => (
- <div
- key={idx}
- className="flex flex-col gap-2 border border-zinc-200 dark:border-zinc-800 rounded p-3 bg-zinc-50/50 dark:bg-zinc-900/40"
- >
- <div className="flex items-center justify-end gap-1">
- <button
- type="button"
- onClick={() => moveSocial(idx, -1)}
- disabled={idx === 0}
- className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
- aria-label="Move link up"
- >
- ↑
- </button>
- <button
- type="button"
- onClick={() => moveSocial(idx, 1)}
- disabled={idx === socialLinks.length - 1}
- className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
- aria-label="Move link down"
- >
- ↓
- </button>
- <button
- type="button"
- onClick={() => removeSocial(idx)}
- className="px-2 py-0.5 rounded border border-red-300 dark:border-red-800 text-xs text-red-700 dark:text-red-300"
- >
- Remove
- </button>
- </div>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Label</span>
- <input
- type="text"
- value={s.label}
- onChange={(e) =>
- setSocialLinks((prev) =>
- prev.map((row, i) =>
- i === idx ? { ...row, label: e.target.value } : row,
- ),
- )
- }
- placeholder="e.g. GitHub"
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
- />
- <span className="text-xs text-zinc-500">
- Shown as the link's tooltip and aria-label.
- </span>
- </label>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">URL</span>
- <input
- type="text"
- value={s.url}
- onChange={(e) =>
- setSocialLinks((prev) =>
- prev.map((row, i) =>
- i === idx ? { ...row, url: e.target.value } : row,
- ),
- )
- }
- placeholder="https://…"
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
- />
- <span className="text-xs text-zinc-500">
- Must start with http(s)://, mailto:, or /.
- </span>
- </label>
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">SVG markup</span>
- <textarea
- value={s.svg}
- onChange={(e) =>
- setSocialLinks((prev) =>
- prev.map((row, i) =>
- i === idx ? { ...row, svg: e.target.value } : row,
- ),
- )
- }
- rows={6}
- placeholder='<svg viewBox="0 0 24 24"><path d="…"/></svg>'
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-xs font-mono"
- />
- </label>
- </div>
- ))}
- <div>
- <button
- type="button"
- onClick={addSocial}
- className="px-3 py-1.5 rounded border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800"
- >
- + Add link
- </button>
- </div>
- </div>
- </fieldset>
<div className="flex items-center gap-3">
<button
type="submit"
diff --git a/editor/app/sites/[siteId]/page.tsx b/editor/app/sites/[siteId]/page.tsx
@@ -0,0 +1,60 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listChannels } from "yt-dlp-transcript-common/controller/channels";
+import {
+ getSite,
+ isValidSiteId,
+ listSiteIds,
+} from "yt-dlp-transcript-common/lib/site";
+import { SiteForm, type ChannelOption } from "../components/SiteForm";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ siteId: string }>;
+}): Promise<Metadata> {
+ const { siteId } = await params;
+ return { title: `${siteId} — Sites` };
+}
+
+export default async function EditSitePage({
+ params,
+}: {
+ params: Promise<{ siteId: string }>;
+}) {
+ const { siteId } = await params;
+ const paths = getPaths();
+ if (!isValidSiteId(siteId) || !listSiteIds(paths).includes(siteId)) {
+ notFound();
+ }
+ const site = getSite(siteId, paths);
+ const channels: ChannelOption[] = (await listChannels(paths)).map((c) => ({
+ slug: c.slug,
+ name: c.config.name ?? c.slug,
+ }));
+ return (
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center gap-2 text-sm text-zinc-500">
+ <Link href="/sites" className="underline hover:text-zinc-900 dark:hover:text-zinc-100">
+ Sites
+ </Link>
+ <span>/</span>
+ <span>{siteId}</span>
+ </div>
+ <div className="flex items-center justify-between gap-2">
+ <h1 className="text-2xl font-semibold">{site.siteTitle || siteId}</h1>
+ <Link
+ href={`/charts?site=${siteId}`}
+ className="text-sm underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ Edit charts dashboard →
+ </Link>
+ </div>
+ <SiteForm initial={site} channels={channels} isNew={false} />
+ </div>
+ );
+}
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -0,0 +1,147 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ writeSite,
+ deleteSite,
+ isValidSiteId,
+ parseSiteChannels,
+ type Site,
+} from "yt-dlp-transcript-common/lib/site";
+import {
+ normalizeSocialSvg,
+ parseSocialLinks,
+ type SocialLink,
+} from "yt-dlp-transcript-common/lib/settings";
+import {
+ parseChannelGroups,
+ resolveDefaultGroupId,
+} from "yt-dlp-transcript-common/lib/channelGroups";
+import { migrateToSites } from "yt-dlp-transcript-common/controller/migrateToSites";
+
+export type SaveResult = { ok: true; siteId: string } | { ok: false; error: string };
+
+export async function saveSiteAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const siteId = String(formData.get("siteId") ?? "").trim();
+ if (!isValidSiteId(siteId)) {
+ return {
+ ok: false,
+ error: "Site ID must be lowercase letters, numbers, and hyphens.",
+ };
+ }
+ const siteTitle = String(formData.get("siteTitle") ?? "").trim();
+ const headerTitle = String(formData.get("headerTitle") ?? "").trim();
+ if (!siteTitle) return { ok: false, error: "Site title is required" };
+ if (!headerTitle) return { ok: false, error: "Header title is required" };
+ const siteDescription = String(formData.get("siteDescription") ?? "").trim();
+ const homeTagline = String(formData.get("homeTagline") ?? "").trim();
+ const cloudflareProject = String(
+ formData.get("cloudflareProject") ?? "",
+ ).trim();
+
+ let groupsInput: unknown;
+ try {
+ groupsInput = JSON.parse(String(formData.get("groupsJson") ?? "[]"));
+ } catch {
+ return { ok: false, error: "Channel groups payload is malformed" };
+ }
+ const groups = parseChannelGroups(groupsInput);
+ if (groups.length === 0) {
+ return {
+ ok: false,
+ error: "At least one channel group is required (check ID/name fields)",
+ };
+ }
+ const defaultGroupIdRaw = String(formData.get("defaultGroupId") ?? "").trim();
+ const defaultGroupId = resolveDefaultGroupId(defaultGroupIdRaw, groups);
+ if (defaultGroupIdRaw && defaultGroupIdRaw !== defaultGroupId) {
+ return {
+ ok: false,
+ error: `Default group "${defaultGroupIdRaw}" is not among the configured group IDs`,
+ };
+ }
+
+ let socialInput: unknown;
+ try {
+ socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]"));
+ } catch {
+ return { ok: false, error: "Social links payload is malformed" };
+ }
+ const socialParsed = parseSocialLinks(socialInput);
+ if (Array.isArray(socialInput) && socialParsed.length !== socialInput.length) {
+ return {
+ ok: false,
+ error:
+ "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.",
+ };
+ }
+ const socialLinks: SocialLink[] = [];
+ for (const link of socialParsed) {
+ const svg = normalizeSocialSvg(link.svg);
+ if (svg === null) {
+ return {
+ ok: false,
+ error: `Social link "${link.label}" has an invalid SVG.`,
+ };
+ }
+ socialLinks.push({ ...link, svg });
+ }
+
+ let channelsInput: unknown;
+ try {
+ channelsInput = JSON.parse(String(formData.get("channelsJson") ?? "[]"));
+ } catch {
+ return { ok: false, error: "Channel membership payload is malformed" };
+ }
+ const channels = parseSiteChannels(channelsInput);
+
+ const site: Site = {
+ siteId,
+ siteTitle,
+ siteDescription,
+ headerTitle,
+ homeTagline,
+ socialLinks,
+ groups,
+ defaultGroupId,
+ channels,
+ ...(cloudflareProject ? { cloudflareProject } : {}),
+ };
+ try {
+ await writeSite(site, getPaths());
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ revalidatePath("/sites");
+ revalidatePath(`/sites/${siteId}`);
+ return { ok: true, siteId };
+}
+
+export type DeleteResult = { ok: true } | { ok: false; error: string };
+
+export async function deleteSiteAction(
+ siteId: string,
+): Promise<DeleteResult> {
+ try {
+ await deleteSite(siteId, getPaths());
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ revalidatePath("/sites");
+ return { ok: true };
+}
+
+export async function migrateToSitesAction(): Promise<DeleteResult> {
+ try {
+ await migrateToSites({ paths: getPaths() });
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ revalidatePath("/sites");
+ revalidatePath("/settings");
+ return { ok: true };
+}
diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx
@@ -0,0 +1,504 @@
+"use client";
+
+import { useState } from "react";
+import { useActionState } from "react";
+import { saveSiteAction, type SaveResult } from "../actions";
+import type { Site } from "yt-dlp-transcript-common/lib/site";
+import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups";
+import type { SocialLink } from "yt-dlp-transcript-common/lib/settings";
+
+export type ChannelOption = { slug: string; name: string };
+
+type Props = {
+ initial: Site;
+ channels: ChannelOption[];
+ isNew: boolean;
+};
+
+type GroupRow = {
+ id: string;
+ name: string;
+ description: string;
+ selectedByDefault: boolean;
+};
+type SocialRow = { label: string; url: string; svg: string };
+
+function toGroupRow(g: ChannelGroup): GroupRow {
+ return {
+ id: g.id,
+ name: g.name,
+ description: g.description ?? "",
+ selectedByDefault: g.selectedByDefault,
+ };
+}
+function toSocialRow(s: SocialLink): SocialRow {
+ return { label: s.label, url: s.url, svg: s.svg };
+}
+
+export function SiteForm({ initial, channels, isNew }: Props) {
+ const [state, formAction] = useActionState<SaveResult | undefined, FormData>(
+ saveSiteAction,
+ undefined,
+ );
+ const [groups, setGroups] = useState<GroupRow[]>(() =>
+ initial.groups.map(toGroupRow),
+ );
+ const [defaultGroupId, setDefaultGroupId] = useState(initial.defaultGroupId);
+ const [social, setSocial] = useState<SocialRow[]>(() =>
+ initial.socialLinks.map(toSocialRow),
+ );
+ // Membership keyed by slug. Order = the order channels are listed; members
+ // serialize in `channels` array order.
+ const [members, setMembers] = useState<Map<string, string>>(
+ () => new Map(initial.channels.map((c) => [c.slug, c.groupId ?? ""])),
+ );
+
+ const groupsJson = JSON.stringify(
+ groups.map((g, idx) => ({
+ id: g.id.trim(),
+ name: g.name.trim(),
+ description: g.description.trim() || undefined,
+ selectedByDefault: g.selectedByDefault,
+ order: idx,
+ })),
+ );
+ const socialLinksJson = JSON.stringify(
+ social.map((s) => ({
+ label: s.label.trim(),
+ url: s.url.trim(),
+ svg: s.svg.trim(),
+ })),
+ );
+ const channelsJson = JSON.stringify(
+ channels
+ .filter((c) => members.has(c.slug))
+ .map((c, idx) => {
+ const groupId = members.get(c.slug)?.trim();
+ return groupId
+ ? { slug: c.slug, groupId, order: idx }
+ : { slug: c.slug, order: idx };
+ }),
+ );
+
+ const toggleMember = (slug: string, on: boolean) =>
+ setMembers((prev) => {
+ const next = new Map(prev);
+ if (on) next.set(slug, prev.get(slug) ?? "");
+ else next.delete(slug);
+ return next;
+ });
+ const setMemberGroup = (slug: string, groupId: string) =>
+ setMembers((prev) => {
+ const next = new Map(prev);
+ next.set(slug, groupId);
+ return next;
+ });
+
+ const moveGroup = (idx: number, delta: -1 | 1) =>
+ setGroups((prev) => {
+ const next = prev.slice();
+ const j = idx + delta;
+ if (j < 0 || j >= next.length) return prev;
+ [next[idx], next[j]] = [next[j], next[idx]];
+ return next;
+ });
+ const addGroup = () =>
+ setGroups((prev) => [
+ ...prev,
+ { id: "", name: "", description: "", selectedByDefault: false },
+ ]);
+ const removeGroup = (idx: number) =>
+ setGroups((prev) => {
+ const next = prev.filter((_, i) => i !== idx);
+ if (next.length > 0 && !next.some((g) => g.id.trim() === defaultGroupId)) {
+ setDefaultGroupId(next[0].id.trim());
+ }
+ return next;
+ });
+ const moveSocial = (idx: number, delta: -1 | 1) =>
+ setSocial((prev) => {
+ const next = prev.slice();
+ const j = idx + delta;
+ if (j < 0 || j >= next.length) return prev;
+ [next[idx], next[j]] = [next[j], next[idx]];
+ return next;
+ });
+
+ const groupOptions = groups
+ .map((g) => ({ id: g.id.trim(), label: g.name.trim() || g.id.trim() }))
+ .filter((g) => g.id);
+
+ return (
+ <form action={formAction} className="flex flex-col gap-4 max-w-2xl">
+ <input type="hidden" name="groupsJson" value={groupsJson} />
+ <input type="hidden" name="defaultGroupId" value={defaultGroupId} />
+ <input type="hidden" name="socialLinksJson" value={socialLinksJson} />
+ <input type="hidden" name="channelsJson" value={channelsJson} />
+
+ {isNew ? (
+ <Field
+ label="Site ID"
+ name="siteId"
+ defaultValue={initial.siteId}
+ required
+ hint="Stable identifier (kebab-case). Used as the directory name and deploy key. Cannot be changed later."
+ />
+ ) : (
+ <>
+ <input type="hidden" name="siteId" value={initial.siteId} />
+ <div className="text-sm">
+ <span className="font-medium">Site ID:</span>{" "}
+ <code>{initial.siteId}</code>
+ </div>
+ </>
+ )}
+ <Field
+ label="Site title"
+ name="siteTitle"
+ defaultValue={initial.siteTitle}
+ required
+ />
+ <Field
+ label="Header title"
+ name="headerTitle"
+ defaultValue={initial.headerTitle}
+ required
+ />
+ <Field
+ label="Site description"
+ name="siteDescription"
+ defaultValue={initial.siteDescription}
+ />
+ <Field
+ label="Home tagline"
+ name="homeTagline"
+ defaultValue={initial.homeTagline}
+ />
+ <Field
+ label="Cloudflare Pages project"
+ name="cloudflareProject"
+ defaultValue={initial.cloudflareProject ?? ""}
+ hint="Project name this site deploys to (wrangler --project-name). Leave blank to set later."
+ />
+
+ <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3">
+ <legend className="px-1 text-sm font-medium">Channels</legend>
+ <p className="text-xs text-zinc-500">
+ Pick the channels this site exposes and the group each belongs to.
+ Channels not checked here are excluded from this site's build.
+ </p>
+ <div className="flex flex-col gap-1 max-h-96 overflow-y-auto">
+ {channels.length === 0 && (
+ <div className="text-sm text-zinc-500">No channels configured.</div>
+ )}
+ {channels.map((c) => {
+ const on = members.has(c.slug);
+ return (
+ <div
+ key={c.slug}
+ className="flex items-center gap-2 text-sm py-1 border-b border-zinc-100 dark:border-zinc-900"
+ >
+ <label className="flex items-center gap-2 flex-1 min-w-0">
+ <input
+ type="checkbox"
+ checked={on}
+ onChange={(e) => toggleMember(c.slug, e.target.checked)}
+ aria-label={`Include ${c.name || c.slug}`}
+ />
+ <span className="truncate">
+ {c.name || c.slug}{" "}
+ <code className="text-xs text-zinc-500">{c.slug}</code>
+ </span>
+ </label>
+ {on && groupOptions.length > 0 && (
+ <select
+ value={members.get(c.slug) ?? ""}
+ onChange={(e) => setMemberGroup(c.slug, e.target.value)}
+ aria-label={`Group for ${c.name || c.slug}`}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-0.5 text-xs shrink-0"
+ >
+ <option value="">(default)</option>
+ {groupOptions.map((g) => (
+ <option key={g.id} value={g.id}>
+ {g.label}
+ </option>
+ ))}
+ </select>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </fieldset>
+
+ <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3">
+ <legend className="px-1 text-sm font-medium">Channel groups</legend>
+ <p className="text-xs text-zinc-500">
+ Cosmetic groupings the export UI uses to organize this site's
+ channel checkboxes.
+ </p>
+ {groups.map((g, idx) => {
+ const idTrim = g.id.trim();
+ const isDefault = idTrim !== "" && idTrim === defaultGroupId;
+ return (
+ <div
+ key={idx}
+ className="flex flex-col gap-2 border border-zinc-200 dark:border-zinc-800 rounded p-3 bg-zinc-50/50 dark:bg-zinc-900/40"
+ >
+ <div className="flex items-center justify-between gap-2">
+ <label className="flex items-center gap-1.5 text-xs select-none">
+ <input
+ type="radio"
+ checked={isDefault}
+ onChange={() => setDefaultGroupId(idTrim)}
+ disabled={!idTrim}
+ className="accent-blue-600"
+ />
+ Default group
+ </label>
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={() => moveGroup(idx, -1)}
+ disabled={idx === 0}
+ className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
+ aria-label="Move group up"
+ >
+ ↑
+ </button>
+ <button
+ type="button"
+ onClick={() => moveGroup(idx, 1)}
+ disabled={idx === groups.length - 1}
+ className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
+ aria-label="Move group down"
+ >
+ ↓
+ </button>
+ <button
+ type="button"
+ onClick={() => removeGroup(idx)}
+ className="px-2 py-0.5 rounded border border-red-300 dark:border-red-800 text-xs text-red-700 dark:text-red-300"
+ >
+ Remove
+ </button>
+ </div>
+ </div>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">ID</span>
+ <input
+ type="text"
+ value={g.id}
+ onChange={(e) => {
+ const v = e.target.value;
+ setGroups((prev) =>
+ prev.map((row, i) => (i === idx ? { ...row, id: v } : row)),
+ );
+ if (idTrim && idTrim === defaultGroupId)
+ setDefaultGroupId(v.trim());
+ }}
+ placeholder="kebab-case"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ />
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Name</span>
+ <input
+ type="text"
+ value={g.name}
+ onChange={(e) =>
+ setGroups((prev) =>
+ prev.map((row, i) =>
+ i === idx ? { ...row, name: e.target.value } : row,
+ ),
+ )
+ }
+ placeholder="Display name (optional)"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Description</span>
+ <textarea
+ value={g.description}
+ onChange={(e) =>
+ setGroups((prev) =>
+ prev.map((row, i) =>
+ i === idx
+ ? { ...row, description: e.target.value }
+ : row,
+ ),
+ )
+ }
+ rows={2}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ </label>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={g.selectedByDefault}
+ onChange={(e) =>
+ setGroups((prev) =>
+ prev.map((row, i) =>
+ i === idx
+ ? { ...row, selectedByDefault: e.target.checked }
+ : row,
+ ),
+ )
+ }
+ className="accent-blue-600"
+ />
+ Selected by default
+ </label>
+ </div>
+ );
+ })}
+ <div>
+ <button
+ type="button"
+ onClick={addGroup}
+ className="px-3 py-1.5 rounded border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800"
+ >
+ + Add group
+ </button>
+ </div>
+ </fieldset>
+
+ <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3">
+ <legend className="px-1 text-sm font-medium">Social links</legend>
+ {social.map((s, idx) => (
+ <div
+ key={idx}
+ className="flex flex-col gap-2 border border-zinc-200 dark:border-zinc-800 rounded p-3 bg-zinc-50/50 dark:bg-zinc-900/40"
+ >
+ <div className="flex items-center justify-end gap-1">
+ <button
+ type="button"
+ onClick={() => moveSocial(idx, -1)}
+ disabled={idx === 0}
+ className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
+ aria-label="Move link up"
+ >
+ ↑
+ </button>
+ <button
+ type="button"
+ onClick={() => moveSocial(idx, 1)}
+ disabled={idx === social.length - 1}
+ className="px-2 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 text-xs disabled:opacity-40"
+ aria-label="Move link down"
+ >
+ ↓
+ </button>
+ <button
+ type="button"
+ onClick={() => setSocial((prev) => prev.filter((_, i) => i !== idx))}
+ className="px-2 py-0.5 rounded border border-red-300 dark:border-red-800 text-xs text-red-700 dark:text-red-300"
+ >
+ Remove
+ </button>
+ </div>
+ <input
+ type="text"
+ value={s.label}
+ onChange={(e) =>
+ setSocial((prev) =>
+ prev.map((row, i) =>
+ i === idx ? { ...row, label: e.target.value } : row,
+ ),
+ )
+ }
+ placeholder="Label (e.g. GitHub)"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ <input
+ type="text"
+ value={s.url}
+ onChange={(e) =>
+ setSocial((prev) =>
+ prev.map((row, i) =>
+ i === idx ? { ...row, url: e.target.value } : row,
+ ),
+ )
+ }
+ placeholder="https://…"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ <textarea
+ value={s.svg}
+ onChange={(e) =>
+ setSocial((prev) =>
+ prev.map((row, i) =>
+ i === idx ? { ...row, svg: e.target.value } : row,
+ ),
+ )
+ }
+ rows={4}
+ placeholder='<svg viewBox="0 0 24 24"><path d="…"/></svg>'
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-xs font-mono"
+ />
+ </div>
+ ))}
+ <div>
+ <button
+ type="button"
+ onClick={() =>
+ setSocial((prev) => [...prev, { label: "", url: "", svg: "" }])
+ }
+ className="px-3 py-1.5 rounded border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800"
+ >
+ + Add link
+ </button>
+ </div>
+ </fieldset>
+
+ <div className="flex items-center gap-3">
+ <button
+ type="submit"
+ className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90"
+ >
+ {isNew ? "Create site" : "Save site"}
+ </button>
+ {state?.ok === true && (
+ <span role="status" className="text-sm text-green-700 dark:text-green-300">
+ Saved.
+ </span>
+ )}
+ {state && state.ok === false && (
+ <span role="alert" className="text-sm text-red-700 dark:text-red-300">
+ {state.error}
+ </span>
+ )}
+ </div>
+ </form>
+ );
+}
+
+function Field({
+ label,
+ name,
+ defaultValue,
+ hint,
+ required,
+}: {
+ label: string;
+ name: string;
+ defaultValue?: string;
+ hint?: string;
+ required?: boolean;
+}) {
+ return (
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">{label}</span>
+ <input
+ type="text"
+ name={name}
+ defaultValue={defaultValue}
+ required={required}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ </label>
+ );
+}
diff --git a/editor/app/sites/components/SiteListActions.tsx b/editor/app/sites/components/SiteListActions.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { deleteSiteAction, migrateToSitesAction } from "../actions";
+
+export function DeleteSiteButton({ siteId }: { siteId: string }) {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ return (
+ <span className="flex items-center gap-2">
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => {
+ if (!confirm(`Delete site "${siteId}"? This cannot be undone.`)) return;
+ start(async () => {
+ const res = await deleteSiteAction(siteId);
+ if (!res.ok) setError(res.error);
+ else router.refresh();
+ });
+ }}
+ className="px-2 py-1 rounded border border-red-300 dark:border-red-800 text-xs text-red-700 dark:text-red-300 disabled:opacity-50"
+ >
+ {pending ? "Deleting…" : "Delete"}
+ </button>
+ {error && <span className="text-xs text-red-600">{error}</span>}
+ </span>
+ );
+}
+
+export function MigrateButton() {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ return (
+ <span className="flex items-center gap-2">
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() =>
+ start(async () => {
+ const res = await migrateToSitesAction();
+ if (!res.ok) setError(res.error);
+ else router.refresh();
+ })
+ }
+ className="px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Migrating…" : "Migrate existing settings into a site"}
+ </button>
+ {error && <span className="text-xs text-red-600">{error}</span>}
+ </span>
+ );
+}
diff --git a/editor/app/sites/new/page.tsx b/editor/app/sites/new/page.tsx
@@ -0,0 +1,45 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listChannels } from "yt-dlp-transcript-common/controller/channels";
+import {
+ DEFAULT_GROUP_FALLBACK_ID,
+ FALLBACK_GROUP,
+} from "yt-dlp-transcript-common/lib/channelGroups";
+import type { Site } from "yt-dlp-transcript-common/lib/site";
+import { SiteForm, type ChannelOption } from "../components/SiteForm";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "New site — Sites" };
+
+export default async function NewSitePage() {
+ const paths = getPaths();
+ const channels: ChannelOption[] = (await listChannels(paths)).map((c) => ({
+ slug: c.slug,
+ name: c.config.name ?? c.slug,
+ }));
+ const blank: Site = {
+ siteId: "",
+ siteTitle: "",
+ siteDescription: "",
+ headerTitle: "",
+ homeTagline: "",
+ socialLinks: [],
+ groups: [{ ...FALLBACK_GROUP }],
+ defaultGroupId: DEFAULT_GROUP_FALLBACK_ID,
+ channels: [],
+ };
+ return (
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center gap-2 text-sm text-zinc-500">
+ <Link href="/sites" className="underline hover:text-zinc-900 dark:hover:text-zinc-100">
+ Sites
+ </Link>
+ <span>/</span>
+ <span>new</span>
+ </div>
+ <h1 className="text-2xl font-semibold">New site</h1>
+ <SiteForm initial={blank} channels={channels} isNew />
+ </div>
+ );
+}
diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx
@@ -0,0 +1,59 @@
+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 { DeleteSiteButton, MigrateButton } from "./components/SiteListActions";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Sites" };
+
+export default function SitesPage() {
+ const sites = listSites(getPaths());
+ return (
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center justify-between gap-2">
+ <h1 className="text-2xl font-semibold">Sites</h1>
+ <Link
+ href="/sites/new"
+ className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90"
+ >
+ + New site
+ </Link>
+ </div>
+ <p className="text-sm text-zinc-500 max-w-2xl">
+ Each site is a selection + branding over the shared channel pool. The
+ same channel can appear on several sites; its downloads are stored once
+ and reused.
+ </p>
+ {sites.length === 0 && (
+ <div className="flex flex-col gap-3 rounded border border-zinc-200 dark:border-zinc-800 p-4">
+ <p className="text-sm">
+ No sites configured yet. If you're upgrading an existing
+ single-site install, migrate your current branding, channel groups,
+ and charts into a first site:
+ </p>
+ <MigrateButton />
+ </div>
+ )}
+ <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
+ {sites.map((s) => (
+ <li
+ key={s.siteId}
+ className="flex items-center justify-between gap-3 py-3"
+ >
+ <Link href={`/sites/${s.siteId}`} className="min-w-0">
+ <div className="font-medium">{s.siteTitle}</div>
+ <div className="text-xs text-zinc-500">
+ <code>{s.siteId}</code> · {s.channels.length} channel
+ {s.channels.length === 1 ? "" : "s"} · {s.groups.length} group
+ {s.groups.length === 1 ? "" : "s"}
+ {s.cloudflareProject ? ` · ${s.cloudflareProject}` : ""}
+ </div>
+ </Link>
+ <DeleteSiteButton siteId={s.siteId} />
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
diff --git a/editor/e2e/availability.spec.ts b/editor/e2e/availability.spec.ts
@@ -1,6 +1,12 @@
import { stat } from "node:fs/promises";
import { test, expect } from "@playwright/test";
-import { pathExists, readJson, resetData, resolvePath } from "./helpers";
+import {
+ pathExists,
+ readJson,
+ resetData,
+ resolvePath,
+ writeSite,
+} from "./helpers";
const CHANNEL = "availability-test";
const FIXTURE = "availability-baseline";
@@ -163,6 +169,9 @@ test("build index marks deleted videos in the export summaries", async ({
"WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nhello deleted\n",
);
+ await writeSite("testsite", {
+ channels: [{ slug: CHANNEL, groupId: "default" }],
+ });
await page.goto("/build");
await page.getByRole("button", { name: "Build index" }).click();
await expect(page.getByLabel("Build index output")).toContainText("Done in", {
@@ -171,7 +180,7 @@ test("build index marks deleted videos in the export summaries", async ({
const pageJson = await readJson<
Array<{ id: string; isDeleted: boolean }>
- >("test-transcripts/.export-public/summaries/page-0000.json");
+ >("test-transcripts/.export-index/sites/testsite/summaries/page-0000.json");
const byId = new Map(pageJson.map((s) => [s.id, s]));
expect(byId.get("vidpublic1")?.isDeleted).toBe(false);
expect(byId.get("viddeleted1")?.isDeleted).toBe(true);
diff --git a/editor/e2e/branding.spec.ts b/editor/e2e/branding.spec.ts
@@ -1,51 +1,37 @@
import { test, expect } from "@playwright/test";
import { resetData, writeSettings } from "./helpers";
+// The editor shell is branded by the global operational `adminTitle` (it manages
+// all sites, so it isn't tied to any one site's branding).
test.beforeEach(async () => {
await resetData("empty");
});
-test("renders siteTitle in document title and headerTitle in sidebar", async ({
- page,
-}) => {
+test("renders adminTitle in document title and sidebar", async ({ page }) => {
await writeSettings({
- siteTitle: "Cypress Browser",
- siteDescription: "x",
- headerTitle: "Cypress HQ",
- homeTagline: "",
+ adminTitle: "Cypress HQ",
maxTranscriptPageBytes: 8388608,
+ sleepBetweenDownloadsSeconds: 0,
});
await page.goto("/");
- await expect(page).toHaveTitle(/Cypress Browser/);
+ await expect(page).toHaveTitle(/Cypress HQ/);
await expect(
page.locator("aside").getByRole("link", { name: "Cypress HQ" }),
).toBeVisible();
});
-test("falls back to siteTitle when headerTitle is empty", async ({ page }) => {
- await writeSettings({
- siteTitle: "Just A Title",
- siteDescription: "",
- headerTitle: "",
- homeTagline: "",
- maxTranscriptPageBytes: 8388608,
- });
- await page.goto("/");
- await expect(
- page.locator("aside").getByRole("link", { name: "Just A Title" }),
- ).toBeVisible();
-});
-
test("reflects sidebar header after saving via the settings form", async ({
page,
}) => {
await page.goto("/settings");
- await page.getByLabel(/header title/i).fill("Saved Header");
+ await page.getByLabel(/admin title/i).fill("Saved Admin");
await page.getByRole("button", { name: /save settings/i }).click();
- await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
+ await expect(
+ page.getByRole("status").filter({ hasText: "Saved" }),
+ ).toBeVisible();
await page.goto("/");
await expect(
- page.locator("aside").getByRole("link", { name: "Saved Header" }),
+ page.locator("aside").getByRole("link", { name: "Saved Admin" }),
).toBeVisible();
});
diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts
@@ -1,25 +1,30 @@
import { test, expect } from "@playwright/test";
-import { pathExists, readJson, resetData } from "./helpers";
+import { pathExists, readJson, resetData, writeSite } from "./helpers";
-test("build index against an empty fixture writes a 0-transcript manifest", async ({
+test("build index against an empty site writes a 0-transcript manifest", async ({
page,
}) => {
await resetData("empty");
+ await writeSite("testsite", { channels: [] });
await page.goto("/build");
await page.getByRole("button", { name: "Build index" }).click();
await expect(page.getByLabel("Build index output")).toContainText("Done", {
timeout: 20_000,
});
- const manifest = await readJson<{ totalCount: number }>(
- "test-transcripts/.export-public/summaries/manifest.json",
+ const manifest = await readJson<{ totalCount: number; siteId: string }>(
+ "test-transcripts/.export-index/sites/testsite/summaries/manifest.json",
);
expect(manifest.totalCount).toBe(0);
+ expect(manifest.siteId).toBe("testsite");
});
-test("build index ingests synthetic video data and emits a transcript page", async ({
+test("build index ingests synthetic video data into shared + per-site output", async ({
page,
}) => {
await resetData("one-youtube-channel-with-data");
+ await writeSite("testsite", {
+ channels: [{ slug: "test-youtube", groupId: "default" }],
+ });
await page.goto("/build");
await page.getByRole("button", { name: "Build index" }).click();
await expect(page.getByLabel("Build index output")).toContainText("Done", {
@@ -28,16 +33,40 @@ test("build index ingests synthetic video data and emits a transcript page", asy
const manifest = await readJson<{
totalCount: number;
channels: { name: string }[];
- }>("test-transcripts/.export-public/summaries/manifest.json");
+ }>("test-transcripts/.export-index/sites/testsite/summaries/manifest.json");
expect(manifest.totalCount).toBe(1);
expect(manifest.channels.map((c) => c.name)).toContain("Test YouTube Channel");
+ // Per-channel transcript pages are written ONCE to the shared tree.
expect(
await pathExists(
- "test-transcripts/.export-public/transcripts/test-youtube/page-0000.json",
+ "test-transcripts/.export-index/shared/transcripts/test-youtube/page-0000.json",
),
).toBe(true);
});
+test("two sites over a shared pool get isolated, differently-filtered summaries", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ await writeSite("with-channel", {
+ channels: [{ slug: "test-youtube", groupId: "default" }],
+ });
+ await writeSite("without-channel", { channels: [] });
+ await page.goto("/build");
+ await page.getByRole("button", { name: "Build index" }).click();
+ await expect(page.getByLabel("Build index output")).toContainText("Done", {
+ timeout: 20_000,
+ });
+ const withChannel = await readJson<{ totalCount: number }>(
+ "test-transcripts/.export-index/sites/with-channel/summaries/manifest.json",
+ );
+ const without = await readJson<{ totalCount: number }>(
+ "test-transcripts/.export-index/sites/without-channel/summaries/manifest.json",
+ );
+ expect(withChannel.totalCount).toBe(1);
+ expect(without.totalCount).toBe(0);
+});
+
test("build static export lives on the deploy page, not the build page", async ({
page,
}) => {
diff --git a/editor/e2e/channel-build-toggle.spec.ts b/editor/e2e/channel-build-toggle.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
-import { pathExists, readJson, resetData } from "./helpers";
+import { pathExists, readJson, resetData, writeSite } from "./helpers";
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
// The build-inclusion toggle on /channels flips `excludeFromBuild` in the
@@ -8,9 +8,10 @@ import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
// output dirs. Toggling back re-includes it on the next build.
const CONFIG_PATH = "test-transcripts/channels/test-youtube/config.json";
-const MANIFEST_PATH = "test-transcripts/.export-public/summaries/manifest.json";
+const MANIFEST_PATH =
+ "test-transcripts/.export-index/sites/testsite/summaries/manifest.json";
const CHANNEL_OUT_DIR =
- "test-transcripts/.export-public/transcripts/test-youtube";
+ "test-transcripts/.export-index/shared/transcripts/test-youtube";
async function runBuild(page: import("@playwright/test").Page) {
await page.goto("/build");
@@ -24,6 +25,9 @@ test("toggling a channel off skips it from the build; toggling back restores it"
page,
}) => {
await resetData("one-youtube-channel-with-data");
+ await writeSite("testsite", {
+ channels: [{ slug: "test-youtube", groupId: "default" }],
+ });
await runBuild(page);
{
diff --git a/editor/e2e/fixtures/test-settings.default.json b/editor/e2e/fixtures/test-settings.default.json
@@ -1,8 +1,5 @@
{
- "siteTitle": "Test Browser",
- "siteDescription": "Test description",
- "headerTitle": "Test Browser",
- "homeTagline": "",
+ "adminTitle": "Test Admin",
"maxTranscriptPageBytes": 8388608,
"sleepBetweenDownloadsSeconds": 0
}
diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts
@@ -52,6 +52,38 @@ export async function writeSettings(settings: Record<string, unknown>) {
await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
}
+// Write a site config under test-transcripts/sites/<siteId>/site.json (the
+// default SITES_DIR derived from TRANSCRIPTS_DIR in tests). Pass partial fields;
+// sensible defaults fill the rest.
+export async function writeSite(
+ siteId: string,
+ site: Record<string, unknown> = {},
+) {
+ const dir = join(testTranscriptsDir, "sites", siteId);
+ await mkdir(dir, { recursive: true });
+ const full = {
+ siteId,
+ siteTitle: site.siteTitle ?? siteId,
+ siteDescription: site.siteDescription ?? "",
+ headerTitle: site.headerTitle ?? site.siteTitle ?? siteId,
+ homeTagline: site.homeTagline ?? "",
+ socialLinks: site.socialLinks ?? [],
+ groups: site.groups ?? [
+ { id: "default", name: "All channels", selectedByDefault: true },
+ ],
+ defaultGroupId: site.defaultGroupId ?? "default",
+ channels: site.channels ?? [],
+ ...(site.cloudflareProject
+ ? { cloudflareProject: site.cloudflareProject }
+ : {}),
+ };
+ await writeFile(
+ join(dir, "site.json"),
+ JSON.stringify(full, null, 2),
+ );
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+}
+
export async function readJson<T = unknown>(relPath: string): Promise<T> {
const full = resolve(editorRoot, relPath);
const raw = await readFile(full, "utf8");
diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts
@@ -10,25 +10,25 @@ test("shows resolved paths and current settings", async ({ page }) => {
await page.locator("summary").filter({ hasText: "System paths" }).click();
await expect(page.getByText("transcriptsDir")).toBeVisible();
await expect(page.getByText("ytdlpBin")).toBeVisible();
- await expect(page.getByLabel(/site title/i)).not.toHaveValue("");
- await expect(page.getByLabel(/header title/i)).not.toHaveValue("");
+ await expect(page.getByLabel(/admin title/i)).not.toHaveValue("");
});
test("saves settings via the form", async ({ page }) => {
await page.goto("/settings");
- await page.getByLabel(/site title/i).fill("Cypress Title");
- await page.getByLabel(/header title/i).fill("Cypress Header");
- await page.getByLabel(/site description/i).fill("Cypress description");
+ await page.getByLabel(/admin title/i).fill("Cypress Admin");
await page.getByRole("button", { name: /save settings/i }).click();
await expect(
page.getByRole("status").filter({ hasText: "Saved" }),
).toBeVisible();
const saved = await readJson<{
- siteTitle: string;
- headerTitle: string;
+ adminTitle: string;
+ siteTitle?: string;
+ groups?: unknown;
}>("test-settings.json");
- expect(saved.siteTitle).toBe("Cypress Title");
- expect(saved.headerTitle).toBe("Cypress Header");
+ expect(saved.adminTitle).toBe("Cypress Admin");
+ // Operational-only: branding/groups must NOT round-trip into settings.json.
+ expect(saved.siteTitle).toBeUndefined();
+ expect(saved.groups).toBeUndefined();
});
test("rejects out-of-range maxTranscriptPageBytes", async ({ page }) => {
diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts
@@ -0,0 +1,85 @@
+import { test, expect } from "@playwright/test";
+import { pathExists, readJson, resetData, writeSite } from "./helpers";
+
+type SiteFile = {
+ siteId: string;
+ siteTitle: string;
+ headerTitle: string;
+ channels: { slug: string; groupId?: string }[];
+};
+
+test("create a site with channel membership via the UI", async ({ page }) => {
+ await resetData("one-youtube-channel-with-data");
+
+ await page.goto("/sites/new");
+ await page.getByLabel(/site id/i).fill("jeralyzer");
+ await page.getByLabel(/site title/i).fill("Jeralyzer");
+ await page.getByLabel(/header title/i).fill("Jeralyzer");
+
+ // Include the fixture channel.
+ await page
+ .getByRole("checkbox", { name: /include test youtube channel/i })
+ .check();
+
+ await page.getByRole("button", { name: /create site/i }).click();
+ await expect(
+ page.getByRole("status").filter({ hasText: "Saved" }),
+ ).toBeVisible();
+
+ const site = await readJson<SiteFile>(
+ "test-transcripts/sites/jeralyzer/site.json",
+ );
+ expect(site.siteTitle).toBe("Jeralyzer");
+ expect(site.channels.map((c) => c.slug)).toContain("test-youtube");
+});
+
+test("list shows configured sites and edit updates branding", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await writeSite("alpha", { siteTitle: "Alpha Site" });
+
+ await page.goto("/sites");
+ await expect(page.getByText("Alpha Site")).toBeVisible();
+
+ await page.getByRole("link", { name: /Alpha Site/ }).click();
+ await expect(page).toHaveURL(/\/sites\/alpha/);
+ await page.getByLabel(/site title/i).fill("Alpha Renamed");
+ await page.getByRole("button", { name: /save site/i }).click();
+ await expect(
+ page.getByRole("status").filter({ hasText: "Saved" }),
+ ).toBeVisible();
+
+ const site = await readJson<SiteFile>("test-transcripts/sites/alpha/site.json");
+ expect(site.siteTitle).toBe("Alpha Renamed");
+});
+
+test("delete removes the site config", async ({ page }) => {
+ await resetData("empty");
+ await writeSite("doomed", { siteTitle: "Doomed" });
+
+ await page.goto("/sites");
+ page.once("dialog", (d) => d.accept());
+ await page.getByRole("button", { name: /delete/i }).click();
+
+ await expect(page.getByText("Doomed")).toHaveCount(0);
+ expect(await pathExists("test-transcripts/sites/doomed/site.json")).toBe(
+ false,
+ );
+});
+
+test("first-run migrate button appears only when no sites exist", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await page.goto("/sites");
+ await expect(
+ page.getByRole("button", { name: /migrate existing settings/i }),
+ ).toBeVisible();
+
+ await writeSite("already", {});
+ await page.reload();
+ await expect(
+ page.getByRole("button", { name: /migrate existing settings/i }),
+ ).toHaveCount(0);
+});
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Per-site builds (major change).** The export is now built and deployed once per site from a shared index, so one editor can publish several independent sites (e.g. Jeralyzer and Rekietalyzer) over the same channel pool. Each deployed site shows only its own channels and carries its own branding (title, header, tagline, footer social links), channel groups, and charts dashboard — a channel shared between sites is downloaded once and never duplicated. The site is selected at build time via the `SITE_ID` env var; the heavy per-channel transcript/subs data is generated once and shared, while each site gets a filtered summaries/subs/stats bundle composed into its own static output. No user-visible change for a single-site install beyond the branding now coming from that site's config.
- **Twitch.tv videos.** Twitch is now a supported platform: Twitch videos play inline via a Twitch embed, and "Twitch" appears as an option in the charts platform filter and series grouping.
- **Advanced query builder when charting search matches.** A chart's search source now uses the same layered query builder as the main search page — combine any number of layers with AND/OR/NOT, nest groups, and set each layer's scope and regex — instead of the old single term box. Simple one-term searches still collapse to a single input.
- **"Chart this search."** A new control beside "Share current search" turns the query you're looking at into a chart in one click: pick a starting template (mentions over time / matching videos per month / matches by channel) and jump to the Charts tab with the query pre-filled and ready to edit. Choose whether it's added to your existing board or opens as a single-chart board, and your selected channels carry over as the chart's channel filter.
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -1,7 +1,7 @@
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { currentSite } from "../lib/site";
export default function Footer() {
- const { socialLinks } = getSettings();
+ const { socialLinks } = currentSite();
return (
<footer className="mt-auto border-t border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between gap-4 text-sm text-zinc-500">
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
+import { currentSite } from "./lib/site";
import Footer from "./components/Footer";
import "./globals.css";
@@ -17,7 +17,7 @@ const geistMono = Geist_Mono({
});
export function generateMetadata(): Metadata {
- const s = getSettings();
+ const s = currentSite();
return {
title: {
default: s.siteTitle,
@@ -32,7 +32,7 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- const settings = getSettings();
+ const settings = currentSite();
return (
<html
lang="en"
diff --git a/export/app/lib/site.ts b/export/app/lib/site.ts
@@ -0,0 +1,29 @@
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getSite,
+ defaultSiteId,
+ listSiteIds,
+ parseSite,
+ type Site,
+} from "yt-dlp-transcript-common/lib/site";
+
+// The export app builds ONE site at a time, selected by the SITE_ID env var
+// (set by the build:site script). At dev time SITE_ID may be unset; we then
+// fall back to the lone configured site so `next dev` just works for a
+// single-site install. Branding, social links, tagline and the charts/search
+// data all derive from this site.
+//
+// When nothing is selected and no (or multiple) sites exist, we synthesize a
+// neutral default rather than throwing — otherwise `next dev` would crash on
+// boot before any site is configured (and the e2e harness couldn't start the
+// server). A real build always passes SITE_ID, so production is unaffected.
+export function currentSite(): Site {
+ const paths = getPaths();
+ const explicit = process.env.SITE_ID;
+ if (explicit) return getSite(explicit, paths);
+ const fallback = defaultSiteId(paths);
+ if (fallback) return getSite(fallback, paths);
+ const first = listSiteIds(paths)[0];
+ if (first) return getSite(first, paths);
+ return parseSite("default", {});
+}
diff --git a/export/app/page.tsx b/export/app/page.tsx
@@ -1,11 +1,11 @@
import { countTranscripts } from "yt-dlp-transcript-common/lib/transcripts";
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
+import { currentSite } from "./lib/site";
export default async function Home() {
- const settings = getSettings();
+ const settings = currentSite();
const count = await countTranscripts();
return (
diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json
@@ -0,0 +1,17 @@
+{
+ "siteId": "testsite",
+ "siteTitle": "Fixture Site Title",
+ "siteDescription": "Fixture site description",
+ "headerTitle": "Fixture Header",
+ "homeTagline": "Fixture tagline here",
+ "socialLinks": [
+ {
+ "label": "GitHub",
+ "url": "https://github.com/example",
+ "svg": "<svg aria-hidden=\"true\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M1 1h2\"/></svg>"
+ }
+ ],
+ "groups": [{ "id": "default", "name": "All channels", "selectedByDefault": true }],
+ "defaultGroupId": "default",
+ "channels": [{ "slug": "test-youtube", "groupId": "default" }]
+}
diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts
@@ -0,0 +1,30 @@
+import { test, expect } from "@playwright/test";
+import { installRoutes } from "./helpers";
+
+// The export app is built per-site; its SSR branding (document title, header,
+// home tagline, footer social links) comes from currentSite() reading the
+// SITE_ID's site.json. The playwright config points the dev server at the
+// `testsite` fixture (see export/e2e/fixtures/sites/testsite/site.json).
+
+test("document title and header come from the site config", async ({ page }) => {
+ await installRoutes(page);
+ await page.goto("/");
+ await expect(page).toHaveTitle(/Fixture Site Title/);
+ await expect(
+ page.getByRole("link", { name: "Fixture Header" }),
+ ).toBeVisible();
+});
+
+test("home tagline comes from the site config", async ({ page }) => {
+ await installRoutes(page);
+ await page.goto("/");
+ await expect(page.getByText("Fixture tagline here")).toBeVisible();
+});
+
+test("footer renders the site's social links", async ({ page }) => {
+ await installRoutes(page);
+ await page.goto("/");
+ await expect(
+ page.getByRole("link", { name: "GitHub" }),
+ ).toHaveAttribute("href", "https://github.com/example");
+});
diff --git a/export/package.json b/export/package.json
@@ -8,8 +8,10 @@
"build:index": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-index.ts",
"build:stats": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-stats.ts",
"build:templates": "tsx ../common/bin/build-chart-templates.ts",
- "prebuild": "pnpm run build:index && pnpm run build:stats && pnpm run build:templates",
- "build": "next build",
+ "build:data": "pnpm run build:index && pnpm run build:stats && pnpm run build:templates",
+ "compose:site": "tsx ../common/bin/compose-site.ts",
+ "prebuild": "pnpm run build:data",
+ "build": "pnpm run compose:site && next build",
"start": "serve out",
"lint": "eslint",
"e2e": "playwright test",
diff --git a/export/playwright.config.ts b/export/playwright.config.ts
@@ -6,6 +6,9 @@ const PORT = Number(process.env.PORT ?? 3020);
const baseURL = `http://localhost:${PORT}`;
const TEST_SETTINGS_FILE = path.resolve(process.cwd(), "test-settings.json");
+// SSR branding comes from currentSite() reading SITES_DIR/<SITE_ID>/site.json.
+// Point the dev server at the fixture site so branding assertions are stable.
+const TEST_SITES_DIR = path.resolve(process.cwd(), "e2e", "fixtures", "sites");
// Server-side reads (layout settings, summaries totalCount) hit the real
// `export/public/` on disk — that's tolerated for these tests, since we only
@@ -28,6 +31,8 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
env: {
SETTINGS_FILE: TEST_SETTINGS_FILE,
+ SITES_DIR: TEST_SITES_DIR,
+ SITE_ID: "testsite",
},
},
use: {
diff --git a/settings.json.example b/settings.json.example
@@ -1,8 +1,5 @@
{
- "siteTitle": "Jeralyzer",
- "siteDescription": "Browse and search Jeremy Hambly's video transcripts",
- "headerTitle": "Jeralyzer",
- "homeTagline": "",
+ "adminTitle": "Transcript Browser Admin",
"maxTranscriptPageBytes": 8388608,
"transcribeBin": "whisper-cli",
"transcribeModel": "~/whispercpp/whisper.cpp/models/ggml-base.en.bin",
@@ -18,40 +15,5 @@
],
"cookiesFromBrowser": "firefox",
"sleepBetweenDownloadsSeconds": 10,
- "inlineTranscribeOnFallback": false,
- "groups": [
- {
- "id": "default",
- "name": "",
- "selectedByDefault": true,
- "order": 0
- },
- {
- "id": "archives",
- "name": "Archives",
- "selectedByDefault": true,
- "description": "Channels that archive Jeremy's content, out of his reach",
- "order": 1
- },
- {
- "id": "other",
- "name": "Extended Universe",
- "selectedByDefault": false,
- "description": "Interesting related characters who aren't Jeremy himself",
- "order": 2
- }
- ],
- "defaultGroupId": "default",
- "socialLinks": [
- {
- "label": "Kiwi Farms",
- "url": "https://kiwifarms.st/members/i-mean-im-just-saying.174691/",
- "svg": "<svg aria-hidden=\"true\" fill=\"currentColor\" version=\"1.1\" id=\"Layer_1\" x=\"0px\" y=\"0px\" viewBox=\"0 0 70.307863 74.803198\" enable-background=\"new 0 0 198.689 74.803\" xml:space=\"preserve\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:svg=\"http://www.w3.org/2000/svg\"><defs id=\"defs15\"><linearGradient id=\"SVGID_1_\" gradientUnits=\"userSpaceOnUse\" x1=\"-296.84601\" y1=\"-520.1969\" x2=\"-296.84601\" y2=\"-595\" gradientTransform=\"matrix(1,0,0,-1,331.99986,-520.1968)\"><stop offset=\"0\" style=\"stop-color:#D7DF23\" id=\"stop1\" /><stop offset=\"0.1996\" style=\"stop-color:#D1DD27\" id=\"stop2\" /><stop offset=\"0.4673\" style=\"stop-color:#C1D82F\" id=\"stop3\" /><stop offset=\"0.7722\" style=\"stop-color:#A6CE39\" id=\"stop4\" /><stop offset=\"1\" style=\"stop-color:#8DC63F\" id=\"stop5\" /></linearGradient></defs><path fill=\"url(#SVGID_1_)\" d=\"m 38.615858,74.803198 c 12.037,-10.62 13.61,-17.838 6.148,-31.397 -3.974,-7.221 -9.139,-13.787 -14.285,-21.417 -9.439,10.185 -17.371,20.045 -19.829,33.09 -1.4659996,7.778 1.87,15.163 9.069,19.724 -10.3539996,-0.362 -19.06199962,-11.798 -19.65999962,-22.867 -0.71,-13.147 5.09200002,-23.802 12.61699962,-33.791 2.698,-3.582 6.081,-6.65 8.742,-10.2550001 5.887,-7.97600004 13.952,-8.81200004 22.707,-7.22900004 10.489,1.89700004 20.381,5.02400004 26.155,15.14600014 0.046,0.081 -0.112,0.279 0.028,-0.041 -5.001,-2.867 -9.802,-6.3420001 -15.138,-8.4770001 -5.351,-2.14 -11.223,-2.126 -16.806,-0.333 4.931,6.6700001 9.965,12.8520001 14.288,19.4970001 4.684,7.199 7.271,15.175 7.026,24.003 -0.351,12.689 -6.81,20.62 -21.062,24.347 z\" id=\"path5\" style=\"fill:url(#SVGID_1_)\" /><circle fill=\"#414042\" cx=\"31.16486\" cy=\"9.6271973\" r=\"2.1010001\" id=\"circle5\" /></svg>"
- },
- {
- "label": "x.com",
- "url": "https://x.com/IMIJS_KF",
- "svg": "<svg aria-hidden=\"true\" viewBox=\"0 0 1200 1227\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z\" fill=\"white\"/>\n</svg>"
- }
- ]
+ "inlineTranscribeOnFallback": false
}
diff --git a/sites/jeralyzer/site.json.example b/sites/jeralyzer/site.json.example
@@ -0,0 +1,37 @@
+{
+ "siteId": "jeralyzer",
+ "siteTitle": "Jeralyzer",
+ "siteDescription": "Browse and search Jeremy Hambly's video transcripts",
+ "headerTitle": "Jeralyzer",
+ "homeTagline": "",
+ "cloudflareProject": "jeralyzer",
+ "groups": [
+ { "id": "default", "name": "", "selectedByDefault": true, "order": 0 },
+ {
+ "id": "archives",
+ "name": "Archives",
+ "selectedByDefault": true,
+ "description": "Channels that archive Jeremy's content, out of his reach",
+ "order": 1
+ },
+ {
+ "id": "other",
+ "name": "Extended Universe",
+ "selectedByDefault": false,
+ "description": "Interesting related characters who aren't Jeremy himself",
+ "order": 2
+ }
+ ],
+ "defaultGroupId": "default",
+ "channels": [
+ { "slug": "jeremy-hambly", "groupId": "default" },
+ { "slug": "the-quartering", "groupId": "archives" }
+ ],
+ "socialLinks": [
+ {
+ "label": "x.com",
+ "url": "https://x.com/IMIJS_KF",
+ "svg": "<svg aria-hidden=\"true\" viewBox=\"0 0 1200 1227\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z\"/></svg>"
+ }
+ ]
+}