Archilyzer · Source

archilyzer

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

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:
Mcommon/components/TranscriptSearch.tsx | 0
Acommon/components/exportAdvancedStorage.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/exportFilterStorage.ts | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/buildIndex.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcommon/lib/channelConfig.ts | 6++++++
Acommon/lib/channelGroups.ts | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/manifest.ts | 24++++++++++++++++++++++--
Mcommon/lib/settings.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 4++++
Meditor/app/channels/components/ChannelForm.tsx | 35+++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelFormClient.tsx | 13++++++++++++-
Meditor/app/channels/components/parseChannelForm.ts | 3+++
Meditor/app/channels/new/page.tsx | 4++++
Meditor/app/settings/actions.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 385++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/e2e/export-search.spec.ts | 260++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Meditor/playwright.config.ts | 11+++++++++++
Aexport/app/components/Footer.tsx | 37+++++++++++++++++++++++++++++++++++++
Mexport/app/layout.tsx | 2++
Mexport/app/page.tsx | 9---------
Msettings.json | 39++++++++++++++++++++++++++++++++++++++-
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&apos;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&apos;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&apos;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>" + } + ] }