commit 7ce693ef86310088415fa61fa19288316c81fae6
parent c5cbd692de8e8ef1822098fe30e6f686fbe722cb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 15 May 2026 19:31:35 -0400
toggle channels in editor
Diffstat:
12 files changed, 190 insertions(+), 8 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -50,6 +50,7 @@ yarn-error.log*
/export/public/transcripts.tar.xz
# preprocessed transcript data (regenerate with `pnpm build:index`)
+/export/public/subs/
/export/public/summaries/
/export/public/transcripts/
/transcripts/index.mdb/
diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts
@@ -156,6 +156,10 @@ async function scanSource(
log(`Skipping channel ${ch.name}: missing or invalid config.json`);
continue;
}
+ if (cfg.excludeFromBuild) {
+ log(`Skipping channel ${ch.name}: excluded from build`);
+ continue;
+ }
channels.set(ch.name, cfg);
const dataDir = path.join(channelDir, "data");
let videoEntries: Dirent[];
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -16,6 +16,7 @@ export type ChannelConfig = {
subLangs?: string;
lastSyncedAt?: string;
lastFullDownloadAt?: string;
+ excludeFromBuild?: boolean;
};
export const HANDLING_VALUES: ReadonlyArray<ChannelHandling> = [
@@ -66,5 +67,8 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
if (typeof r.lastFullDownloadAt === "string") {
config.lastFullDownloadAt = r.lastFullDownloadAt;
}
+ if (typeof r.excludeFromBuild === "boolean") {
+ config.excludeFromBuild = r.excludeFromBuild;
+ }
return config;
}
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -126,6 +126,9 @@ const OUTPUT_ARGS: string[] = [
"subtitle:data/%(id)s/transcript",
"-o",
"infojson:data/%(id)s/metadata",
+ // Suppress per-playlist info.json/description files that otherwise land at
+ // the channel root and trip up the index build with malformed entries.
+ "--no-write-playlist-metafiles",
];
async function storePlaylist(opts: RunYtdlpOpts): Promise<void> {
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -95,6 +95,23 @@ export async function refreshChannelSnapshotAction(
return undefined;
}
+export async function toggleChannelBuildInclusionAction(
+ slug: string,
+): Promise<ActionResult> {
+ const paths = getPaths();
+ const existing = await readChannelConfig(paths, slug);
+ if (!existing) return { error: `Channel "${slug}" not found` };
+ const next = { ...existing };
+ if (existing.excludeFromBuild) {
+ delete next.excludeFromBuild;
+ } else {
+ next.excludeFromBuild = true;
+ }
+ await writeChannelConfig(paths, slug, next);
+ revalidatePath("/channels");
+ return undefined;
+}
+
export async function deleteChannelAction(
slug: string,
_prev: ActionResult,
diff --git a/editor/app/channels/components/ChannelBuildToggle.tsx b/editor/app/channels/components/ChannelBuildToggle.tsx
@@ -0,0 +1,43 @@
+import { toggleChannelBuildInclusionAction } from "../actions";
+
+export function ChannelBuildToggle({
+ slug,
+ excluded,
+}: {
+ slug: string;
+ excluded: boolean;
+}) {
+ async function submit() {
+ "use server";
+ await toggleChannelBuildInclusionAction(slug);
+ }
+ return (
+ <form action={submit}>
+ <button
+ type="submit"
+ aria-label={`toggle build inclusion for ${slug}`}
+ aria-pressed={!excluded}
+ title={
+ excluded
+ ? "Excluded from the next export build. Click to include."
+ : "Included in the next export build. Click to exclude."
+ }
+ className={
+ "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium border transition-colors " +
+ (excluded
+ ? "border-zinc-300 dark:border-zinc-700 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ : "border-emerald-300 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/40")
+ }
+ >
+ <span
+ aria-hidden="true"
+ className={
+ "inline-block h-1.5 w-1.5 rounded-full " +
+ (excluded ? "bg-zinc-400" : "bg-emerald-500")
+ }
+ />
+ {excluded ? "Skipped" : "Included"}
+ </button>
+ </form>
+ );
+}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -3,6 +3,7 @@ import Link from "next/link";
import { listChannels } from "yt-dlp-transcript-common/controller/channels";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { ChannelAvailabilityButton } from "./components/ChannelAvailabilityButton";
+import { ChannelBuildToggle } from "./components/ChannelBuildToggle";
import { ChannelSyncButton } from "./components/ChannelSyncButton";
export const dynamic = "force-dynamic";
@@ -34,6 +35,7 @@ export default async function ChannelsPage() {
<Th>Slug</Th>
<Th>Name</Th>
<Th>Handling</Th>
+ <Th>Build</Th>
<Th className="text-right">Playlist</Th>
<Th className="whitespace-nowrap">Last sync</Th>
<Th className="text-right">Downloads</Th>
@@ -45,7 +47,10 @@ export default async function ChannelsPage() {
{channels.map((c) => (
<tr
key={c.slug}
- className="border-t border-zinc-200 dark:border-zinc-800"
+ className={
+ "border-t border-zinc-200 dark:border-zinc-800 " +
+ (c.config.excludeFromBuild ? "opacity-60" : "")
+ }
>
<Td className="font-mono">
<Link
@@ -57,6 +62,12 @@ export default async function ChannelsPage() {
</Td>
<Td>{c.config.name ?? ""}</Td>
<Td>{c.config.handling}</Td>
+ <Td>
+ <ChannelBuildToggle
+ slug={c.slug}
+ excluded={c.config.excludeFromBuild === true}
+ />
+ </Td>
<Td
className="text-right"
ariaLabel={`playlist count for ${c.slug}`}
diff --git a/editor/e2e/channel-build-toggle.spec.ts b/editor/e2e/channel-build-toggle.spec.ts
@@ -0,0 +1,101 @@
+import { test, expect } from "@playwright/test";
+import { pathExists, readJson, resetData } from "./helpers";
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+
+// The build-inclusion toggle on /channels flips `excludeFromBuild` in the
+// channel's config.json. The next build then skips the channel entirely and
+// the existing top-level cleanup loops in buildIndex remove its per-channel
+// output dirs. Toggling back re-includes it on the next build.
+
+const CONFIG_PATH = "test-transcripts/channels/test-youtube/config.json";
+const MANIFEST_PATH = "test-transcripts/.export-public/summaries/manifest.json";
+const CHANNEL_OUT_DIR =
+ "test-transcripts/.export-public/transcripts/test-youtube";
+
+async function runBuild(page: import("@playwright/test").Page) {
+ await page.goto("/build");
+ await page.getByRole("button", { name: "Build index" }).click();
+ await expect(page.getByLabel("Build index output")).toContainText("Done", {
+ timeout: 20_000,
+ });
+}
+
+test("toggling a channel off skips it from the build; toggling back restores it", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+
+ await runBuild(page);
+ {
+ const manifest = await readJson<{
+ totalCount: number;
+ channels: { name: string }[];
+ }>(MANIFEST_PATH);
+ expect(manifest.totalCount).toBe(1);
+ expect(manifest.channels.map((c) => c.name)).toContain(
+ "Test YouTube Channel",
+ );
+ expect(await pathExists(`${CHANNEL_OUT_DIR}/manifest.json`)).toBe(true);
+ }
+
+ await page.goto("/channels");
+ const toggle = page.getByRole("button", {
+ name: "toggle build inclusion for test-youtube",
+ });
+ await expect(toggle).toHaveText(/Included/);
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
+ await toggle.click();
+
+ await expect(
+ page.getByRole("button", { name: "toggle build inclusion for test-youtube" }),
+ ).toHaveText(/Skipped/);
+ await expect(
+ page.getByRole("button", { name: "toggle build inclusion for test-youtube" }),
+ ).toHaveAttribute("aria-pressed", "false");
+
+ {
+ const cfg = await readJson<ChannelConfig>(CONFIG_PATH);
+ expect(cfg.excludeFromBuild).toBe(true);
+ }
+
+ await runBuild(page);
+ {
+ const manifest = await readJson<{
+ totalCount: number;
+ channels: { name: string }[];
+ }>(MANIFEST_PATH);
+ expect(manifest.totalCount).toBe(0);
+ expect(manifest.channels.map((c) => c.name)).not.toContain(
+ "Test YouTube Channel",
+ );
+ expect(await pathExists(CHANNEL_OUT_DIR)).toBe(false);
+ }
+
+ await page.goto("/channels");
+ const reToggle = page.getByRole("button", {
+ name: "toggle build inclusion for test-youtube",
+ });
+ await expect(reToggle).toHaveText(/Skipped/);
+ await reToggle.click();
+ await expect(
+ page.getByRole("button", { name: "toggle build inclusion for test-youtube" }),
+ ).toHaveText(/Included/);
+
+ {
+ const cfg = await readJson<ChannelConfig>(CONFIG_PATH);
+ expect(cfg.excludeFromBuild).toBeUndefined();
+ }
+
+ await runBuild(page);
+ {
+ const manifest = await readJson<{
+ totalCount: number;
+ channels: { name: string }[];
+ }>(MANIFEST_PATH);
+ expect(manifest.totalCount).toBe(1);
+ expect(manifest.channels.map((c) => c.name)).toContain(
+ "Test YouTube Channel",
+ );
+ expect(await pathExists(`${CHANNEL_OUT_DIR}/manifest.json`)).toBe(true);
+ }
+});
diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts
@@ -19,7 +19,7 @@ const defaultTestSettingsFile = resolve(
"test-settings.default.json",
);
-const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3001";
+const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3011";
async function fileExists(p: string): Promise<boolean> {
try {
diff --git a/editor/package.json b/editor/package.json
@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "next dev --port 3001",
- "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs next dev --port 3001",
- "start:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs next start --port 3001",
+ "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs next dev --port 3011",
+ "start:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs next start --port 3011",
"build": "next build",
"start": "next start --port 3001",
"lint": "eslint",
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -1,11 +1,11 @@
import { defineConfig, devices } from "@playwright/test";
-const PORT = Number(process.env.PORT ?? 3001);
+const PORT = Number(process.env.PORT ?? 3011);
const baseURL = `http://localhost:${PORT}`;
const webServerCommand =
process.env.E2E_MODE === "start" ? "pnpm start:test" : "pnpm dev:test";
-const EXPORT_PORT = Number(process.env.EXPORT_PORT ?? 3000);
+const EXPORT_PORT = Number(process.env.EXPORT_PORT ?? 3010);
const exportBaseURL = `http://localhost:${EXPORT_PORT}`;
export default defineConfig({
diff --git a/export/public/subs/manifest.json b/export/public/subs/manifest.json
@@ -1 +0,0 @@
-{"version":1,"channels":[],"totalCount":0,"generatedAt":"2026-05-15T00:29:24.351Z"}
-\ No newline at end of file