commit 640e730bc934f5d22883b1fbb9b1708d98c5dfb6
parent 177fb06b810326545cf73428f39f04fd4a3145b7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 18 May 2026 19:39:57 -0400
Polish on the export app post-Chrissie Mayr
Diffstat:
21 files changed, 1419 insertions(+), 28 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
Binary files differ.
diff --git a/common/components/exportAdvancedStorage.ts b/common/components/exportAdvancedStorage.ts
@@ -0,0 +1,102 @@
+// localStorage layer for the export "Advanced options" panel. Independent
+// of exportFilterStorage so filter resets / profile switches don't disturb
+// advanced settings (and vice versa). Strict parser — malformed value
+// resets storage and returns null so the UI falls back to defaults.
+
+const KEY = "ytdlp-tb:export-advanced";
+const VERSION = 1;
+
+export type AdvancedSnapshot = {
+ hitBatchValue: number;
+ hitBatchUnlimited: boolean;
+ groupsBatchValue: number;
+ groupsBatchUnlimited: boolean;
+ fetchConcurrency: number;
+ flushIntervalMs: number;
+};
+
+type StoredAdvanced = {
+ v: 1;
+ s: AdvancedSnapshot;
+};
+
+function clampInt(
+ v: unknown,
+ min: number,
+ max: number,
+ fallback: number,
+): number {
+ if (typeof v !== "number" || !Number.isFinite(v)) return fallback;
+ const n = Math.floor(v);
+ if (n < min) return min;
+ if (n > max) return max;
+ return n;
+}
+
+function parseSnapshot(raw: unknown): AdvancedSnapshot | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record<string, unknown>;
+ if (typeof r.hitBatchUnlimited !== "boolean") return null;
+ if (typeof r.groupsBatchUnlimited !== "boolean") return null;
+ return {
+ hitBatchValue: clampInt(r.hitBatchValue, 1, 1_000_000, 500),
+ hitBatchUnlimited: r.hitBatchUnlimited,
+ groupsBatchValue: clampInt(r.groupsBatchValue, 1, 1_000_000, 50),
+ groupsBatchUnlimited: r.groupsBatchUnlimited,
+ fetchConcurrency: clampInt(r.fetchConcurrency, 1, 32, 6),
+ flushIntervalMs: clampInt(r.flushIntervalMs, 16, 2000, 120),
+ };
+}
+
+function parseStored(raw: string): AdvancedSnapshot | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (!parsed || typeof parsed !== "object") return null;
+ const r = parsed as Record<string, unknown>;
+ if (r.v !== VERSION) return null;
+ return parseSnapshot(r.s);
+}
+
+export function loadAdvanced(): AdvancedSnapshot | null {
+ if (typeof window === "undefined") return null;
+ let raw: string | null = null;
+ try {
+ raw = window.localStorage.getItem(KEY);
+ } catch {
+ return null;
+ }
+ if (raw === null) return null;
+ const parsed = parseStored(raw);
+ if (!parsed) {
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+ return null;
+ }
+ return parsed;
+}
+
+export function saveAdvanced(snapshot: AdvancedSnapshot): void {
+ if (typeof window === "undefined") return;
+ const stored: StoredAdvanced = { v: VERSION, s: snapshot };
+ try {
+ window.localStorage.setItem(KEY, JSON.stringify(stored));
+ } catch {
+ /* quota/serialization errors are non-fatal */
+ }
+}
+
+export function clearAdvanced(): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts
@@ -0,0 +1,177 @@
+// localStorage layer for the export filter UI: working snapshot + named
+// profiles + active-profile pointer + UI-only collapse state. Strict parser
+// — any malformed value resets storage and returns null so the UI falls
+// back to group defaults.
+
+import type { SearchMode } from "./urlState";
+
+const KEY = "ytdlp-tb:export-filters";
+const VERSION = 1;
+
+// Per-channel delta vs. group defaults. Storing the *override* (not the
+// resolved selection) means newly-added channels naturally inherit their
+// group's selectedByDefault without showing up as accidentally-unchecked.
+export type FilterSnapshot = {
+ channels: { included: string[]; excluded: string[] };
+ nov?: boolean;
+ nol?: boolean;
+ naa?: boolean;
+ nar?: boolean;
+ nav?: boolean;
+ nd?: boolean;
+ mode?: SearchMode;
+ tracks?: string[];
+};
+
+export type StoredState = {
+ v: 1;
+ working: FilterSnapshot;
+ profiles: Record<string, FilterSnapshot>;
+ activeProfileName: string | null;
+ // UI-only: persistent collapse state per group id. Lives here rather than
+ // inside snapshots so toggling a section never marks a profile dirty.
+ collapsedGroups?: string[];
+};
+
+export function emptySnapshot(): FilterSnapshot {
+ return { channels: { included: [], excluded: [] } };
+}
+
+export function emptyStoredState(): StoredState {
+ return {
+ v: VERSION,
+ working: emptySnapshot(),
+ profiles: {},
+ activeProfileName: null,
+ };
+}
+
+function isStringArray(v: unknown): v is string[] {
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
+}
+
+function parseSnapshot(raw: unknown): FilterSnapshot | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record<string, unknown>;
+ const ch = r.channels as Record<string, unknown> | undefined;
+ if (!ch || typeof ch !== "object") return null;
+ if (!isStringArray(ch.included) || !isStringArray(ch.excluded)) return null;
+ const snap: FilterSnapshot = {
+ channels: {
+ included: ch.included.slice(),
+ excluded: ch.excluded.slice(),
+ },
+ };
+ if (typeof r.nov === "boolean") snap.nov = r.nov;
+ if (typeof r.nol === "boolean") snap.nol = r.nol;
+ if (typeof r.naa === "boolean") snap.naa = r.naa;
+ if (typeof r.nar === "boolean") snap.nar = r.nar;
+ if (typeof r.nav === "boolean") snap.nav = r.nav;
+ if (typeof r.nd === "boolean") snap.nd = r.nd;
+ if (r.mode === "transcripts" || r.mode === "subs") snap.mode = r.mode;
+ if (isStringArray(r.tracks)) snap.tracks = r.tracks.slice();
+ return snap;
+}
+
+function parseStored(raw: string): StoredState | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (!parsed || typeof parsed !== "object") return null;
+ const r = parsed as Record<string, unknown>;
+ if (r.v !== VERSION) return null;
+ const working = parseSnapshot(r.working);
+ if (!working) return null;
+ const profilesRaw = r.profiles;
+ if (!profilesRaw || typeof profilesRaw !== "object" || Array.isArray(profilesRaw)) {
+ return null;
+ }
+ const profiles: Record<string, FilterSnapshot> = {};
+ for (const [name, snapRaw] of Object.entries(
+ profilesRaw as Record<string, unknown>,
+ )) {
+ if (!name) continue;
+ const snap = parseSnapshot(snapRaw);
+ if (!snap) return null;
+ profiles[name] = snap;
+ }
+ const active =
+ typeof r.activeProfileName === "string" && r.activeProfileName in profiles
+ ? r.activeProfileName
+ : null;
+ const out: StoredState = {
+ v: VERSION,
+ working,
+ profiles,
+ activeProfileName: active,
+ };
+ if (isStringArray(r.collapsedGroups)) {
+ out.collapsedGroups = r.collapsedGroups.slice();
+ }
+ return out;
+}
+
+export function loadStoredState(): StoredState | null {
+ if (typeof window === "undefined") return null;
+ let raw: string | null = null;
+ try {
+ raw = window.localStorage.getItem(KEY);
+ } catch {
+ return null;
+ }
+ if (raw === null) return null;
+ const parsed = parseStored(raw);
+ if (!parsed) {
+ // Reset on malformed payloads per spec.
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+ return null;
+ }
+ return parsed;
+}
+
+export function saveStoredState(next: StoredState): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(KEY, JSON.stringify(next));
+ } catch {
+ /* quota/serialization errors are non-fatal */
+ }
+}
+
+export function clearStoredState(): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+}
+
+function sortedJson(arr: string[]): string {
+ return JSON.stringify(arr.slice().sort());
+}
+
+export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean {
+ if (sortedJson(a.channels.included) !== sortedJson(b.channels.included)) {
+ return false;
+ }
+ if (sortedJson(a.channels.excluded) !== sortedJson(b.channels.excluded)) {
+ return false;
+ }
+ if ((a.nov ?? false) !== (b.nov ?? false)) return false;
+ if ((a.nol ?? false) !== (b.nol ?? false)) return false;
+ if ((a.naa ?? false) !== (b.naa ?? false)) return false;
+ if ((a.nar ?? false) !== (b.nar ?? false)) return false;
+ if ((a.nav ?? false) !== (b.nav ?? false)) return false;
+ if ((a.nd ?? false) !== (b.nd ?? false)) return false;
+ if ((a.mode ?? "transcripts") !== (b.mode ?? "transcripts")) return false;
+ if (sortedJson(a.tracks ?? []) !== sortedJson(b.tracks ?? [])) return false;
+ return true;
+}
diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts
@@ -64,6 +64,7 @@ import {
type ChannelConfig,
type ChannelHandling,
} from "../lib/channelConfig";
+import { resolveChannelGroupId } from "../lib/channelGroups";
import type { Paths } from "../lib/paths";
import {
pickIndexTranscript,
@@ -368,7 +369,36 @@ export async function buildIndex({
const anyMutations =
added.length > 0 || changed.length > 0 || removed.length > 0;
- const { maxTranscriptPageBytes: configuredMaxPageBytes } = getSettings();
+ 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
+ );
+ };
if (!anyMutations && !schemaBumped) {
const manifestRaw = await readFile(manifestPath, "utf8").catch(() => null);
if (manifestRaw) {
@@ -377,7 +407,8 @@ export async function buildIndex({
if (
parsed.version === MANIFEST_VERSION &&
parsed.totalCount === live.length &&
- parsed.pageSize === SUMMARIES_PAGE_SIZE
+ parsed.pageSize === SUMMARIES_PAGE_SIZE &&
+ manifestGroupsMatch(parsed)
) {
const firstPage = path.join(summariesDir, pageFileName(0));
const lastPage = path.join(
@@ -734,6 +765,7 @@ export async function buildIndex({
let subsPagesDeleted = 0;
const subsChannelEntries: SubsChannelEntry[] = [];
let subsTotalCount = 0;
+ let subsLiveChatTotalCount = 0;
const writeSubsPage = async (
channelSlug: string,
@@ -769,6 +801,7 @@ export async function buildIndex({
const subSlugToPage: Record<string, number> = {};
const tracksInChannel = new Set<string>();
let videoCount = 0;
+ let liveChatCount = 0;
for (const { key } of byChannel.getRange({
start: [channelSlug],
@@ -786,10 +819,13 @@ export async function buildIndex({
);
const display = toDisplaySummary(summary, { isDeleted });
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);
const entryBytes = Buffer.byteLength(encoded, "utf8");
@@ -869,13 +905,19 @@ export async function buildIndex({
path.join(subsChannelDir, "manifest.json"),
channelSubsManifest,
);
+ const subsEntryName = cfg.name ?? channelSlug;
subsChannelEntries.push({
- name: cfg.name ?? channelSlug,
+ name: subsEntryName,
slug: channelSlug,
videoCount,
tracks: tracksList,
+ liveChatCount,
+ groupId:
+ channelGroupByName.get(subsEntryName) ??
+ resolveChannelGroupId(cfg.group, settingsGroups, settingsDefaultGroupId),
});
subsTotalCount += videoCount;
+ subsLiveChatTotalCount += liveChatCount;
}
await subPageHashes.flushed;
@@ -902,7 +944,10 @@ export async function buildIndex({
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"),
@@ -956,7 +1001,13 @@ export async function buildIndex({
}
const channelList: ChannelEntry[] = Array.from(channelCounts.entries())
- .map(([name, count]) => ({ name, count }))
+ .map(([name, count]) => ({
+ name,
+ count,
+ groupId:
+ channelGroupByName.get(name) ??
+ resolveChannelGroupId(undefined, settingsGroups, settingsDefaultGroupId),
+ }))
.sort((a, b) => a.name.localeCompare(b.name));
const manifest: Manifest = {
@@ -966,6 +1017,8 @@ export async function buildIndex({
pageCount: pageIndex,
generatedAt: new Date().toISOString(),
channels: channelList,
+ groups: settingsGroups,
+ defaultGroupId: settingsDefaultGroupId,
};
await writeJsonAtomic(manifestPath, manifest);
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -16,6 +16,9 @@ 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[];
@@ -81,6 +84,9 @@ 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/channelGroups.ts b/common/lib/channelGroups.ts
@@ -0,0 +1,103 @@
+// Channel grouping primitives. A ChannelGroup is a cosmetic+default-selection
+// bucket the export UI uses to organize channel checkboxes. Membership lives
+// on each ChannelConfig (group?: string) — group definitions themselves live
+// in SiteSettings.groups so they can be edited centrally without touching
+// every channel config when a description or default-selected value changes.
+
+export type ChannelGroup = {
+ id: string;
+ name: string;
+ description?: string;
+ selectedByDefault: boolean;
+ order?: number;
+};
+
+export const DEFAULT_GROUP_FALLBACK_ID = "default";
+
+// Synthesized fallback used when settings has no groups configured at all,
+// so the UI always has a non-empty grouping to render.
+export const FALLBACK_GROUP: ChannelGroup = {
+ id: DEFAULT_GROUP_FALLBACK_ID,
+ name: "All channels",
+ selectedByDefault: true,
+};
+
+const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
+
+export function isValidGroupId(id: unknown): id is string {
+ return typeof id === "string" && ID_RE.test(id);
+}
+
+export function parseChannelGroup(raw: unknown): ChannelGroup | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record<string, unknown>;
+ if (!isValidGroupId(r.id)) return null;
+ // Name may be blank — UI treats empty as "no header" and falls back to id
+ // for admin contexts. Still require it to be a string for shape stability.
+ if (typeof r.name !== "string") return null;
+ const group: ChannelGroup = {
+ id: r.id,
+ name: r.name.trim(),
+ selectedByDefault: r.selectedByDefault === true,
+ };
+ if (typeof r.description === "string" && r.description.trim()) {
+ group.description = r.description.trim();
+ }
+ if (typeof r.order === "number" && Number.isFinite(r.order)) {
+ group.order = Math.floor(r.order);
+ }
+ return group;
+}
+
+export function parseChannelGroups(raw: unknown): ChannelGroup[] {
+ if (!Array.isArray(raw)) return [];
+ const out: ChannelGroup[] = [];
+ const seen = new Set<string>();
+ for (const entry of raw) {
+ const g = parseChannelGroup(entry);
+ if (!g) continue;
+ if (seen.has(g.id)) continue;
+ seen.add(g.id);
+ out.push(g);
+ }
+ return out;
+}
+
+// Picks a usable defaultGroupId given the candidate and the configured groups.
+// Strategy: honor the candidate if it matches an existing group; otherwise
+// fall back to the first group's id; otherwise the synthesized fallback id.
+export function resolveDefaultGroupId(
+ candidate: unknown,
+ groups: ReadonlyArray<ChannelGroup>,
+): string {
+ if (typeof candidate === "string" && groups.some((g) => g.id === candidate)) {
+ return candidate;
+ }
+ if (groups.length > 0) return groups[0].id;
+ return DEFAULT_GROUP_FALLBACK_ID;
+}
+
+// Maps a channel's optional group field to a concrete group id, folding
+// unknown or missing values onto the configured default.
+export function resolveChannelGroupId(
+ channelGroup: string | undefined,
+ groups: ReadonlyArray<ChannelGroup>,
+ defaultGroupId: string,
+): string {
+ if (channelGroup && groups.some((g) => g.id === channelGroup)) {
+ return channelGroup;
+ }
+ return defaultGroupId;
+}
+
+// Stable sort for rendering: explicit order asc, then name asc.
+export function sortGroups(
+ groups: ReadonlyArray<ChannelGroup>,
+): ChannelGroup[] {
+ return groups.slice().sort((a, b) => {
+ const ao = a.order ?? Number.POSITIVE_INFINITY;
+ const bo = b.order ?? Number.POSITIVE_INFINITY;
+ if (ao !== bo) return ao - bo;
+ return a.name.localeCompare(b.name);
+ });
+}
diff --git a/common/lib/manifest.ts b/common/lib/manifest.ts
@@ -1,6 +1,12 @@
+import type { ChannelGroup } from "./channelGroups";
+
export type ChannelEntry = {
name: string;
count: number;
+ // Group this channel belongs to (resolved against SiteSettings.groups at
+ // index-build time). Optional for backwards compat with older manifests:
+ // a missing groupId implies the default group.
+ groupId?: string;
};
export type Manifest = {
@@ -10,9 +16,14 @@ export type Manifest = {
pageCount: number;
generatedAt: string;
channels: ChannelEntry[];
+ // Channel groups + default-group id, surfaced to clients so they can render
+ // grouped checkboxes without a second roundtrip. Optional for backwards
+ // compat — clients fall back to a synthesized single group.
+ groups?: ChannelGroup[];
+ defaultGroupId?: string;
};
-export const MANIFEST_VERSION = 1;
+export const MANIFEST_VERSION = 2;
export const SUMMARIES_PAGE_SIZE = 1000;
export function pageFileName(index: number): string {
@@ -34,20 +45,29 @@ export type ChannelTranscriptsManifest = {
slugToPage: Record<string, number>;
};
-export const SUBS_MANIFEST_VERSION = 1;
+export const SUBS_MANIFEST_VERSION = 2;
export type SubsChannelEntry = {
name: string;
slug: string;
videoCount: number;
tracks: string[];
+ // Number of videos in this channel that contain a live_chat track. Optional
+ // for backwards compat — older manifests don't have it.
+ liveChatCount?: number;
+ groupId?: string;
};
export type SubsManifest = {
version: number;
channels: SubsChannelEntry[];
totalCount: number;
+ // Sum of liveChatCount across all channels; used by the export UI to label
+ // the Live chat tab. Optional for backwards compat.
+ liveChatTotalCount?: number;
generatedAt: string;
+ groups?: ChannelGroup[];
+ defaultGroupId?: string;
};
export const SUBS_CHANNEL_MANIFEST_VERSION = 1;
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -1,4 +1,11 @@
import fs from "node:fs";
+import {
+ DEFAULT_GROUP_FALLBACK_ID,
+ FALLBACK_GROUP,
+ parseChannelGroups,
+ resolveDefaultGroupId,
+ type ChannelGroup,
+} from "./channelGroups";
import { getPaths } from "./paths";
export type SiteSettings = {
@@ -24,6 +31,21 @@ 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 = {
+ label: string;
+ url: string;
+ svg: string;
};
export const SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS = 600;
@@ -67,9 +89,67 @@ 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: [],
};
}
+const SOCIAL_URL_RE = /^(https?:\/\/|mailto:|\/)/i;
+
+export function parseSocialLinks(input: unknown): SocialLink[] {
+ if (!Array.isArray(input)) return [];
+ const out: SocialLink[] = [];
+ for (const raw of input) {
+ if (!raw || typeof raw !== "object") continue;
+ const r = raw as Record<string, unknown>;
+ const label = typeof r.label === "string" ? r.label.trim() : "";
+ const url = typeof r.url === "string" ? r.url.trim() : "";
+ const svg = typeof r.svg === "string" ? r.svg : "";
+ if (!label || !url || !svg) continue;
+ if (!SOCIAL_URL_RE.test(url)) continue;
+ out.push({ label, url, svg });
+ }
+ return out;
+}
+
+// Normalize an admin-provided SVG snippet for inline use in the export
+// footer. Returns null on anything that looks unsafe or unrenderable.
+// Steps: trim, allowlist-check, strip width/height, force fill="currentColor"
+// + aria-hidden on the root <svg>. Requires a viewBox so the icon scales.
+export function normalizeSocialSvg(raw: string): string | null {
+ if (typeof raw !== "string") return null;
+ const trimmed = raw.trim();
+ if (!trimmed.startsWith("<svg") || !trimmed.endsWith("</svg>")) return null;
+ if (/<script\b/i.test(trimmed)) return null;
+ if (/<foreignObject\b/i.test(trimmed)) return null;
+ if (/<iframe\b/i.test(trimmed)) return null;
+ if (/javascript:/i.test(trimmed)) return null;
+ if (/\son[a-z]+\s*=/i.test(trimmed)) return null;
+ if (/<\?|<!ENTITY/i.test(trimmed)) return null;
+
+ const openEnd = trimmed.indexOf(">");
+ if (openEnd < 0) return null;
+ let opening = trimmed.slice(0, openEnd);
+ const rest = trimmed.slice(openEnd);
+
+ if (!/\sviewBox\s*=\s*"/i.test(opening)) return null;
+
+ opening = opening.replace(/\s(width|height)\s*=\s*"[^"]*"/gi, "");
+ opening = opening.replace(/\s(width|height)\s*=\s*'[^']*'/gi, "");
+
+ if (!/\sfill\s*=/i.test(opening)) {
+ opening = opening.replace(/^<svg/i, '<svg fill="currentColor"');
+ }
+ if (!/\saria-hidden\s*=/i.test(opening)) {
+ opening = opening.replace(/^<svg/i, '<svg aria-hidden="true"');
+ }
+ return opening + rest;
+}
+
export function clampSleepBetweenDownloadsSeconds(value: unknown): number {
const n =
typeof value === "number" && Number.isFinite(value)
@@ -110,6 +190,15 @@ 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;
}
@@ -151,7 +240,25 @@ 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 });
+ }
const merged: SiteSettings = {
...next,
maxTranscriptPageBytes: clampPageBytes(next.maxTranscriptPageBytes),
@@ -163,6 +270,9 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
sleepBetweenDownloadsSeconds: clampSleepBetweenDownloadsSeconds(
next.sleepBetweenDownloadsSeconds,
),
+ groups,
+ defaultGroupId,
+ socialLinks,
};
const tmp = `${file}.tmp-${process.pid}`;
await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n");
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -16,6 +16,7 @@ import {
type ShardOp,
} from "yt-dlp-transcript-common/controller/shard";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import {
detectPlatform,
platformQueueKey,
@@ -69,6 +70,7 @@ 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
@@ -154,6 +156,8 @@ 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,12 +10,15 @@ 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({
@@ -23,6 +26,8 @@ export function ChannelForm({
initial,
submitLabel,
errorMessage,
+ groups,
+ defaultGroupId,
}: Props) {
const isEdit = !!initial;
const c = initial?.config;
@@ -56,6 +61,36 @@ 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>
</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,6 +3,7 @@
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 = {
@@ -12,9 +13,17 @@ type Props = {
) => Promise<ActionResult>;
initial?: { slug: string; config: ChannelConfig };
submitLabel: string;
+ groups: ChannelGroup[];
+ defaultGroupId: string;
};
-export function ChannelFormClient({ action, initial, submitLabel }: Props) {
+export function ChannelFormClient({
+ action,
+ initial,
+ submitLabel,
+ groups,
+ defaultGroupId,
+}: Props) {
const [state, formAction] = useActionState<ActionResult, FormData>(
action,
undefined,
@@ -25,6 +34,8 @@ export function ChannelFormClient({ action, initial, submitLabel }: Props) {
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,6 +27,7 @@ export type ParsedChannelForm = {
export const CHANNEL_FORM_FIELDS = [
"platform",
"url",
+ "group",
"audioFormat",
"keepSourceVideo",
"ytdlpExtraArgs",
@@ -48,6 +49,7 @@ 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,
@@ -144,6 +146,7 @@ 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,11 +1,13 @@
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">
@@ -19,6 +21,8 @@ export default function NewChannelPage() {
<ChannelFormClient
action={createChannelAction}
submitLabel="Create channel"
+ groups={groups}
+ defaultGroupId={defaultGroupId}
/>
</div>
);
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -5,10 +5,17 @@ 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 };
@@ -77,6 +84,58 @@ 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,
@@ -89,6 +148,9 @@ 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,21 +1,128 @@
"use client";
+import { useState } from "react";
import { useActionState } from "react";
import {
saveSettingsAction,
type SaveResult,
} from "../actions";
-import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings";
+import type {
+ SiteSettings,
+ SocialLink,
+} from "yt-dlp-transcript-common/lib/settings";
+import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups";
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
@@ -120,6 +227,282 @@ 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/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts
@@ -141,6 +141,82 @@ async function installFixtureRoutes(page: Page) {
});
}
+// Routes for a populated subs manifest with a single live-chat video. Used
+// by the live-chat tab tests. Call AFTER installFixtureRoutes (overrides
+// the empty subs-manifest route).
+async function installLiveChatRoutes(page: Page) {
+ const subsManifest = {
+ version: 2,
+ channels: [
+ {
+ name: CHANNEL,
+ slug: CHANNEL_SLUG,
+ videoCount: 1,
+ tracks: ["live_chat", "en"],
+ liveChatCount: 1,
+ },
+ ],
+ totalCount: 2,
+ liveChatTotalCount: 1,
+ generatedAt: new Date().toISOString(),
+ };
+ await page.unroute("**/subs/manifest.json");
+ await page.route("**/subs/manifest.json", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(subsManifest),
+ });
+ });
+ // Per-channel subs manifest. Use a regex so we don't accidentally also
+ // match `/subs/manifest.json` (playwright globs let `**` collapse to zero
+ // segments).
+ await page.route(/\/subs\/[^/]+\/manifest\.json$/, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ version: 1,
+ channelSlug: CHANNEL_SLUG,
+ pageCount: 1,
+ maxPageBytes: 8388608,
+ generatedAt: new Date().toISOString(),
+ tracks: ["live_chat", "en"],
+ // The video has both tracks; we'll use it to assert the export only
+ // surfaces live_chat hits.
+ slugToPage: { "v-both": 0 },
+ }),
+ });
+ });
+ await page.route(/\/subs\/[^/]+\/page-\d+\.json$/, async (route) => {
+ // Two tracks, both containing "platypus" — only live_chat should match.
+ const detail = {
+ slug: `${CHANNEL_SLUG}/v-both`,
+ id: "v-both",
+ channelSlug: CHANNEL_SLUG,
+ title: "Platypus facts and figures",
+ uploadDate: "20260101",
+ date: "2026-01-01",
+ duration: "5:00",
+ channel: CHANNEL,
+ isLivestream: true,
+ ageRestricted: false,
+ isDeleted: false,
+ platform: "youtube" as const,
+ webpageUrl: "https://example.com/v-both",
+ tracks: {
+ live_chat: [{ start: 11, text: "viewer: platypus in chat!" }],
+ en: [{ start: 22, text: "captioned platypus mention" }],
+ },
+ };
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([detail]),
+ });
+ });
+}
+
async function search(page: Page, query: string) {
const input = page.getByPlaceholder("Search transcripts...");
await input.click();
@@ -165,32 +241,41 @@ test.describe("export TranscriptSearch — title matching + filter layout", () =
await waitForHydration(page);
});
- test("subs tab is hidden when the subs manifest reports no subs", async ({
+ test("live chat tab is hidden when the subs manifest reports no live chat", async ({
page,
}) => {
- // The search-mode tablist is not rendered at all when there are no subs
- // to switch to. (Next.js dev-tools may render unrelated tablists; scope
- // the search by aria-label.)
+ // The search-mode tablist is not rendered at all when there's no live
+ // chat content to switch to. (Next.js dev-tools may render unrelated
+ // tablists; scope the search by aria-label.)
await expect(
page.getByRole("tablist", { name: "Search mode" }),
).toHaveCount(0);
+ await expect(page.getByRole("tab", { name: /Live chat/ })).toHaveCount(0);
+ // Also assert the old "Subs" label is gone.
await expect(page.getByRole("tab", { name: /^Subs/ })).toHaveCount(0);
});
- test("subs tab appears when the subs manifest has at least one channel", async ({
+ test("live chat tab is hidden even when manifest has only alt-language tracks", async ({
page,
}) => {
- // Re-route the manifest with non-zero totalCount, then reload so the
- // updated response is fetched.
await page.unroute("**/subs/manifest.json");
await page.route("**/subs/manifest.json", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
- version: 1,
- channels: [{ name: CHANNEL, slug: CHANNEL_SLUG, videoCount: 1, tracks: ["en"] }],
+ version: 2,
+ channels: [
+ {
+ name: CHANNEL,
+ slug: CHANNEL_SLUG,
+ videoCount: 1,
+ tracks: ["en"],
+ liveChatCount: 0,
+ },
+ ],
totalCount: 1,
+ liveChatTotalCount: 0,
generatedAt: new Date().toISOString(),
}),
});
@@ -198,8 +283,18 @@ test.describe("export TranscriptSearch — title matching + filter layout", () =
await page.reload();
await waitForHydration(page);
+ await expect(page.getByRole("tab", { name: /Live chat/ })).toHaveCount(0);
+ });
+
+ test("live chat tab appears when the subs manifest reports liveChatTotalCount > 0", async ({
+ page,
+ }) => {
+ await installLiveChatRoutes(page);
+ await page.reload();
+ await waitForHydration(page);
+
await expect(
- page.getByRole("tab", { name: /^Subs/ }),
+ page.getByRole("tab", { name: /Live chat/ }),
).toBeVisible();
await expect(
page.getByRole("tab", { name: "Transcripts" }),
@@ -308,3 +403,148 @@ test.describe("export TranscriptSearch — title matching + filter layout", () =
).toHaveCount(0);
});
});
+
+test.describe("export TranscriptSearch — live chat tab", () => {
+ test.beforeEach(async ({ page }) => {
+ await installFixtureRoutes(page);
+ await installLiveChatRoutes(page);
+ await page.goto(EXPORT_BASE);
+ await waitForHydration(page);
+ });
+
+ test("track filter UI is not shown in live chat mode", async ({ page }) => {
+ await page.getByRole("tab", { name: /Live chat/ }).click();
+ // The old per-track checkbox row is gone — no "Tracks" label, no
+ // per-track checkboxes.
+ await expect(page.getByText("Tracks", { exact: true })).toHaveCount(0);
+ await expect(
+ page.getByRole("checkbox", { name: "en", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("checkbox", { name: "live chat", exact: true }),
+ ).toHaveCount(0);
+ });
+
+ test("only live_chat track results are surfaced", async ({ page }) => {
+ await page.getByRole("tab", { name: /Live chat/ }).click();
+ // Wait for mode to commit to URL before searching.
+ await page.waitForURL(/[?&]m=subs/);
+
+ const input = page.getByPlaceholder("Search transcripts...");
+ await input.click();
+ await input.fill("platypus");
+ await input.press("Enter");
+ await page.waitForURL(/[?&]q=platypus/);
+
+ // The "live chat" TrackBadge appears for the live_chat cue. The
+ // captioned-English cue should never appear, even though it also
+ // contains "platypus".
+ await expect(page.getByText("live chat", { exact: true })).toHaveCount(1);
+ await expect(
+ page.getByText("captioned platypus mention"),
+ ).toHaveCount(0);
+ await expect(
+ page.getByText("viewer: platypus in chat!"),
+ ).toHaveCount(1);
+ });
+});
+
+test.describe("export TranscriptSearch — advanced options persistence", () => {
+ test.beforeEach(async ({ page }) => {
+ await installFixtureRoutes(page);
+ await page.goto(EXPORT_BASE);
+ await waitForHydration(page);
+ });
+
+ test("Max hits per batch persists across reload", async ({ page }) => {
+ await page.locator("summary").filter({ hasText: "Advanced options" }).click();
+
+ const maxHits = page.getByRole("spinbutton", {
+ name: "Max hits per batch",
+ });
+ await maxHits.fill("250");
+ await maxHits.press("Tab");
+
+ await page.reload();
+ await waitForHydration(page);
+ await page.locator("summary").filter({ hasText: "Advanced options" }).click();
+ await expect(
+ page.getByRole("spinbutton", { name: "Max hits per batch" }),
+ ).toHaveValue("250");
+ });
+
+ test("Reset to defaults restores advanced values and persists the reset", async ({
+ page,
+ }) => {
+ await page.locator("summary").filter({ hasText: "Advanced options" }).click();
+ const maxHits = page.getByRole("spinbutton", {
+ name: "Max hits per batch",
+ });
+ await maxHits.fill("250");
+ await maxHits.press("Tab");
+
+ await page.getByRole("button", { name: "Reset to defaults" }).click();
+ await expect(maxHits).toHaveValue("500");
+
+ await page.reload();
+ await waitForHydration(page);
+ await page.locator("summary").filter({ hasText: "Advanced options" }).click();
+ await expect(
+ page.getByRole("spinbutton", { name: "Max hits per batch" }),
+ ).toHaveValue("500");
+ });
+
+ test("Advanced reset does not touch filter checkboxes", async ({ page }) => {
+ // Set up a filter change first.
+ await search(page, "platypus");
+ await page.getByRole("checkbox", { name: "Deleted" }).uncheck();
+ // Then change Advanced.
+ await page.locator("summary").filter({ hasText: "Advanced options" }).click();
+ const maxHits = page.getByRole("spinbutton", {
+ name: "Max hits per batch",
+ });
+ await maxHits.fill("250");
+ await maxHits.press("Tab");
+
+ // Reset Advanced.
+ await page.getByRole("button", { name: "Reset to defaults" }).click();
+ await expect(maxHits).toHaveValue("500");
+
+ // The filter state is unaffected — Deleted stays unchecked.
+ await expect(
+ page.getByRole("checkbox", { name: "Deleted" }),
+ ).not.toBeChecked();
+ });
+});
+
+test.describe("export footer", () => {
+ test.beforeEach(async ({ page }) => {
+ await installFixtureRoutes(page);
+ await page.goto(EXPORT_BASE);
+ await waitForHydration(page);
+ });
+
+ test("footer holds downloads + social links sourced from settings", async ({
+ page,
+ }) => {
+ const footer = page.locator("footer");
+ await expect(footer).toBeVisible();
+ await expect(
+ footer.getByRole("link", { name: "code.tar.gz" }),
+ ).toBeVisible();
+
+ // The test settings file ships a single GitHub social link.
+ const social = footer.getByRole("link", { name: "GitHub" });
+ await expect(social).toHaveAttribute("title", "GitHub");
+ await expect(social).toHaveAttribute(
+ "href",
+ "https://github.com/example/repo",
+ );
+ await expect(social).toHaveAttribute("target", "_blank");
+ // The inlined SVG has been normalized — no width/height attrs.
+ const svgHtml = await social.innerHTML();
+ expect(svgHtml).toContain("<svg");
+ expect(svgHtml).not.toMatch(/<svg[^>]*\swidth=/);
+ expect(svgHtml).not.toMatch(/<svg[^>]*\sheight=/);
+ });
+});
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -1,3 +1,4 @@
+import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
const PORT = Number(process.env.PORT ?? 3011);
@@ -7,6 +8,13 @@ const webServerCommand =
const EXPORT_PORT = Number(process.env.EXPORT_PORT ?? 3010);
const exportBaseURL = `http://localhost:${EXPORT_PORT}`;
+// Share the editor's test-settings.json with the export server so the
+// footer (and any other server-rendered settings consumers) can be
+// driven from a single fixture file during E2E.
+const exportSettingsFile = path.resolve(
+ process.cwd(),
+ "test-settings.json",
+);
export default defineConfig({
testDir: "./e2e",
@@ -28,6 +36,9 @@ export default defineConfig({
url: exportBaseURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
+ env: {
+ SETTINGS_FILE: exportSettingsFile,
+ },
},
],
use: {
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -0,0 +1,37 @@
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+
+export default function Footer() {
+ const { socialLinks } = getSettings();
+ 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">
+ <div className="flex items-center gap-3">
+ <span>Downloads:</span>
+ <a
+ href="/code.tar.gz"
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ code.tar.gz
+ </a>
+ </div>
+ {socialLinks.length > 0 && (
+ <ul className="flex items-center gap-3 list-none">
+ {socialLinks.map((link, i) => (
+ <li key={`${link.url}-${i}`}>
+ <a
+ href={link.url}
+ title={link.label}
+ aria-label={link.label}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="inline-block w-5 h-5 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 [&_svg]:w-full [&_svg]:h-full"
+ dangerouslySetInnerHTML={{ __html: link.svg }}
+ />
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ </footer>
+ );
+}
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -3,6 +3,7 @@ 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 Footer from "./components/Footer";
import "./globals.css";
const geistSans = Geist({
@@ -49,6 +50,7 @@ export default async function RootLayout({
<main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6">
{children}
</main>
+ <Footer />
</QueryProvider>
</body>
</html>
diff --git a/export/app/page.tsx b/export/app/page.tsx
@@ -18,15 +18,6 @@ export default async function Home() {
)}
</div>
<TranscriptSearch />
- <section className="border-t border-zinc-200 dark:border-zinc-800 pt-4 text-sm text-zinc-500 flex gap-4">
- <span>Downloads:</span>
- <a
- href="/code.tar.gz"
- className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- code.tar.gz
- </a>
- </section>
</div>
<TranscriptModal />
</PlayerProvider>
diff --git a/settings.json b/settings.json
@@ -16,5 +16,42 @@
"{outputBase}",
"{audioFile}"
],
- "cookiesFromBrowser": "firefox"
+ "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>"
+ }
+ ]
}