commit b7e0d2b8ecb326a3aaba91912a252835e2454960
parent 3117d8b6f6c8e54e1aab5d943188c33bed748c21
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 26 Apr 2026 21:08:13 -0400
feat(editor): channel list/create/edit/delete UI with e2e tests
Adds full channel CRUD wired through common/controller/channels.ts.
Editor:
- /channels server-component table (slug, name, handling, video count,
has playlist, has archive, last sync). Empty-state copy when none.
- /channels/new with ChannelFormClient + createChannelAction. Slug auto-
derived from name via @sindresorhus/slugify when blank.
- /channels/[slug] with edit form (slug locked) + delete form gated by
typed-confirmation. Pipeline / Transcription panels are stubs for
Phases 6 and 7.
- Server actions in app/channels/actions.ts use useActionState shape
(prev, formData) -> ActionResult so client wrappers can render inline
errors. Each mutation calls revalidatePath.
- ChannelForm + parseChannelForm cover the full ChannelConfig schema
(handling, name, url, audioFormat, cookiesFromBrowser, ytdlpExtraArgs).
Cypress:
- New fixtures: empty/, one-youtube-channel/, one-transcribe-channel/
- channels.cy.ts: 7 specs covering empty state, create with explicit slug,
auto-slugify, duplicate-slug rejection, edit, typed-confirmation delete
(wrong + right), and audio format prefill on transcribe channels.
Verified: 10/10 cypress specs pass; pnpm --filter export run build still
produces the existing static output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat:
14 files changed, 673 insertions(+), 5 deletions(-)
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -0,0 +1,84 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { ChannelFormClient } from "../_components/ChannelFormClient";
+import { DeleteChannelForm } from "../_components/DeleteChannelForm";
+import {
+ deleteChannelAction,
+ updateChannelAction,
+ type ActionResult,
+} from "../actions";
+
+export const dynamic = "force-dynamic";
+
+export default async function ChannelDetailPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const config = await readChannelConfig(getPaths(), slug);
+ if (!config) notFound();
+
+ const update = updateChannelAction.bind(null, slug);
+ const del = deleteChannelAction.bind(null, slug);
+
+ return (
+ <div className="flex flex-col gap-6">
+ <div className="flex items-center gap-2 text-sm text-zinc-500">
+ <Link href="/channels" className="underline hover:text-zinc-900 dark:hover:text-zinc-100">
+ Channels
+ </Link>
+ <span>/</span>
+ <span className="font-mono">{slug}</span>
+ </div>
+ <h1 className="text-2xl font-semibold">{config.name ?? slug}</h1>
+
+ <section className="flex flex-col gap-3">
+ <h2 className="text-lg font-semibold">Configuration</h2>
+ <ChannelFormClient
+ action={
+ update as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ initial={{ slug, config }}
+ submitLabel="Save changes"
+ />
+ </section>
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <h2 className="text-lg font-semibold">Pipeline</h2>
+ <p className="text-sm text-zinc-500">
+ Store playlist / Download / Sync UI lands in Phase 6.
+ </p>
+ </section>
+
+ {config.handling === "transcribe" && (
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <h2 className="text-lg font-semibold">Transcription</h2>
+ <p className="text-sm text-zinc-500">
+ Whisper queue UI lands in Phase 7.
+ </p>
+ </section>
+ )}
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <h2 className="text-lg font-semibold text-red-700 dark:text-red-400">
+ Danger zone
+ </h2>
+ <DeleteChannelForm
+ slug={slug}
+ action={
+ del as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ />
+ </section>
+ </div>
+ );
+}
diff --git a/editor/app/channels/_components/ChannelForm.tsx b/editor/app/channels/_components/ChannelForm.tsx
@@ -0,0 +1,153 @@
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+
+type Props = {
+ action: string | ((formData: FormData) => void | Promise<void>);
+ initial?: { slug: string; config: ChannelConfig };
+ submitLabel: string;
+ errorMessage?: string;
+};
+
+export function ChannelForm({
+ action,
+ initial,
+ submitLabel,
+ errorMessage,
+}: Props) {
+ const isEdit = !!initial;
+ const c = initial?.config;
+ return (
+ <form action={action} className="flex flex-col gap-4 max-w-xl">
+ {errorMessage && (
+ <div className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300">
+ {errorMessage}
+ </div>
+ )}
+ <Field
+ label="Name"
+ name="name"
+ required
+ defaultValue={c?.name ?? ""}
+ placeholder="Display name (e.g., TheQuartering)"
+ />
+ {isEdit ? (
+ <Field
+ label="Slug"
+ name="slug"
+ readOnly
+ defaultValue={initial!.slug}
+ hint="Slug is fixed once a channel is created."
+ />
+ ) : (
+ <Field
+ label="Slug"
+ name="slug"
+ placeholder="kebab-case (auto-derived from name if blank)"
+ />
+ )}
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium">Handling</legend>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="youtube"
+ defaultChecked={c?.handling !== "transcribe"}
+ />
+ YouTube (auto-subtitles via yt-dlp)
+ </label>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="transcribe"
+ defaultChecked={c?.handling === "transcribe"}
+ />
+ Transcribe (download audio, run whisper-cpp)
+ </label>
+ </fieldset>
+ <Field
+ label="URL"
+ name="url"
+ defaultValue={c?.url ?? ""}
+ placeholder="https://www.youtube.com/@channel/videos"
+ hint="Channel or playlist URL passed to yt-dlp."
+ />
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">
+ Audio format <span className="text-zinc-500 font-normal">(transcribe only)</span>
+ </span>
+ <select
+ name="audioFormat"
+ defaultValue={c?.audioFormat ?? ""}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ <option value="">Default (m4a)</option>
+ <option value="m4a">m4a</option>
+ <option value="mp3">mp3</option>
+ <option value="opus">opus</option>
+ </select>
+ </label>
+ <Field
+ label="Cookies from browser"
+ name="cookiesFromBrowser"
+ defaultValue={c?.cookiesFromBrowser ?? ""}
+ placeholder="firefox / chrome / brave (optional)"
+ hint="Forwarded to yt-dlp's --cookies-from-browser."
+ />
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">
+ Extra yt-dlp args
+ <span className="text-zinc-500 font-normal"> (one per line)</span>
+ </span>
+ <textarea
+ name="ytdlpExtraArgs"
+ defaultValue={(c?.ytdlpExtraArgs ?? []).join("\n")}
+ rows={4}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ />
+ </label>
+ <div className="flex gap-2">
+ <button
+ type="submit"
+ className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90"
+ >
+ {submitLabel}
+ </button>
+ </div>
+ </form>
+ );
+}
+
+function Field({
+ label,
+ name,
+ defaultValue,
+ placeholder,
+ hint,
+ required,
+ readOnly,
+}: {
+ label: string;
+ name: string;
+ defaultValue?: string;
+ placeholder?: string;
+ hint?: string;
+ required?: boolean;
+ readOnly?: boolean;
+}) {
+ return (
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">{label}</span>
+ <input
+ type="text"
+ name={name}
+ defaultValue={defaultValue}
+ placeholder={placeholder}
+ required={required}
+ readOnly={readOnly}
+ className={`rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm ${readOnly ? "opacity-60" : ""}`}
+ />
+ {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ </label>
+ );
+}
diff --git a/editor/app/channels/_components/ChannelFormClient.tsx b/editor/app/channels/_components/ChannelFormClient.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { useActionState } from "react";
+import { ChannelForm } from "./ChannelForm";
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+import type { ActionResult } from "../actions";
+
+type Props = {
+ action: (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>;
+ initial?: { slug: string; config: ChannelConfig };
+ submitLabel: string;
+};
+
+export function ChannelFormClient({ action, initial, submitLabel }: Props) {
+ const [state, formAction] = useActionState<ActionResult, FormData>(
+ action,
+ undefined,
+ );
+ return (
+ <ChannelForm
+ action={formAction}
+ initial={initial}
+ submitLabel={submitLabel}
+ errorMessage={state?.error}
+ />
+ );
+}
diff --git a/editor/app/channels/_components/DeleteChannelForm.tsx b/editor/app/channels/_components/DeleteChannelForm.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useActionState } from "react";
+import type { ActionResult } from "../actions";
+
+type Props = {
+ slug: string;
+ action: (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>;
+};
+
+export function DeleteChannelForm({ slug, action }: Props) {
+ const [state, formAction] = useActionState<ActionResult, FormData>(
+ action,
+ undefined,
+ );
+ return (
+ <form action={formAction} className="flex flex-col gap-2">
+ <p className="text-sm text-zinc-500">
+ Deleting will remove <code>transcripts/channels/{slug}/</code> and all
+ downloaded videos. Type the slug to confirm.
+ </p>
+ <div className="flex gap-2 items-start">
+ <input
+ name="confirmSlug"
+ required
+ placeholder={slug}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ />
+ <button
+ type="submit"
+ className="px-3 py-1.5 rounded-md bg-red-600 text-white text-sm font-medium hover:bg-red-700"
+ >
+ Delete channel
+ </button>
+ </div>
+ {state?.error && (
+ <p className="text-sm text-red-600 dark:text-red-400">{state.error}</p>
+ )}
+ </form>
+ );
+}
diff --git a/editor/app/channels/_components/parseChannelForm.ts b/editor/app/channels/_components/parseChannelForm.ts
@@ -0,0 +1,54 @@
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+
+export type ParsedChannelForm = {
+ name: string;
+ slug: string;
+ config: ChannelConfig;
+};
+
+// Maps FormData -> ChannelConfig. Throws on invalid input so the server
+// action can render an error message.
+export function parseChannelForm(formData: FormData): ParsedChannelForm {
+ const name = String(formData.get("name") ?? "").trim();
+ if (!name) throw new Error("Name is required");
+
+ const handlingRaw = String(formData.get("handling") ?? "");
+ if (handlingRaw !== "youtube" && handlingRaw !== "transcribe") {
+ throw new Error("Handling must be 'youtube' or 'transcribe'");
+ }
+
+ const slug = String(formData.get("slug") ?? "").trim();
+
+ const url = stringOrUndef(formData, "url");
+ const audioFormatRaw = String(formData.get("audioFormat") ?? "");
+ const audioFormat: ChannelConfig["audioFormat"] | undefined =
+ audioFormatRaw === "m4a" ||
+ audioFormatRaw === "mp3" ||
+ audioFormatRaw === "opus"
+ ? audioFormatRaw
+ : undefined;
+ const cookiesFromBrowser = stringOrUndef(formData, "cookiesFromBrowser");
+ const argsBlob = String(formData.get("ytdlpExtraArgs") ?? "").trim();
+ const ytdlpExtraArgs = argsBlob
+ ? argsBlob
+ .split("\n")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : undefined;
+
+ const config: ChannelConfig = {
+ handling: handlingRaw,
+ name,
+ };
+ if (url) config.url = url;
+ if (audioFormat) config.audioFormat = audioFormat;
+ if (cookiesFromBrowser) config.cookiesFromBrowser = cookiesFromBrowser;
+ if (ytdlpExtraArgs?.length) config.ytdlpExtraArgs = ytdlpExtraArgs;
+
+ return { name, slug, config };
+}
+
+function stringOrUndef(fd: FormData, key: string): string | undefined {
+ const v = String(fd.get(key) ?? "").trim();
+ return v ? v : undefined;
+}
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -0,0 +1,87 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import slugify from "@sindresorhus/slugify";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ channelExists,
+ createChannel,
+ deleteChannel,
+ readChannelConfig,
+ writeChannelConfig,
+} from "yt-dlp-transcript-common/controller/channels";
+import { parseChannelForm } from "./_components/parseChannelForm";
+
+export type ActionResult = { error: string } | undefined;
+
+export async function createChannelAction(
+ _prev: ActionResult,
+ formData: FormData,
+): Promise<ActionResult> {
+ let parsed;
+ try {
+ parsed = parseChannelForm(formData);
+ } catch (e) {
+ return { error: (e as Error).message };
+ }
+ const { name, config } = parsed;
+ const slug = parsed.slug || slugify(name);
+ if (!slug) {
+ return { error: "Could not derive a slug from the name" };
+ }
+ const paths = getPaths();
+ if (await channelExists(paths, slug)) {
+ return { error: `Channel "${slug}" already exists` };
+ }
+ await createChannel(paths, slug, config);
+ revalidatePath("/channels");
+ revalidatePath("/");
+ redirect(`/channels/${slug}`);
+}
+
+export async function updateChannelAction(
+ slug: string,
+ _prev: ActionResult,
+ formData: FormData,
+): Promise<ActionResult> {
+ let parsed;
+ try {
+ parsed = parseChannelForm(formData);
+ } catch (e) {
+ return { error: (e as Error).message };
+ }
+ const paths = getPaths();
+ const existing = await readChannelConfig(paths, slug);
+ if (!existing) return { error: `Channel "${slug}" not found` };
+ // Preserve writeback fields the form doesn't expose.
+ const merged = {
+ ...existing,
+ ...parsed.config,
+ };
+ if (existing.lastSyncedAt) merged.lastSyncedAt = existing.lastSyncedAt;
+ if (existing.lastFullDownloadAt) {
+ merged.lastFullDownloadAt = existing.lastFullDownloadAt;
+ }
+ await writeChannelConfig(paths, slug, merged);
+ revalidatePath("/channels");
+ revalidatePath(`/channels/${slug}`);
+ return undefined;
+}
+
+export async function deleteChannelAction(
+ slug: string,
+ _prev: ActionResult,
+ formData: FormData,
+): Promise<ActionResult> {
+ const confirm = String(formData.get("confirmSlug") ?? "").trim();
+ if (confirm !== slug) {
+ return {
+ error: `Type the channel slug "${slug}" exactly to confirm deletion`,
+ };
+ }
+ await deleteChannel(getPaths(), slug);
+ revalidatePath("/channels");
+ revalidatePath("/");
+ redirect("/channels");
+}
diff --git a/editor/app/channels/new/page.tsx b/editor/app/channels/new/page.tsx
@@ -0,0 +1,22 @@
+import Link from "next/link";
+import { ChannelFormClient } from "../_components/ChannelFormClient";
+import { createChannelAction } from "../actions";
+
+export default function NewChannelPage() {
+ return (
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center gap-2 text-sm text-zinc-500">
+ <Link href="/channels" className="underline hover:text-zinc-900 dark:hover:text-zinc-100">
+ Channels
+ </Link>
+ <span>/</span>
+ <span>new</span>
+ </div>
+ <h1 className="text-2xl font-semibold">New channel</h1>
+ <ChannelFormClient
+ action={createChannelAction}
+ submitLabel="Create channel"
+ />
+ </div>
+ );
+}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -1,10 +1,99 @@
-export default function ChannelsPage() {
+import Link from "next/link";
+import { listChannels } from "yt-dlp-transcript-common/controller/channels";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+
+export const dynamic = "force-dynamic";
+
+export default async function ChannelsPage() {
+ const channels = await listChannels(getPaths());
return (
<div className="flex flex-col gap-4">
- <h1 className="text-2xl font-semibold">Channels</h1>
- <p className="text-sm text-zinc-500">
- Channel CRUD lands in Phase 4.
- </p>
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-semibold">Channels</h1>
+ <Link
+ href="/channels/new"
+ className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90"
+ >
+ New channel
+ </Link>
+ </div>
+ {channels.length === 0 ? (
+ <p
+ data-testid="channels-empty"
+ className="text-sm text-zinc-500 border border-dashed border-zinc-300 dark:border-zinc-700 rounded p-4"
+ >
+ No channels yet.
+ </p>
+ ) : (
+ <table
+ data-testid="channels-table"
+ className="text-sm border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden"
+ >
+ <thead className="bg-zinc-100 dark:bg-zinc-900">
+ <tr>
+ <Th>Slug</Th>
+ <Th>Name</Th>
+ <Th>Handling</Th>
+ <Th className="text-right">Videos</Th>
+ <Th>Playlist</Th>
+ <Th>Archive</Th>
+ <Th>Last sync</Th>
+ </tr>
+ </thead>
+ <tbody>
+ {channels.map((c) => (
+ <tr
+ key={c.slug}
+ data-testid={`channel-row-${c.slug}`}
+ className="border-t border-zinc-200 dark:border-zinc-800"
+ >
+ <Td className="font-mono">
+ <Link
+ href={`/channels/${c.slug}`}
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {c.slug}
+ </Link>
+ </Td>
+ <Td>{c.config.name ?? ""}</Td>
+ <Td>{c.config.handling}</Td>
+ <Td className="text-right">{c.videoCount}</Td>
+ <Td>{c.hasPlaylist ? "✓" : ""}</Td>
+ <Td>{c.hasArchive ? "✓" : ""}</Td>
+ <Td className="text-xs text-zinc-500">
+ {c.config.lastSyncedAt
+ ? new Date(c.config.lastSyncedAt).toLocaleString()
+ : ""}
+ </Td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
</div>
);
}
+
+function Th({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <th className={`text-left font-medium px-3 py-2 ${className}`}>
+ {children}
+ </th>
+ );
+}
+
+function Td({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <td className={`px-3 py-2 ${className}`}>{children}</td>;
+}
diff --git a/editor/cypress/e2e/channels.cy.ts b/editor/cypress/e2e/channels.cy.ts
@@ -0,0 +1,90 @@
+describe("Channels", () => {
+ it("shows empty state with no channels", () => {
+ cy.resetData("empty");
+ cy.visit("/channels");
+ cy.get('[data-testid="channels-empty"]').should("exist");
+ });
+
+ it("creates a youtube channel", () => {
+ cy.resetData("empty");
+ cy.visit("/channels/new");
+ cy.findByLabelText(/^name/i).type("New Test Channel");
+ cy.findByLabelText(/^slug/i).clear().type("new-test");
+ cy.findByLabelText(/^url/i).type(
+ "https://www.youtube.com/@newtest/videos",
+ );
+ cy.findByRole("button", { name: /create channel/i }).click();
+ cy.location("pathname", { timeout: 10000 }).should(
+ "eq",
+ "/channels/new-test",
+ );
+ cy.task("readJson", "test-transcripts/channels/new-test/config.json").then(
+ (config) => {
+ expect(config).to.deep.equal({
+ handling: "youtube",
+ name: "New Test Channel",
+ url: "https://www.youtube.com/@newtest/videos",
+ });
+ },
+ );
+ });
+
+ it("auto-derives slug from name when blank", () => {
+ cy.resetData("empty");
+ cy.visit("/channels/new");
+ cy.findByLabelText(/^name/i).type("Auto Slug Channel");
+ cy.findByRole("button", { name: /create channel/i }).click();
+ cy.location("pathname", { timeout: 10000 }).should(
+ "eq",
+ "/channels/auto-slug-channel",
+ );
+ });
+
+ it("rejects duplicate slug", () => {
+ cy.resetData("one-youtube-channel");
+ cy.visit("/channels/new");
+ cy.findByLabelText(/^name/i).type("Test YouTube Channel");
+ cy.findByLabelText(/^slug/i).clear().type("test-youtube");
+ cy.findByRole("button", { name: /create channel/i }).click();
+ cy.contains(/already exists/i);
+ });
+
+ it("edits an existing channel", () => {
+ cy.resetData("one-youtube-channel");
+ cy.visit("/channels/test-youtube");
+ cy.findByLabelText(/^name/i).clear().type("Renamed Channel");
+ cy.findByRole("button", { name: /save changes/i }).click();
+ cy.contains("Renamed Channel");
+ cy.task(
+ "readJson",
+ "test-transcripts/channels/test-youtube/config.json",
+ ).then((config) => {
+ expect((config as { name?: string }).name).to.equal("Renamed Channel");
+ });
+ });
+
+ it("requires typed-confirmation to delete", () => {
+ cy.resetData("one-youtube-channel");
+ cy.visit("/channels/test-youtube");
+ // Wrong slug → error
+ cy.findByPlaceholderText("test-youtube").type("wrong-slug");
+ cy.findByRole("button", { name: /delete channel/i }).click();
+ cy.contains(/type the channel slug/i);
+ cy.task("pathExists", "test-transcripts/channels/test-youtube").then(
+ (exists) => expect(exists).to.equal(true),
+ );
+ // Correct slug → deletes
+ cy.findByPlaceholderText("test-youtube").clear().type("test-youtube");
+ cy.findByRole("button", { name: /delete channel/i }).click();
+ cy.location("pathname", { timeout: 10000 }).should("eq", "/channels");
+ cy.task("pathExists", "test-transcripts/channels/test-youtube").then(
+ (exists) => expect(exists).to.equal(false),
+ );
+ });
+
+ it("hides transcribe form fields for youtube channels (cosmetic)", () => {
+ cy.resetData("one-transcribe-channel");
+ cy.visit("/channels/test-transcribe");
+ cy.findByLabelText(/audio format/i).should("have.value", "m4a");
+ });
+});
diff --git a/editor/cypress/fixtures/test-transcripts/empty/.gitkeep b/editor/cypress/fixtures/test-transcripts/empty/.gitkeep
diff --git a/editor/cypress/fixtures/test-transcripts/one-transcribe-channel/channels/test-transcribe/config.json b/editor/cypress/fixtures/test-transcripts/one-transcribe-channel/channels/test-transcribe/config.json
@@ -0,0 +1,6 @@
+{
+ "handling": "transcribe",
+ "name": "Test Transcribe Channel",
+ "url": "https://odysee.com/@example",
+ "audioFormat": "m4a"
+}
diff --git a/editor/cypress/fixtures/test-transcripts/one-youtube-channel/channels/test-youtube/config.json b/editor/cypress/fixtures/test-transcripts/one-youtube-channel/channels/test-youtube/config.json
@@ -0,0 +1,5 @@
+{
+ "handling": "youtube",
+ "name": "Test YouTube Channel",
+ "url": "https://www.youtube.com/@example/videos"
+}
diff --git a/editor/package.json b/editor/package.json
@@ -13,6 +13,7 @@
"e2e-dev:headless": "start-server-and-test dev:test http://localhost:3001 \"cypress run --e2e\""
},
"dependencies": {
+ "@sindresorhus/slugify": "^3.0.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -66,6 +66,9 @@ importers:
editor:
dependencies:
+ '@sindresorhus/slugify':
+ specifier: ^3.0.0
+ version: 3.0.0
next:
specifier: 16.2.3
version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)