From 0bbd8b57fea19c9c4cf199b02f2bd2f729c7b556 Mon Sep 17 00:00:00 2001
From: WP-05 agent
Date: Tue, 27 Oct 2026 19:42:11 +0600
Subject: [PATCH 1/2] WP-05: Intake + ASR
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Transcript parsers (plain/SRT/VTT/Riverside/Descript/Otter) with format
detection + U5 chip labels; fixtures per format incl. edge cases
- ASR: Groq whisper-large-v3-turbo adapter (verbose_json, segment
granularity, lang=en, vocab prompt, concurrency 3), 2s overlap dedupe,
sentence-group merge (<=45s/<=500 chars), language gate (U19), fake adapter
- Browser compression port from site/app.html: 16 kHz mono Opus ~24 kbps,
<=10-min chunks with 2s overlap, plan/mime/bitrade pure functions tested
- Intake UI per MK-4: upload tab (drop zone, allow-list, compress/upload
progress), paste tab (format chip), details, sample episode, resumable
status view via ?id=
- Routes: POST /api/uploads/token, POST /api/episodes, POST
/api/episodes/:id/transcribe, GET /api/episodes/:id, GET /api/audio/:id
at the documented db/queries + blob + entitlements boundaries
- Entitlements stub (free-plan limits) until WP-08; quota wall 402 + §6 copy
- Tests: 240 unit (parsers, merge, language, Groq, service at mocked
boundaries, compression plan) + 7 new e2e (U4/U5/U19 paths)
---
docs/relaunch/DECISIONS.md | 12 +
web/.env.example | 8 +
web/app/(app)/app/episodes/new/page.tsx | 5 +-
web/app/(app)/app/onboarding/first/page.tsx | 5 +-
web/app/api/audio/[id]/route.ts | 16 +
web/app/api/episodes/[id]/route.ts | 14 +
web/app/api/episodes/[id]/transcribe/route.ts | 19 +
web/app/api/episodes/route.ts | 14 +
web/app/api/uploads/token/route.ts | 13 +
web/components/app/episode-intake-shell.tsx | 494 +++++++++++++++++-
web/db/queries.ts | 91 +++-
web/lib/asr/fake.ts | 34 ++
web/lib/asr/groq.ts | 147 ++++++
web/lib/asr/language.ts | 31 ++
web/lib/asr/merge.ts | 123 +++++
web/lib/billing/entitlements.ts | 85 +++
web/lib/blob.ts | 80 +++
web/lib/compress/browser.ts | 148 ++++++
web/lib/compress/plan.ts | 86 +++
web/lib/copy.ts | 28 +
web/lib/episodes/audio.ts | 27 +
web/lib/episodes/input.ts | 31 ++
web/lib/episodes/manifest.ts | 48 ++
web/lib/episodes/month.ts | 4 +
web/lib/episodes/service.ts | 364 +++++++++++++
web/lib/transcripts/index.ts | 254 +++++++++
web/package.json | 1 +
web/pnpm-lock.yaml | 111 ++++
web/public/samples/demo-episode.txt | 58 ++
web/tests/e2e/new-episode.spec.ts | 123 +++++
web/tests/fixtures/transcripts/descript.txt | 3 +
web/tests/fixtures/transcripts/otter.txt | 3 +
web/tests/fixtures/transcripts/plain.txt | 6 +
.../fixtures/transcripts/riverside-crlf.txt | 3 +
web/tests/fixtures/transcripts/riverside.txt | 3 +
.../fixtures/transcripts/srt-unsorted.txt | 7 +
web/tests/fixtures/transcripts/srt.txt | 11 +
web/tests/fixtures/transcripts/vtt.txt | 11 +
web/tests/unit/asr-fake.test.ts | 28 +
web/tests/unit/asr-groq.test.ts | 143 +++++
web/tests/unit/asr-language.test.ts | 44 ++
web/tests/unit/asr-merge.test.ts | 130 +++++
web/tests/unit/compression.test.ts | 63 +++
web/tests/unit/episodes-service.test.ts | 445 ++++++++++++++++
web/tests/unit/transcripts.test.ts | 222 ++++++++
45 files changed, 3572 insertions(+), 24 deletions(-)
create mode 100644 web/app/api/audio/[id]/route.ts
create mode 100644 web/app/api/episodes/[id]/route.ts
create mode 100644 web/app/api/episodes/[id]/transcribe/route.ts
create mode 100644 web/app/api/episodes/route.ts
create mode 100644 web/app/api/uploads/token/route.ts
create mode 100644 web/lib/asr/fake.ts
create mode 100644 web/lib/asr/groq.ts
create mode 100644 web/lib/asr/language.ts
create mode 100644 web/lib/asr/merge.ts
create mode 100644 web/lib/billing/entitlements.ts
create mode 100644 web/lib/blob.ts
create mode 100644 web/lib/compress/browser.ts
create mode 100644 web/lib/compress/plan.ts
create mode 100644 web/lib/episodes/audio.ts
create mode 100644 web/lib/episodes/input.ts
create mode 100644 web/lib/episodes/manifest.ts
create mode 100644 web/lib/episodes/month.ts
create mode 100644 web/lib/episodes/service.ts
create mode 100644 web/lib/transcripts/index.ts
create mode 100644 web/public/samples/demo-episode.txt
create mode 100644 web/tests/e2e/new-episode.spec.ts
create mode 100644 web/tests/fixtures/transcripts/descript.txt
create mode 100644 web/tests/fixtures/transcripts/otter.txt
create mode 100644 web/tests/fixtures/transcripts/plain.txt
create mode 100644 web/tests/fixtures/transcripts/riverside-crlf.txt
create mode 100644 web/tests/fixtures/transcripts/riverside.txt
create mode 100644 web/tests/fixtures/transcripts/srt-unsorted.txt
create mode 100644 web/tests/fixtures/transcripts/srt.txt
create mode 100644 web/tests/fixtures/transcripts/vtt.txt
create mode 100644 web/tests/unit/asr-fake.test.ts
create mode 100644 web/tests/unit/asr-groq.test.ts
create mode 100644 web/tests/unit/asr-language.test.ts
create mode 100644 web/tests/unit/asr-merge.test.ts
create mode 100644 web/tests/unit/compression.test.ts
create mode 100644 web/tests/unit/episodes-service.test.ts
create mode 100644 web/tests/unit/transcripts.test.ts
diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md
index 3c6728e..90521b9 100644
--- a/docs/relaunch/DECISIONS.md
+++ b/docs/relaunch/DECISIONS.md
@@ -3,6 +3,18 @@
Agents append one entry per tie-break decision (01-product.md §0.4). Newest first.
Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`.
+- 2026-10-27 · WP-05 · Live ASR smoke test skipped: `.env.relaunch` holds sanitized placeholders (values literally `"[SENSITIVE]"`, no `gsk_` key, ASR_PROVIDER ≠ groq) · the handoff rule says skip the live call when ASR_PROVIDER≠groq; the Groq adapter is covered by fetch-injected unit tests (request shape, error mapping, language gate) instead · running a live call with a placeholder key
+- 2026-10-27 · WP-05 · Chunk manifest (path + offsetMs per ≤10-min chunk) serialized as JSON into `episodes.audio_blob_path` · §7.1 has a single text column and the server must re-run transcription idempotently after a refresh without client state (U4); a plain single-path string still parses as one chunk at 0 ms · new table/column (schema is WP-02's), body-only manifest (lost on refresh)
+- 2026-10-27 · WP-05 · POST /api/episodes accepts `blobPaths[]` + parallel `chunkOffsetsMs[]` beyond the §7.5 body shape · §8.3 requires per-chunk offsets for the overlap dedupe and §7.5 pins `blobPaths[]`; parallel arrays are the smallest coherent extension · nested `chunks[]` objects in create (used only on transcribe), path-embedded offsets
+- 2026-10-27 · WP-05 · Transcript format labels: `Name (00:12:03):` paren shape → "Riverside", `[00:12:03] Name:` bracket shape → "Otter", speaker-only `Name:` lines → "Descript" · the spec groups all three exports under two line shapes without disambiguating marks, so detection needs a deterministic heuristic; every M2 format name still gets its own label + fixture · one generic "speaker-timestamped" label for all three (U5 chip must say Riverside)
+- 2026-10-27 · WP-05 · Paste path applies the same Latin-ratio ≥0.6 gate as ASR (`supportedInput` port) at create time → 400 `unsupported_language` with the exact U19 copy, nothing created · non-Latin transcripts would yield empty artifacts downstream (same reason `api/generate-suite.js` gates) and U19 says "wasn't charged" — no episode row means nothing to refund · letting generation fail later (WP-04's problem, worse UX)
+- 2026-10-27 · WP-05 · Entitlements boundary stubbed in `lib/billing/entitlements.ts` (free=2/month, ≤60 min; pro=10, ≤2 h; ≤300 MB uploads; type allow-list) with quota counted via `countQuotaEpisodes` · WP-08 owns the real logic; intake routes need a documented seam now and must not fake success — over-quota returns 402 + §6 wall copy in tests with the count mocked · inlining free-plan constants in routes (WP-08 would have to untangle them)
+- 2026-10-27 · WP-05 · GET /api/audio/:episodeId takes `?t=` and returns a signed URL for the chunk containing t + `chunkOffsetMs` so playback can seek within the chunk · §8.3 stores ≤10-min chunks (no single audio file), §7.5 promises a short-lived signed URL; signed via `issueSignedToken`+`presignUrl` (get, 10 min) — BLOB_READ_WRITE_TOKEN is not configured anywhere, so the boundary is stubbed and unit-tested with the seam mocked (PR blocker noted) · uploading a duplicate full-file audio for playback
+- 2026-10-27 · WP-05 · Browser compression ports site/app.html's decode→OfflineAudioContext(16 kHz mono)→MediaRecorder pipeline but records ≤10-min chunks with a 2 s overlap at 24 kbps · §8.3 pins chunked output (~1.8 MB/chunk) for the 25 MB Groq limit; MediaRecorder encodes in real time (same as the legacy app) — the pure decisions (planChunks, bitrate, mime choice) are unit-tested in Node · WebCodecs AudioEncoder (faster-than-realtime, weak Safari support, not "ported from site/app.html")
+- 2026-10-27 · WP-05 · Overlap dedupe drops chunk N's leading segments inside [offsetMs, offsetMs+2 s) only when chunk N-1 transcribed up to the seam (a segment ends ≥ seam) · blind dropping loses words when Whisper returns nothing near the previous chunk's tail; the guard makes the merge safe for real ASR output · always-drop (simpler, lossy)
+- 2026-10-27 · WP-05 · Trailing chunk plan absorbs a ≤2 s stub into the previous chunk (cap becomes ≤602 s) · a 2 s upload + Groq call for a sliver of overlap is pure waste; still far under the 25 MB ASR limit · dropping the stub (loses the overlap safety on the last seam)
+- 2026-10-27 · WP-05 · Quota is checked (not incremented) at POST /api/uploads/token and counted at POST /api/episodes · §7.5 asks for a quota check on the token route, but §7.5 also says create "counts quota" — the count must happen exactly once, at create; the token-route check just fails fast before a 300 MB upload · counting at token issue (double-counts on chunked uploads)
+- 2026-10-27 · WP-05 · `db/queries.ts` extended additively (episodes/segments queries) instead of a new module · the handoff designates `@/db/queries` the documented DB boundary for WP-05 storage; no WP-02 function was modified · parallel `db/queries-episodes.ts` (two seams to mock everywhere)
- 2026-10-27 · WP-02 · Keyless preview mode: when Clerk keys AND DATABASE_URL are both absent, `getAuthUserId()` returns a fixed `user_preview` id and DB-backed routes answer 503; if either is configured the app fails closed (DB-without-Clerk → no user) · previews/CI/e2e must render shells with zero secrets (handoff rule) without faking a provider; the half-configured window (DB, no auth) must never expose data · env flag like ALLOW_KEYLESS_AUTH; dev-only gating (Vercel preview is NODE_ENV=production)
- 2026-10-27 · WP-02 · ClerkProvider, clerkMiddleware (in `proxy.ts`, Next 16 convention) and ` / ` mount only when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`+`CLERK_SECRET_KEY` are set; /sign-in shows a plain "Sign in isn't set up in this preview." card otherwise · app must build/preview keyless; rendering Clerk components keyless throws · always mounting Clerk with dummy keys (fake provider)
- 2026-10-27 · WP-02 · `user.deleted` deletes the users row; workspaces/shows/episodes cascade via FK, feedback + usage_events keep with SET NULL · account deletion should wipe personal data (GDPR-friendly) while the owner keeps the learning signal from feedback notes · soft-delete flag (no column in §7.1)
diff --git a/web/.env.example b/web/.env.example
index 75152a0..52bedbd 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -18,3 +18,11 @@ CLERK_WEBHOOK_SIGNING_SECRET=
# Clerk routing (defaults shown; used by ClerkProvider and the sign-in/up pages)
# NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
# NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
+
+# --- WP-05 (intake + ASR) ---
+# Vercel Blob read-write token (Vercel → Storage → Blob). NOT configured yet —
+# owner blocker: audio upload and playback answer 503 until it is set.
+BLOB_READ_WRITE_TOKEN=
+
+# Groq (ASR + fallback LLM). ASR adapter uses GROQ_API_KEY (or ASR_API_KEY).
+GROQ_API_KEY=
diff --git a/web/app/(app)/app/episodes/new/page.tsx b/web/app/(app)/app/episodes/new/page.tsx
index f8459cd..b0c2448 100644
--- a/web/app/(app)/app/episodes/new/page.tsx
+++ b/web/app/(app)/app/episodes/new/page.tsx
@@ -1,3 +1,4 @@
+import { Suspense } from "react";
import { EpisodeIntakeShell } from "@/components/app/episode-intake-shell";
export const metadata = { title: "New episode · EpisodeKit" };
@@ -6,7 +7,9 @@ export default function NewEpisodePage() {
return (
New episode
-
+ }>
+
+
);
}
diff --git a/web/app/(app)/app/onboarding/first/page.tsx b/web/app/(app)/app/onboarding/first/page.tsx
index 9192a8e..6485b7d 100644
--- a/web/app/(app)/app/onboarding/first/page.tsx
+++ b/web/app/(app)/app/onboarding/first/page.tsx
@@ -1,3 +1,4 @@
+import { Suspense } from "react";
import Link from "next/link";
import { EpisodeIntakeShell } from "@/components/app/episode-intake-shell";
import { StepDots } from "@/components/app/step-dots";
@@ -15,7 +16,9 @@ export default function OnboardingFirstPage() {
You can do this later from Episodes.
-
+ }>
+
+
diff --git a/web/app/api/audio/[id]/route.ts b/web/app/api/audio/[id]/route.ts
new file mode 100644
index 0000000..534fbf6
--- /dev/null
+++ b/web/app/api/audio/[id]/route.ts
@@ -0,0 +1,16 @@
+import { getAuthUserId } from "@/lib/auth";
+import { AUTH_REQUIRED_COPY } from "@/lib/copy";
+import { handleAudioGet } from "@/lib/episodes/service";
+
+/** GET /api/audio/:episodeId — short-lived signed URL for playback (§7.5). */
+export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const userId = await getAuthUserId();
+ if (!userId) {
+ return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
+ }
+ const { id } = await params;
+ const tParam = new URL(req.url).searchParams.get("t");
+ const tMs = tParam !== null && /^\d+$/.test(tParam) ? Number(tParam) : 0;
+ const result = await handleAudioGet(userId, id, tMs);
+ return Response.json(result.json, { status: result.status });
+}
diff --git a/web/app/api/episodes/[id]/route.ts b/web/app/api/episodes/[id]/route.ts
new file mode 100644
index 0000000..94496b7
--- /dev/null
+++ b/web/app/api/episodes/[id]/route.ts
@@ -0,0 +1,14 @@
+import { getAuthUserId } from "@/lib/auth";
+import { AUTH_REQUIRED_COPY } from "@/lib/copy";
+import { handleEpisodeGet } from "@/lib/episodes/service";
+
+/** GET /api/episodes/:id — episode + status, polled every 2 s (§7.5, U4). */
+export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const userId = await getAuthUserId();
+ if (!userId) {
+ return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
+ }
+ const { id } = await params;
+ const result = await handleEpisodeGet(userId, id);
+ return Response.json(result.json, { status: result.status });
+}
diff --git a/web/app/api/episodes/[id]/transcribe/route.ts b/web/app/api/episodes/[id]/transcribe/route.ts
new file mode 100644
index 0000000..d63d8d6
--- /dev/null
+++ b/web/app/api/episodes/[id]/transcribe/route.ts
@@ -0,0 +1,19 @@
+import { getAuthUserId } from "@/lib/auth";
+import { AUTH_REQUIRED_COPY } from "@/lib/copy";
+import { handleTranscribePost } from "@/lib/episodes/service";
+
+// Groq chunked transcription of a 2 h episode must not hit the function
+// timeout (§7.5: maxDuration 300).
+export const maxDuration = 300;
+
+/** POST /api/episodes/:id/transcribe — upload→queued→transcribing→queued (§8.3). */
+export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const userId = await getAuthUserId();
+ if (!userId) {
+ return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
+ }
+ const { id } = await params;
+ const body: unknown = await req.json().catch(() => ({}));
+ const result = await handleTranscribePost(userId, id, body);
+ return Response.json(result.json, { status: result.status });
+}
diff --git a/web/app/api/episodes/route.ts b/web/app/api/episodes/route.ts
new file mode 100644
index 0000000..eaea77e
--- /dev/null
+++ b/web/app/api/episodes/route.ts
@@ -0,0 +1,14 @@
+import { getAuthUserId } from "@/lib/auth";
+import { AUTH_REQUIRED_COPY } from "@/lib/copy";
+import { handleEpisodesPost } from "@/lib/episodes/service";
+
+/** POST /api/episodes — create an episode from upload/paste/sample (§7.5). */
+export async function POST(req: Request) {
+ const userId = await getAuthUserId();
+ if (!userId) {
+ return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
+ }
+ const body: unknown = await req.json().catch(() => null);
+ const result = await handleEpisodesPost(userId, body);
+ return Response.json(result.json, { status: result.status });
+}
diff --git a/web/app/api/uploads/token/route.ts b/web/app/api/uploads/token/route.ts
new file mode 100644
index 0000000..931e4e9
--- /dev/null
+++ b/web/app/api/uploads/token/route.ts
@@ -0,0 +1,13 @@
+import { getAuthUserId } from "@/lib/auth";
+import { AUTH_REQUIRED_COPY } from "@/lib/copy";
+import { handleUploadsTokenPost } from "@/lib/episodes/service";
+
+/** POST /api/uploads/token — @vercel/blob client-upload token (§7.5). */
+export async function POST(req: Request) {
+ const userId = await getAuthUserId();
+ if (!userId) {
+ return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
+ }
+ const result = await handleUploadsTokenPost(userId, req);
+ return Response.json(result.json, { status: result.status });
+}
diff --git a/web/components/app/episode-intake-shell.tsx b/web/components/app/episode-intake-shell.tsx
index ccbe0b1..7cb40b2 100644
--- a/web/components/app/episode-intake-shell.tsx
+++ b/web/components/app/episode-intake-shell.tsx
@@ -1,33 +1,487 @@
+"use client";
+
/**
- * Static intake shell (WP-02). Real upload/compression/transcript parsing
- * ships in WP-05/06 (M2, MK-4). Copy is from the mockup/spec.
+ * New-episode intake (M2, MK-4): Upload audio / Paste transcript tabs, browser
+ * compression + chunked Blob upload with progress, format chip for pasted
+ * transcripts (U5), optional title/number/guests, sample episode (free, no
+ * quota), and the resumable status read path (?id= → server status, U4).
+ * WP-01 components are not merged yet — plain Tailwind per §5.2 tokens.
*/
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { upload } from "@vercel/blob/client";
+import { checkUpload } from "@/lib/episodes/audio";
+import { chunkPathname, compressToChunks, type CompressedChunk } from "@/lib/compress/browser";
+import { detectFormat } from "@/lib/transcripts";
+import type { SerializedEpisode } from "@/lib/episodes/service";
+
+const SAMPLE_TEXT_URL = "/samples/demo-episode.txt";
+
+const STATUS_LABELS: Record
= {
+ uploading: "Uploading",
+ queued: "Queued",
+ transcribing: "Transcribing",
+ writing: "Writing",
+ ready: "Ready",
+ failed: "Failed",
+};
+
+function errorCodeCopy(code: string | null): string | null {
+ switch (code) {
+ case "unsupported_language":
+ return "EpisodeKit works with English episodes for now. This one wasn't charged.";
+ case "no_speech":
+ return "We couldn't hear any speech in this file. Check the audio and try again.";
+ case "too_long":
+ return "Episodes can be up to 2 hours on your plan.";
+ case "quota_exceeded":
+ return "You've used your episodes for this month. Upgrade for more.";
+ default:
+ return "Something went wrong on our side. Try again — it won't use another episode.";
+ }
+}
+
+const formatDuration = (ms: number) => {
+ const total = Math.round(ms / 1000);
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
+};
+
+async function postJson(url: string, body: unknown) {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const json = (await res.json().catch(() => ({}))) as Record;
+ return { ok: res.ok, status: res.status, json };
+}
+
export function EpisodeIntakeShell() {
+ // ?id= → resumable status view (U4).
+ const searchParams = useSearchParams();
+ const resumeId = searchParams.get("id") ?? undefined;
+ const [tab, setTab] = useState<"upload" | "paste">("upload");
+ const [episodeId, setEpisodeId] = useState(resumeId ?? null);
+ const [episode, setEpisode] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ // Upload tab state
+ const [fileInfo, setFileInfo] = useState<{ name: string; durationMs: number; sizeMB: number } | null>(null);
+ const [phase, setPhase] = useState<"idle" | "compressing" | "ready" | "creating" | "uploading">("idle");
+ const [progress, setProgress] = useState(0);
+ const chunksRef = useRef<{ chunks: CompressedChunk[]; extension: string } | null>(null);
+ const fileInputRef = useRef(null);
+ const [dragOver, setDragOver] = useState(false);
+
+ // Paste tab state
+ const [pasteText, setPasteText] = useState("");
+ const [sampleLoaded, setSampleLoaded] = useState(false);
+ const detected = pasteText.trim() ? detectFormat(pasteText) : null;
+
+ // Details (MK-4)
+ const [title, setTitle] = useState("");
+ const [number_, setNumber_] = useState("");
+ const [guests, setGuests] = useState([]);
+
+ // U4: resumable status — poll the server every 2 s until a terminal state.
+ useEffect(() => {
+ if (!episodeId) return;
+ let alive = true;
+ let timer: ReturnType | null = null;
+ const poll = async () => {
+ const res = await fetch(`/api/episodes/${episodeId}`);
+ if (!res.ok) {
+ if (res.status !== 503 && alive) {
+ setError("Couldn't load this episode. Refresh to try again.");
+ }
+ return;
+ }
+ const json = (await res.json().catch(() => ({}))) as { episode?: SerializedEpisode };
+ if (alive && json.episode) {
+ setEpisode(json.episode);
+ if (json.episode.status === "ready" || json.episode.status === "failed") {
+ if (timer) clearInterval(timer);
+ }
+ }
+ };
+ void poll();
+ timer = setInterval(() => void poll(), 2000);
+ return () => {
+ alive = false;
+ if (timer) clearInterval(timer);
+ };
+ }, [episodeId]);
+
+ const prepareFile = useCallback(async (file: File) => {
+ setError(null);
+ const check = checkUpload(file.name, file.size);
+ if (!check.ok) {
+ setError(check.copy);
+ return;
+ }
+ setFileInfo({ name: file.name, durationMs: 0, sizeMB: file.size / 1e6 });
+ setPhase("compressing");
+ setProgress(0);
+ try {
+ const result = await compressToChunks(file, (pct) => setProgress(pct));
+ chunksRef.current = {
+ chunks: result.chunks,
+ extension: result.chunks[0]?.blob.type.split("/")[1]?.split(";")[0] ?? "webm",
+ };
+ const totalMB = result.chunks.reduce((sum, c) => sum + c.blob.size, 0) / 1e6;
+ setFileInfo({ name: file.name, durationMs: result.durationMs, sizeMB: totalMB });
+ setPhase("ready");
+ } catch (e) {
+ const code = e instanceof Error && "code" in e ? String((e as { code: unknown }).code) : "decode_failed";
+ setError(
+ code === "too_long"
+ ? "Episodes can be up to 2 hours on your plan."
+ : "Couldn't process that audio file. Try another format, or paste the transcript.",
+ );
+ setPhase("idle");
+ setFileInfo(null);
+ }
+ }, []);
+
+ const details = useCallback(
+ () => ({
+ title: title.trim() || undefined,
+ number: number_ ? Number(number_) : undefined,
+ guests: guests.map((g) => g.trim()).filter(Boolean),
+ }),
+ [title, number_, guests],
+ );
+
+ const createAndUpload = useCallback(async () => {
+ const prepared = chunksRef.current;
+ if (!prepared?.chunks.length) return;
+ setBusy(true);
+ setError(null);
+ setPhase("creating");
+ try {
+ const created = await postJson("/api/episodes", { source: "upload", ...details() });
+ if (!created.ok || !created.json.id) {
+ throw new Error(String(created.json.error ?? "Couldn't create the episode. Try again."));
+ }
+ const id = String(created.json.id);
+ window.history.replaceState(null, "", `/app/episodes/new?id=${id}`);
+ setEpisodeId(id);
+ setEpisode({
+ id,
+ title: title.trim() || null,
+ number: number_ ? Number(number_) : null,
+ source: "upload",
+ status: "uploading",
+ errorCode: null,
+ durationMs: null,
+ hasTimestamps: false,
+ hasSpeakers: false,
+ countsQuota: true,
+ });
+
+ setPhase("uploading");
+ const manifest: { path: string; offsetMs: number }[] = [];
+ for (let i = 0; i < prepared.chunks.length; i += 1) {
+ const chunk = prepared.chunks[i];
+ const pathname = chunkPathname(id, i, prepared.extension);
+ const uploaded = await upload(pathname, chunk.blob, {
+ access: "private",
+ handleUploadUrl: "/api/uploads/token",
+ onUploadProgress: (p) => {
+ const chunkBase = (i / prepared.chunks.length) * 100;
+ setProgress(Math.round(chunkBase + (p.percentage / 100) * (100 / prepared.chunks.length)));
+ },
+ });
+ manifest.push({ path: uploaded.pathname, offsetMs: chunk.offsetMs });
+ }
+
+ setProgress(100);
+ const started = await postJson(`/api/episodes/${id}/transcribe`, { chunks: manifest });
+ if (!started.ok) {
+ throw new Error(String(started.json.error ?? "Transcription didn't start. Try again."));
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Upload failed. Try again.");
+ setPhase("ready");
+ } finally {
+ setBusy(false);
+ }
+ }, [details, number_, title]);
+
+ const createPaste = useCallback(async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ const source = sampleLoaded && pasteText.trim() ? "sample" : "paste";
+ const created = await postJson("/api/episodes", { source, transcriptText: pasteText, ...details() });
+ if (!created.ok || !created.json.id) {
+ throw new Error(String(created.json.error ?? "Couldn't create the episode. Try again."));
+ }
+ const id = String(created.json.id);
+ window.history.replaceState(null, "", `/app/episodes/new?id=${id}`);
+ setEpisodeId(id);
+ setEpisode({
+ id,
+ title: title.trim() || null,
+ number: number_ ? Number(number_) : null,
+ source: source as SerializedEpisode["source"],
+ status: "queued",
+ errorCode: null,
+ durationMs: null,
+ hasTimestamps: detected?.hasTimestamps ?? false,
+ hasSpeakers: detected?.hasSpeakers ?? false,
+ countsQuota: source !== "sample",
+ });
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Couldn't create the episode. Try again.");
+ } finally {
+ setBusy(false);
+ }
+ }, [pasteText, sampleLoaded, details, detected, number_, title]);
+
+ const loadSample = useCallback(async () => {
+ setError(null);
+ try {
+ const text = await (await fetch(SAMPLE_TEXT_URL)).text();
+ setPasteText(text);
+ setSampleLoaded(true);
+ setTab("paste");
+ } catch {
+ setError("Couldn't load the sample. Try again.");
+ }
+ }, []);
+
+ // --- resume view (U4): show server status instead of the form ---
+ if (episode) {
+ const failed = episode.status === "failed";
+ return (
+
+
+
+ {STATUS_LABELS[episode.status]}
+
+ {episode.title ? {episode.title} : null}
+
+ {failed && episode.errorCode ? (
+
+ {errorCodeCopy(episode.errorCode)}
+
+ ) : (
+
{"You can leave this page — we'll email you when it's ready."}
+ )}
+
+ );
+ }
+
return (
-
- Upload audio
- Paste transcript
-
-
- Drop audio or video, or browse
-
- MP3, M4A, WAV, AAC, OGG, MP4, MOV · up to 2 hours
-
-
-
+ Try with a sample episode
+
+
+
setTab("upload")}
+ className={`-mb-px border-b-2 pb-2 ${tab === "upload" ? "border-blue-700 font-medium" : "border-transparent text-neutral-500"}`}
>
- Create package
+ Upload audio
- Episode intake is coming soon.
+ setTab("paste")}
+ className={`-mb-px border-b-2 pb-2 ${tab === "paste" ? "border-blue-700 font-medium" : "border-transparent text-neutral-500"}`}
+ >
+ Paste transcript
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {tab === "upload" ? (
+
+
fileInputRef.current?.click()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ fileInputRef.current?.click();
+ }
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ setDragOver(true);
+ }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDragOver(false);
+ const file = e.dataTransfer.files[0];
+ if (file) void prepareFile(file);
+ }}
+ className={`flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center ${
+ dragOver ? "border-blue-700 bg-blue-50" : "border-neutral-400"
+ }`}
+ >
+ Drop audio or video, or browse
+
+ MP3, M4A, WAV, AAC, OGG, MP4, MOV · up to 2 hours
+
+
+
{
+ const file = e.target.files?.[0];
+ if (file) void prepareFile(file);
+ }}
+ />
+ {phase !== "idle" && fileInfo ? (
+
+
+ {fileInfo.name}
+
+ {phase === "compressing" ? "Compressing" : phase === "uploading" ? "Uploading" : "Ready"}{" "}
+ {progress}%
+ {fileInfo.durationMs ? ` · ${formatDuration(fileInfo.durationMs)}` : ""}
+ {phase !== "compressing" ? ` · ${fileInfo.sizeMB.toFixed(1)} MB` : ""}
+
+
+
+
+ ) : null}
+
+ ) : (
+
+ )}
+
+
+
Details (all optional)
+
+
+ Episode title
+ setTitle(e.target.value)}
+ className="w-64 rounded-md border border-neutral-300 px-3 py-2 text-sm"
+ />
+
+
+ Episode #
+ setNumber_(e.target.value.replace(/[^\d]/g, ""))}
+ className="w-24 rounded-md border border-neutral-300 px-3 py-2 text-sm"
+ />
+
+
+
+
Guests
+ {guests.map((guest, i) => (
+
+ setGuests(guests.map((g, j) => (j === i ? e.target.value : g)))}
+ className="w-56 rounded-md border border-neutral-300 px-3 py-2 text-sm"
+ />
+ setGuests(guests.filter((_, j) => j !== i))}
+ className="text-sm text-neutral-500 hover:text-neutral-800"
+ >
+ Remove
+
+
+ ))}
+
setGuests([...guests, ""])}
+ className="self-start text-sm font-medium text-blue-700 hover:underline"
+ >
+ + add guest
+
+
+
+
+
+ {tab === "upload" ? (
+ void createAndUpload()}
+ className="rounded-md bg-blue-700 px-4 py-2 text-sm font-medium text-white disabled:bg-neutral-200 disabled:text-neutral-500"
+ >
+ {phase === "creating" || phase === "uploading" ? "Working…" : "Create package"}
+
+ ) : (
+ void createPaste()}
+ className="rounded-md bg-blue-700 px-4 py-2 text-sm font-medium text-white disabled:bg-neutral-200 disabled:text-neutral-500"
+ >
+ {busy ? "Working…" : "Create package"}
+
+ )}
+ {tab === "paste" && !sampleLoaded ? Uses 1 of your episodes this month. : null}
+ {tab === "paste" && sampleLoaded ? {"The sample is free — it doesn't use an episode."} : null}
);
diff --git a/web/db/queries.ts b/web/db/queries.ts
index b50f242..9cf94f6 100644
--- a/web/db/queries.ts
+++ b/web/db/queries.ts
@@ -1,10 +1,97 @@
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { getDb } from "./index";
-import { shows, users, webhookEvents, workspaces } from "./schema";
+import {
+ episodes,
+ segments,
+ shows,
+ users,
+ webhookEvents,
+ workspaces,
+} from "./schema";
export type ShowRow = typeof shows.$inferSelect;
export type WorkspaceRow = typeof workspaces.$inferSelect;
export type UserRow = typeof users.$inferSelect;
+export type EpisodeRow = typeof episodes.$inferSelect;
+export type SegmentRow = typeof segments.$inferSelect;
+
+// ---------------------------------------------------------------------------
+// Episodes + segments (WP-05, additive — WP-02 functions above are untouched).
+// ---------------------------------------------------------------------------
+
+export async function createEpisode(
+ values: typeof episodes.$inferInsert,
+): Promise
{
+ const db = getDb();
+ const inserted = await db.insert(episodes).values(values).returning();
+ return inserted[0];
+}
+
+/** Ownership check per §9: the episode must belong to a show in the caller's workspace. */
+export async function getEpisodeForUser(
+ userId: string,
+ episodeId: string,
+): Promise<{ episode: EpisodeRow; show: ShowRow; workspace: WorkspaceRow } | null> {
+ const db = getDb();
+ const rows = await db
+ .select({ episode: episodes, show: shows, workspace: workspaces })
+ .from(episodes)
+ .innerJoin(shows, eq(episodes.showId, shows.id))
+ .innerJoin(workspaces, eq(shows.workspaceId, workspaces.id))
+ .where(and(eq(episodes.id, episodeId), eq(workspaces.ownerId, userId)))
+ .limit(1);
+ return rows[0] ?? null;
+}
+
+export async function updateEpisode(
+ episodeId: string,
+ values: Partial,
+): Promise {
+ const db = getDb();
+ const updated = await db
+ .update(episodes)
+ .set({ ...values, updatedAt: new Date() })
+ .where(eq(episodes.id, episodeId))
+ .returning();
+ return updated[0];
+}
+
+/** Replaces the episode's segments (atomic delete+insert via Neon batch —
+ * the neon-http driver has no interactive transactions). */
+export async function replaceSegments(
+ episodeId: string,
+ rows: Array>,
+): Promise {
+ const db = getDb();
+ if (!rows.length) {
+ await db.delete(segments).where(eq(segments.episodeId, episodeId));
+ return [];
+ }
+ const result = await db.batch([
+ db.delete(segments).where(eq(segments.episodeId, episodeId)),
+ db.insert(segments).values(rows.map((r) => ({ ...r, episodeId }))).returning(),
+ ] as const);
+ return result[1];
+}
+
+export async function listSegments(episodeId: string): Promise {
+ const db = getDb();
+ return db.select().from(segments).where(eq(segments.episodeId, episodeId));
+}
+
+/** Episodes created in `month` that count against the plan quota (M10). */
+export async function countQuotaEpisodes(
+ workspaceId: string,
+ month: string,
+): Promise {
+ const db = getDb();
+ const rows = await db
+ .select({ id: episodes.id })
+ .from(episodes)
+ .innerJoin(shows, eq(episodes.showId, shows.id))
+ .where(and(eq(shows.workspaceId, workspaceId), eq(episodes.quotaMonth, month), eq(episodes.countsQuota, true)));
+ return rows.length;
+}
export async function upsertUser(input: {
id: string;
diff --git a/web/lib/asr/fake.ts b/web/lib/asr/fake.ts
new file mode 100644
index 0000000..e582e49
--- /dev/null
+++ b/web/lib/asr/fake.ts
@@ -0,0 +1,34 @@
+/**
+ * In-memory fake ASR adapter (§8.3 "a fake/in-memory adapter for tests").
+ * Deterministic: one segment per script line, spread across the given chunk
+ * offsets with the overlap dedupe exercised. No network, no key.
+ */
+import type { Segment } from "@/lib/quotes/types";
+import { mergeChunks, type RawAsrSegment } from "./merge";
+import type { AsrChunk } from "./groq";
+
+export interface FakeAsrOptions {
+ /** One text per chunk; defaults to a one-line placeholder per chunk. */
+ linesPerChunk?: string[][];
+ /** Seconds of audio each chunk covers (for end times). Default 598. */
+ chunkDurationSec?: number;
+}
+
+export function fakeTranscribe(chunks: AsrChunk[], opts: FakeAsrOptions = {}): Segment[] {
+ if (!chunks.length) return [];
+ const duration = opts.chunkDurationSec ?? 598;
+ return mergeChunks(
+ chunks.map((chunk, i) => {
+ const lines = opts.linesPerChunk?.[i] ?? [`Chunk ${i + 1} transcript line.`];
+ const segments: RawAsrSegment[] = lines.map((text, j) => {
+ const start = j === 0 ? 0.5 : (duration / lines.length) * j;
+ return {
+ start,
+ end: Math.min(duration, start + 3),
+ text,
+ };
+ });
+ return { offsetMs: chunk.offsetMs, segments };
+ }),
+ );
+}
diff --git a/web/lib/asr/groq.ts b/web/lib/asr/groq.ts
new file mode 100644
index 0000000..60da779
--- /dev/null
+++ b/web/lib/asr/groq.ts
@@ -0,0 +1,147 @@
+/**
+ * Groq Whisper ASR adapter (§8.3).
+ *
+ * transcribe(chunks, opts) → Segment[] where chunks are the ≤10-min Opus
+ * files the browser uploaded ({blobPath, offsetMs}). Each chunk is POSTed to
+ * Groq /openai/v1/audio/transcriptions with verbose_json + segment
+ * timestamps, transcribed with concurrency 3, then merged: 2 s overlap
+ * dedupe by timestamp + sentence-group merge (lib/asr/merge).
+ *
+ * Error handling is ported from api/asr.js (AsrError with code + status,
+ * injectable fetch for tests). Language gate: whisper `language` ≠ english
+ * or Latin ratio < 0.6 → unsupported_language, no quota used (U19).
+ */
+import type { Segment } from "@/lib/quotes/types";
+import { isSupportedAsrLanguage } from "./language";
+import { mergeChunks, type ChunkSegments } from "./merge";
+
+export class AsrError extends Error {
+ constructor(
+ public code: string,
+ public status = 502,
+ ) {
+ super(code);
+ this.name = "AsrError";
+ }
+}
+
+export const GROQ_MODEL = "whisper-large-v3-turbo";
+export const ASR_CONCURRENCY = 3;
+export const GROQ_ASR_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
+/** Whisper tokens are ~4 chars; the prompt accepts ≤200 tokens. */
+const PROMPT_MAX_CHARS = 800;
+
+export interface AsrChunk {
+ blobPath: string;
+ offsetMs: number;
+}
+
+export interface FetchChunkResult {
+ body: ArrayBuffer | Blob;
+ contentType: string;
+ filename: string;
+}
+
+export interface GroqTranscribeDeps {
+ fetchImpl?: typeof fetch;
+ /** Fetches an uploaded chunk from Blob storage (boundary; mocked in tests). */
+ fetchChunk?: (blobPath: string) => Promise;
+ apiKey?: string;
+}
+
+/** Key resolution order ported from api/asr.js (ASR_API_KEY then provider key). */
+export function resolveAsrApiKey(env: NodeJS.ProcessEnv = process.env): string | null {
+ return env.ASR_API_KEY || env.GROQ_API_KEY || null;
+}
+
+/** Vocabulary + guest names, capped at ~200 tokens. */
+export function buildAsrPrompt(vocab: string[]): string {
+ const terms = vocab.map((v) => v.trim()).filter(Boolean);
+ let prompt = "";
+ for (const term of terms) {
+ const candidate = prompt ? `${prompt}, ${term}` : term;
+ if (candidate.length > PROMPT_MAX_CHARS) break;
+ prompt = candidate;
+ }
+ return prompt;
+}
+
+interface VerboseJsonSegment {
+ start: number;
+ end: number;
+ text: string;
+}
+
+async function transcribeOne(
+ chunk: AsrChunk,
+ prompt: string,
+ deps: Required> & { apiKey: string },
+): Promise {
+ const { body, contentType, filename } = await deps.fetchChunk(chunk.blobPath);
+ const form = new FormData();
+ form.append("file", new File([body], filename, { type: contentType || "audio/webm" }), filename);
+ form.append("model", GROQ_MODEL);
+ form.append("response_format", "verbose_json");
+ form.append("timestamp_granularities[]", "segment");
+ form.append("language", "en");
+ if (prompt) form.append("prompt", prompt);
+
+ const res = await deps.fetchImpl(GROQ_ASR_URL, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${deps.apiKey}` },
+ body: form,
+ });
+ // Ported from api/asr.js: provider errors become AsrError("groq_").
+ if (!res.ok) throw new AsrError(`groq_${res.status}`, 502);
+ const j = (await res.json()) as {
+ text?: string;
+ language?: string;
+ segments?: VerboseJsonSegment[];
+ };
+ const segments = (j.segments ?? []).filter((s) => s.text && s.text.trim());
+ if (!segments.length) throw new AsrError("empty_transcript", 422);
+ const text = segments.map((s) => s.text).join(" ");
+ if (!isSupportedAsrLanguage(j.language, text)) {
+ throw new AsrError("unsupported_language", 422);
+ }
+ return { offsetMs: chunk.offsetMs, segments };
+}
+
+/**
+ * Transcribe uploaded chunks on Groq and merge into stored Segment[].
+ * Concurrency 3; results are merged in chunk order regardless of completion.
+ */
+export async function transcribeWithGroq(
+ chunks: AsrChunk[],
+ opts: { vocab: string[] },
+ deps: GroqTranscribeDeps = {},
+): Promise {
+ if (!chunks.length) throw new AsrError("empty_audio", 400);
+ const apiKey = deps.apiKey ?? resolveAsrApiKey();
+ if (!apiKey) throw new AsrError("not_configured", 503);
+ const fetchImpl = deps.fetchImpl ?? fetch;
+ const fetchChunk =
+ deps.fetchChunk ??
+ (async () => {
+ throw new AsrError("not_configured", 503);
+ });
+ const prompt = buildAsrPrompt(opts.vocab);
+
+ const results: ChunkSegments[] = new Array(chunks.length);
+ let next = 0;
+ const worker = async () => {
+ while (next < chunks.length) {
+ const index = next;
+ next += 1;
+ results[index] = await transcribeOne(chunks[index], prompt, {
+ fetchImpl,
+ fetchChunk,
+ apiKey,
+ });
+ }
+ };
+ await Promise.all(
+ Array.from({ length: Math.min(ASR_CONCURRENCY, chunks.length) }, () => worker()),
+ );
+ return mergeChunks(results);
+}
diff --git a/web/lib/asr/language.ts b/web/lib/asr/language.ts
new file mode 100644
index 0000000..fa5f6df
--- /dev/null
+++ b/web/lib/asr/language.ts
@@ -0,0 +1,31 @@
+/**
+ * Language gate (§8.3): English episodes only for the MVP.
+ *
+ * Two checks, both ported from the legacy app:
+ * - the Whisper `language` field (full name, e.g. "english") must be English;
+ * - the Latin ratio check `supportedInput()` ported verbatim from
+ * api/generate-suite.js — non-Latin transcripts (CJK/Cyrillic/Arabic…)
+ * would yield empty artifacts downstream.
+ *
+ * Failing either marks the episode failed with error_code
+ * unsupported_language and no quota is used (U19).
+ */
+
+/** Latin ratio ≥ 0.6 required — ported verbatim from api/generate-suite.js. */
+export function supportedInput(text: string): boolean {
+ const latin = (text.match(/[A-Za-z]/g) || []).length;
+ const letters = (text.match(/\p{L}/gu) || []).length;
+ return letters === 0 ? false : latin / letters >= 0.6;
+}
+
+const ENGLISH_ASR_LANGUAGES = new Set(["en", "english"]);
+
+export function isSupportedAsrLanguage(
+ whisperLanguage: string | undefined | null,
+ text: string,
+): boolean {
+ if (whisperLanguage && !ENGLISH_ASR_LANGUAGES.has(whisperLanguage.trim().toLowerCase())) {
+ return false;
+ }
+ return supportedInput(text);
+}
diff --git a/web/lib/asr/merge.ts b/web/lib/asr/merge.ts
new file mode 100644
index 0000000..2f3f8be
--- /dev/null
+++ b/web/lib/asr/merge.ts
@@ -0,0 +1,123 @@
+/**
+ * Segment merge (§8.3): the browser produces ≤10-min Opus chunks with a 2 s
+ * overlap; the server transcribes each chunk and merges:
+ * 1. offset chunk-relative times to absolute ms;
+ * 2. de-duplicate the 2 s overlap by timestamp — a leading segment of chunk N
+ * that starts inside chunk N's overlap window is dropped when chunk N-1
+ * already transcribed past the seam;
+ * 3. merge Whisper segments into ~sentence-group segments (≤45 s / ≤500 chars).
+ */
+import type { Segment } from "@/lib/quotes/types";
+
+export const OVERLAP_MS = 2000;
+export const MAX_GROUP_SPAN_MS = 45_000;
+export const MAX_GROUP_CHARS = 500;
+
+/** Chunk-relative Whisper segment (verbose_json returns seconds). */
+export interface RawAsrSegment {
+ start: number;
+ end: number;
+ text: string;
+}
+
+export interface ChunkSegments {
+ offsetMs: number;
+ segments: RawAsrSegment[];
+}
+
+const SENTENCE_END = /[.!?…]["')\]]?$/;
+
+function absolute(chunk: ChunkSegments): Segment[] {
+ return chunk.segments.map((s) => ({
+ id: "",
+ idx: 0,
+ speaker: null,
+ startMs: Math.round(chunk.offsetMs + s.start * 1000),
+ endMs: Math.round(chunk.offsetMs + s.end * 1000),
+ text: s.text.trim(),
+ }));
+}
+
+/**
+ * Dedupe the overlap seam. A segment from chunk N that starts within
+ * [offsetMs, offsetMs + OVERLAP_MS) duplicates audio chunk N-1 already
+ * covered — drop it, but only when chunk N-1 actually transcribed up to the
+ * seam (one of its segments ends at/after the chunk N start). Otherwise the
+ * previous chunk missed its tail and dropping would lose words.
+ */
+export function dedupeOverlap(chunks: ChunkSegments[]): Segment[] {
+ const ordered: Segment[] = [];
+ for (let i = 0; i < chunks.length; i += 1) {
+ const current = absolute(chunks[i]);
+ if (i > 0) {
+ const seam = chunks[i].offsetMs;
+ const prevCoversSeam = ordered.some((s) => (s.endMs ?? 0) >= seam);
+ for (const seg of current) {
+ if (prevCoversSeam && seg.startMs !== null && seg.startMs < seam + OVERLAP_MS) {
+ continue; // duplicate of the overlap region
+ }
+ ordered.push(seg);
+ }
+ } else {
+ ordered.push(...current);
+ }
+ }
+ return ordered.filter((s) => s.text.length > 0);
+}
+
+/**
+ * Merge flat Whisper segments into sentence groups bounded by
+ * ≤45 s span and ≤500 chars. Assigns seg_XXXXXX ids and sequential idx.
+ */
+export function mergeSentenceGroups(segments: Segment[]): Segment[] {
+ const groups: Segment[] = [];
+ const push = (group: Segment) => {
+ groups.push({ ...group, id: segmentId(groups.length), idx: groups.length });
+ };
+ let open: Segment | null = null;
+
+ for (const seg of segments) {
+ // Fresh per-iteration binding: the loop's entry narrowing of `open` is
+ // null (its initializer), so group building works on a clean local.
+ // Assertion resets the loop-entry narrowing (open starts as null).
+ let cur = open as Segment | null;
+ if (cur) {
+ const spanOver =
+ seg.startMs !== null &&
+ cur.startMs !== null &&
+ seg.endMs !== null &&
+ seg.endMs - cur.startMs > MAX_GROUP_SPAN_MS;
+ const charsOver = cur.text.length + 1 + seg.text.length > MAX_GROUP_CHARS;
+ if (spanOver || charsOver) {
+ push(cur);
+ cur = null;
+ }
+ }
+ if (!cur) {
+ cur = { ...seg };
+ } else {
+ cur = {
+ ...cur,
+ endMs: seg.endMs ?? cur.endMs,
+ text: `${cur.text} ${seg.text}`.trim(),
+ };
+ }
+ if (cur && SENTENCE_END.test(cur.text)) {
+ push(cur);
+ cur = null;
+ }
+ open = cur;
+ }
+ if (open) push(open);
+ return groups;
+}
+
+/** "seg_000123" per §7.1. */
+export function segmentId(idx: number): string {
+ return `seg_${String(idx).padStart(6, "0")}`;
+}
+
+/** Full merge pipeline: ordered chunk results → stored Segment[]. */
+export function mergeChunks(chunks: ChunkSegments[]): Segment[] {
+ return mergeSentenceGroups(dedupeOverlap(chunks));
+}
diff --git a/web/lib/billing/entitlements.ts b/web/lib/billing/entitlements.ts
new file mode 100644
index 0000000..0fc0a9f
--- /dev/null
+++ b/web/lib/billing/entitlements.ts
@@ -0,0 +1,85 @@
+/**
+ * Entitlements boundary — STUB returning free-plan limits until WP-08 lands
+ * (billing + quota, M10). WP-08 replaces the bodies, not the call sites:
+ * intake routes call `getEpisodeQuota` / `checkEpisodeQuota` and treat this
+ * as the single documented entitlements seam.
+ */
+import { quotaMonthNow } from "@/lib/episodes/month";
+import * as queries from "@/db/queries";
+
+export const PLAN_LIMITS = {
+ free: { monthlyEpisodes: 2, maxDurationMs: 60 * 60 * 1000 },
+ pro: { monthlyEpisodes: 10, maxDurationMs: 2 * 60 * 60 * 1000 },
+} as const;
+
+export type PlanId = keyof typeof PLAN_LIMITS;
+
+/** ≤300 MB compressed per §7.5 (uploads/token quota check). */
+export const MAX_UPLOAD_BYTES = 300 * 1000 * 1000;
+
+/** M2 upload allow-list: mp3, m4a, wav, aac, ogg, webm, mp4, mov. */
+export const AUDIO_UPLOAD_EXTENSIONS = ["mp3", "m4a", "wav", "aac", "ogg", "webm", "mp4", "mov"] as const;
+
+export const AUDIO_UPLOAD_CONTENT_TYPES = [
+ "audio/mpeg",
+ "audio/mp4",
+ "audio/x-m4a",
+ "audio/wav",
+ "audio/x-wav",
+ "audio/aac",
+ "audio/ogg",
+ "audio/opus",
+ "audio/vorbis",
+ "audio/webm",
+ "video/webm",
+ "video/mp4",
+ "video/quicktime",
+] as const;
+
+export interface EpisodeQuota {
+ allowed: boolean;
+ used: number;
+ limit: number;
+ resetDate: string;
+}
+
+export function planLimits(plan: string): (typeof PLAN_LIMITS)[PlanId] {
+ return PLAN_LIMITS[plan === "pro" ? "pro" : "free"];
+}
+
+/**
+ * Current quota state for a workspace. STUB: counts episodes created this
+ * month that count against quota; WP-08 owns the real entitlements logic.
+ */
+export async function getEpisodeQuota(workspace: {
+ id: string;
+ plan: string;
+}): Promise {
+ const limits = planLimits(workspace.plan);
+ const month = quotaMonthNow();
+ const used = await queries.countQuotaEpisodes(workspace.id, month);
+ return {
+ allowed: used < limits.monthlyEpisodes,
+ used,
+ limit: limits.monthlyEpisodes,
+ resetDate: resetDateFor(month),
+ };
+}
+
+export async function checkEpisodeQuota(workspace: {
+ id: string;
+ plan: string;
+}): Promise {
+ return getEpisodeQuota(workspace);
+}
+
+/** First day of next month (YYYY-MM-DD) for the §6 quota-wall copy. */
+export function resetDateFor(month: string): string {
+ const [y, m] = month.split("-").map(Number);
+ return `${m === 12 ? y + 1 : y}-${String(m === 12 ? 1 : m + 1).padStart(2, "0")}-01`;
+}
+
+/** §6 verbatim quota-wall copy. */
+export function quotaWallCopy(quota: EpisodeQuota): string {
+ return `You've used ${quota.used} of ${quota.limit} episodes this month. Upgrade for more, or wait until ${quota.resetDate}.`;
+}
diff --git a/web/lib/blob.ts b/web/lib/blob.ts
new file mode 100644
index 0000000..eca902d
--- /dev/null
+++ b/web/lib/blob.ts
@@ -0,0 +1,80 @@
+/**
+ * Blob boundary (§9: private Blob via signed URLs). Every @vercel/blob call
+ * goes through this module so tests can mock the seam and the app degrades
+ * cleanly when BLOB_READ_WRITE_TOKEN is missing (owner blocker — tracked in
+ * the WP-05 PR; uploads/audio answer 503 until it is configured).
+ */
+import {
+ head,
+ issueSignedToken,
+ presignUrl,
+ type HeadBlobResult,
+} from "@vercel/blob";
+import { handleUpload as blobHandleUpload } from "@vercel/blob/client";
+
+export function isBlobConfigured(): boolean {
+ return Boolean(process.env.BLOB_READ_WRITE_TOKEN);
+}
+
+export interface ClientUploadConstraints {
+ allowedContentTypes: readonly string[];
+ maximumSizeInBytes: number;
+}
+
+/**
+ * Server half of the @vercel/blob client-upload flow: verifies the request,
+ * issues a short-lived client token scoped to the constraints, and accepts
+ * upload-completed callbacks. Returns the JSON to write back.
+ */
+export async function handleClientUpload(
+ request: Request,
+ constraints: ClientUploadConstraints,
+): Promise {
+ const body = await request.json().catch(() => null);
+ const result = await blobHandleUpload({
+ request,
+ body: body as never,
+ onBeforeGenerateToken: async () => ({
+ allowedContentTypes: [...constraints.allowedContentTypes],
+ maximumSizeInBytes: constraints.maximumSizeInBytes,
+ validUntil: Date.now() + 60 * 60 * 1000,
+ }),
+ });
+ return result;
+}
+
+export async function headBlob(pathname: string): Promise {
+ return head(pathname);
+}
+
+/** Signed short-lived GET URL (10 min) for private-blob playback (M5/M7). */
+export async function issueAudioUrl(
+ pathname: string,
+ ttlMs = 10 * 60 * 1000,
+): Promise<{ url: string; expiresAt: string }> {
+ const validUntil = Date.now() + ttlMs;
+ const token = await issueSignedToken({ pathname, operations: ["get"], validUntil });
+ const { presignedUrl } = await presignUrl(token, {
+ operation: "get",
+ pathname,
+ validUntil,
+ access: "private",
+ });
+ return { url: presignedUrl, expiresAt: new Date(validUntil).toISOString() };
+}
+
+/** Downloads a chunk for server-side ASR (§8.3). */
+export async function fetchChunkAudio(pathname: string): Promise<{
+ body: ArrayBuffer;
+ contentType: string;
+ filename: string;
+}> {
+ const meta = await head(pathname);
+ const res = await fetch(meta.downloadUrl);
+ if (!res.ok) throw new Error(`blob_fetch_${res.status}`);
+ return {
+ body: await res.arrayBuffer(),
+ contentType: meta.contentType,
+ filename: meta.pathname.split("/").pop() ?? "chunk",
+ };
+}
diff --git a/web/lib/compress/browser.ts b/web/lib/compress/browser.ts
new file mode 100644
index 0000000..8d940dc
--- /dev/null
+++ b/web/lib/compress/browser.ts
@@ -0,0 +1,148 @@
+/**
+ * Browser compression engine (M2) — ported from site/app.html and extended to
+ * chunked output (§8.3): decode with AudioContext → resample to mono 16 kHz
+ * with OfflineAudioContext → re-encode WebM/Opus ~24 kbps with MediaRecorder →
+ * ≤10-min chunks with a 2 s overlap. Pure decisions live in ./plan (tested in
+ * Node); this module only drives the browser audio graph. Client only.
+ */
+import { BITRATE_BPS, MAX_DURATION_SEC, SAMPLE_RATE, chooseMimeType, planChunks } from "./plan";
+
+export interface CompressedChunk {
+ blob: Blob;
+ offsetMs: number;
+ durationMs: number;
+}
+
+export interface CompressResult {
+ chunks: CompressedChunk[];
+ container: string;
+ durationMs: number;
+}
+
+export class CompressionError extends Error {
+ constructor(
+ public code: "too_long" | "no_encoder" | "decode_failed",
+ message: string,
+ ) {
+ super(message);
+ this.name = "CompressionError";
+ }
+}
+
+export type CompressProgress = (pct: number, stage: "decoding" | "encoding") => void;
+
+const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+type AudioContextCtor = new (options?: { sampleRate?: number }) => AudioContext;
+
+function audioContextCtor(): AudioContextCtor | null {
+ const w = window as unknown as {
+ AudioContext?: AudioContextCtor;
+ webkitAudioContext?: AudioContextCtor;
+ };
+ return w.AudioContext ?? w.webkitAudioContext ?? null;
+}
+
+/** Deterministic chunk path for the client upload (one blob per chunk). */
+export function chunkPathname(episodeId: string, index: number, extension: string): string {
+ return `episodes/${episodeId}/chunk-${String(index).padStart(4, "0")}.${extension}`;
+}
+
+/**
+ * Decode + resample + chunk-encode a picked media file. Encoding runs in real
+ * time per chunk (MediaRecorder records the playing buffer, as in
+ * site/app.html); onProgress reports overall percent.
+ */
+export async function compressToChunks(
+ file: File,
+ onProgress: CompressProgress = () => {},
+): Promise {
+ const Ctor = audioContextCtor();
+ if (!Ctor) throw new CompressionError("no_encoder", "Web Audio is unavailable in this browser.");
+
+ onProgress(4, "decoding");
+ let decoded: AudioBuffer;
+ const ctx = new Ctor();
+ try {
+ decoded = await ctx.decodeAudioData(await file.arrayBuffer());
+ } catch {
+ throw new CompressionError("decode_failed", "This audio could not be decoded.");
+ } finally {
+ await ctx.close().catch(() => {});
+ }
+
+ const durationSec = decoded.duration;
+ if (durationSec > MAX_DURATION_SEC) {
+ throw new CompressionError("too_long", "Episodes can be up to 2 hours.");
+ }
+
+ // Resample to 16 kHz mono offline (ported from site/app.html).
+ const offline = new OfflineAudioContext(1, Math.ceil(durationSec * SAMPLE_RATE), SAMPLE_RATE);
+ const src = offline.createBufferSource();
+ src.buffer = decoded;
+ src.connect(offline.destination);
+ src.start();
+ const mono = await offline.startRendering();
+
+ const mime = chooseMimeType((t) =>
+ typeof MediaRecorder !== "undefined" ? MediaRecorder.isTypeSupported(t) : false,
+ );
+ if (!mime) {
+ throw new CompressionError(
+ "no_encoder",
+ "This browser can't re-encode audio — paste the transcript instead.",
+ );
+ }
+
+ const chunks = planChunks(durationSec);
+ const out: CompressedChunk[] = [];
+ for (const chunk of chunks) {
+ const startSample = Math.floor(chunk.startSec * SAMPLE_RATE);
+ const endSample = Math.min(Math.floor(chunk.endSec * SAMPLE_RATE), mono.length);
+ const length = Math.max(1, endSample - startSample);
+ const slice = new AudioBuffer({ length, sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
+ slice.copyToChannel(mono.getChannelData(0).subarray(startSample, endSample), 0);
+
+ const parts: Blob[] = [];
+ const recCtx = new Ctor();
+ try {
+ const dest = recCtx.createMediaStreamDestination();
+ const play = recCtx.createBufferSource();
+ play.buffer = slice;
+ play.connect(dest);
+ const rec = new MediaRecorder(dest.stream, {
+ mimeType: mime.mimeType,
+ audioBitsPerSecond: BITRATE_BPS,
+ });
+ rec.ondataavailable = (e) => {
+ if (e.data.size > 0) parts.push(e.data);
+ };
+ const stopped = new Promise((resolve) => {
+ rec.onstop = () => resolve();
+ });
+ rec.start(2000);
+ play.start();
+ const encodeSecs = slice.duration;
+ const base = 25 + (chunk.index / chunks.length) * 70;
+ const span = 70 / chunks.length;
+ const tick = setInterval(() => {
+ const done = Math.min(1, recCtx.currentTime / Math.max(encodeSecs, 0.001));
+ onProgress(Math.round(base + done * span), "encoding");
+ }, 500);
+ await wait(encodeSecs * 1000 + 300);
+ rec.stop();
+ await stopped;
+ clearInterval(tick);
+ } finally {
+ await recCtx.close().catch(() => {});
+ }
+ out.push({
+ blob: new Blob(parts, { type: mime.container }),
+ offsetMs: chunk.offsetMs,
+ durationMs: Math.round(slice.duration * 1000),
+ });
+ onProgress(25 + ((chunk.index + 1) / chunks.length) * 70, "encoding");
+ }
+ onProgress(100, "encoding");
+ return { chunks: out, container: mime.container, durationMs: Math.round(durationSec * 1000) };
+}
diff --git a/web/lib/compress/plan.ts b/web/lib/compress/plan.ts
new file mode 100644
index 0000000..02c9cfc
--- /dev/null
+++ b/web/lib/compress/plan.ts
@@ -0,0 +1,86 @@
+/**
+ * Pure decision functions for browser compression (M2, ported from
+ * site/app.html): decode the file, resample to mono 16 kHz (OfflineAudioContext),
+ * re-encode Opus at ~24 kbps, and split into ≤10-min chunks with a 2 s overlap
+ * so the server can dedupe by timestamp (§8.3). The AudioContext/MediaRecorder
+ * graph lives in lib/compress/browser.ts (client only); everything decided
+ * here is unit-testable in Node.
+ */
+
+export const SAMPLE_RATE = 16_000;
+/** ~24 kbps per M2 → ~1.8 MB per 10-min chunk (well under the 25 MB ASR limit). */
+export const BITRATE_BPS = 24_000;
+export const CHUNK_SEC = 600;
+export const OVERLAP_SEC = 2;
+/** mp3/m4a/wav/aac/ogg/webm/mp4/mov · up to 2 hours (M2). */
+export const MAX_DURATION_SEC = 2 * 3600;
+
+export interface PlannedChunk {
+ index: number;
+ /** Absolute start in the source audio (seconds). */
+ startSec: number;
+ /** Absolute end in the source audio (seconds). */
+ endSec: number;
+ /** Absolute start in ms — the offset passed to the ASR merge. */
+ offsetMs: number;
+ /** Chunks after the first include the previous 2 s as an overlap prefix. */
+ hasOverlapPrefix: boolean;
+}
+
+/**
+ * Split [0, durationSec] into ≤CHUNK_SEC chunks. Chunk N starts at
+ * N * (CHUNK_SEC - OVERLAP_SEC) so consecutive chunks share a 2 s seam.
+ */
+export function planChunks(durationSec: number): PlannedChunk[] {
+ const step = CHUNK_SEC - OVERLAP_SEC;
+ const chunks: PlannedChunk[] = [];
+ for (let startSec = 0; startSec < durationSec; startSec += step) {
+ const endSec = Math.min(startSec + CHUNK_SEC, durationSec);
+ chunks.push({
+ index: chunks.length,
+ startSec,
+ endSec,
+ offsetMs: Math.round(startSec * 1000),
+ hasOverlapPrefix: startSec > 0,
+ });
+ }
+ // Absorb a tiny trailing chunk (≤ OVERLAP_SEC of new audio) into the
+ // previous chunk — a 2 s upload + ASR call is pure waste. The previous
+ // chunk then ends at durationSec (≤ CHUNK_SEC + OVERLAP_SEC, still tiny).
+ if (chunks.length > 1) {
+ const last = chunks[chunks.length - 1];
+ const prev = chunks[chunks.length - 2];
+ if (last.endSec - prev.endSec <= OVERLAP_SEC) {
+ prev.endSec = durationSec;
+ chunks.pop();
+ }
+ }
+ return chunks;
+}
+
+/** Wire size estimate for one chunk: bytes = seconds * bitsPerSecond / 8. */
+export function estimateChunkBytes(seconds: number, bitrateBps = BITRATE_BPS): number {
+ return (seconds * bitrateBps) / 8;
+}
+
+export interface MimeChoice {
+ mimeType: string;
+ /** Container without codecs parameter — used for the Blob type + upload. */
+ container: string;
+ extension: string;
+}
+
+/**
+ * Encoder choice, ported from site/app.html: prefer WebM/Opus, fall back to
+ * MP4 (Safari's MediaRecorder), otherwise give up cleanly (the paste tab
+ * still works).
+ */
+export function chooseMimeType(isTypeSupported: (type: string) => boolean): MimeChoice | null {
+ if (isTypeSupported("audio/webm;codecs=opus")) {
+ return { mimeType: "audio/webm;codecs=opus", container: "audio/webm", extension: "webm" };
+ }
+ if (isTypeSupported("audio/mp4")) {
+ return { mimeType: "audio/mp4", container: "audio/mp4", extension: "mp4" };
+ }
+ return null;
+}
diff --git a/web/lib/copy.ts b/web/lib/copy.ts
index 5bafa61..f0deddd 100644
--- a/web/lib/copy.ts
+++ b/web/lib/copy.ts
@@ -15,3 +15,31 @@ export const NO_SHOW_COPY = "Create your show first.";
export const SHOW_NAME_REQUIRED_COPY = "Enter your show name.";
export const URL_INVALID_COPY = "Enter a valid URL.";
+
+// --- WP-05: intake + ASR (§6 copy deck unless noted) ---
+
+/** U19 — verbatim. */
+export const UNSUPPORTED_LANGUAGE_COPY =
+ "EpisodeKit works with English episodes for now. This one wasn't charged.";
+
+export function tooLongCopy(limit: string): string {
+ return `Episodes can be up to ${limit} on your plan.`;
+}
+
+export const NO_SPEECH_COPY =
+ "We couldn't hear any speech in this file. Check the audio and try again.";
+
+/** transcribe_failed / generic — verbatim. */
+export const TRANSCRIBE_FAILED_COPY =
+ "Something went wrong on our side. Try again — it won't use another episode.";
+
+export const NO_AUDIO_COPY = "This episode has no audio to transcribe.";
+
+export const UPLOAD_AUDIO_FIRST_COPY = "Upload the audio before transcribing.";
+
+/** Keyless preview (no BLOB_READ_WRITE_TOKEN) — audio upload answers 503. */
+export const AUDIO_UPLOAD_UNAVAILABLE_COPY = "Audio upload isn't set up in this preview.";
+
+export const AUDIO_PLAYBACK_UNAVAILABLE_COPY = "Audio playback isn't set up in this preview.";
+
+export const PASTE_TRANSCRIPT_FIRST_COPY = "Paste a transcript first.";
diff --git a/web/lib/episodes/audio.ts b/web/lib/episodes/audio.ts
new file mode 100644
index 0000000..a19d070
--- /dev/null
+++ b/web/lib/episodes/audio.ts
@@ -0,0 +1,27 @@
+/**
+ * Upload allow-list decisions shared by the browser intake (MK-4) and the
+ * /api/uploads/token route (§7.5). Extensions from M2; size ≤300 MB
+ * compressed (§7.5).
+ */
+import { AUDIO_UPLOAD_EXTENSIONS, MAX_UPLOAD_BYTES } from "@/lib/billing/entitlements";
+
+export function fileExtension(name: string): string {
+ const dot = name.lastIndexOf(".");
+ return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
+}
+
+export function isAllowedAudioFilename(name: string): boolean {
+ return (AUDIO_UPLOAD_EXTENSIONS as readonly string[]).includes(fileExtension(name));
+}
+
+export type UploadCheck = { ok: true } | { ok: false; reason: "type" | "size"; copy: string };
+
+export function checkUpload(filename: string, sizeBytes: number): UploadCheck {
+ if (!isAllowedAudioFilename(filename)) {
+ return { ok: false, reason: "type", copy: "That file type isn't supported. Try MP3, M4A, WAV, MP4 or MOV." };
+ }
+ if (sizeBytes > MAX_UPLOAD_BYTES) {
+ return { ok: false, reason: "size", copy: "That file is over the 300 MB upload limit." };
+ }
+ return { ok: true };
+}
diff --git a/web/lib/episodes/input.ts b/web/lib/episodes/input.ts
new file mode 100644
index 0000000..08a62de
--- /dev/null
+++ b/web/lib/episodes/input.ts
@@ -0,0 +1,31 @@
+/**
+ * Request schemas for the intake routes (§7.5). zod 4 (see DECISIONS.md:
+ * count constraints live on the array, not the object).
+ */
+import { z } from "zod";
+
+export const episodeCreateSchema = z.object({
+ source: z.enum(["upload", "paste", "sample"]),
+ /** Paste/sample path: the transcript text (parsed server-side). */
+ transcriptText: z.string().optional(),
+ /** §7.5 shape: chunk blob pathnames (parallel to chunkOffsetsMs). */
+ blobPaths: z.array(z.string().min(1)).optional(),
+ /** §8.3: each chunk's absolute start in ms (parallel to blobPaths). */
+ chunkOffsetsMs: z.array(z.number().int().nonnegative()).optional(),
+ title: z.string().max(200).optional(),
+ number: z.number().int().min(0).max(10000).optional(),
+ guests: z.array(z.string().min(1).max(80)).max(10).optional(),
+});
+
+export const episodeTranscribeSchema = z.object({
+ /** Chunk manifest from the upload flow; falls back to the stored manifest. */
+ chunks: z
+ .array(
+ z.object({
+ path: z.string().min(1),
+ offsetMs: z.number().int().nonnegative(),
+ }),
+ )
+ .max(64)
+ .optional(),
+});
diff --git a/web/lib/episodes/manifest.ts b/web/lib/episodes/manifest.ts
new file mode 100644
index 0000000..c266755
--- /dev/null
+++ b/web/lib/episodes/manifest.ts
@@ -0,0 +1,48 @@
+/**
+ * Chunk manifest stored in episodes.audio_blob_path (§7.1 has a single text
+ * column; the browser-produced ≤10-min chunks + their offsets are serialized
+ * as JSON — logged in DECISIONS.md). A plain single path string is still
+ * readable as one chunk at 0 ms.
+ */
+export interface StoredChunk {
+ path: string;
+ offsetMs: number;
+}
+
+export function serializeChunkManifest(chunks: StoredChunk[]): string {
+ return JSON.stringify(chunks);
+}
+
+export function parseChunkManifest(value: string | null | undefined): StoredChunk[] {
+ if (!value) return [];
+ try {
+ const parsed: unknown = JSON.parse(value);
+ if (!Array.isArray(parsed)) return fallback(value);
+ const chunks = parsed.filter(
+ (c): c is StoredChunk =>
+ typeof c === "object" &&
+ c !== null &&
+ typeof (c as StoredChunk).path === "string" &&
+ typeof (c as StoredChunk).offsetMs === "number",
+ );
+ return chunks.length ? chunks : fallback(value);
+ } catch {
+ return fallback(value);
+ }
+}
+
+function fallback(value: string): StoredChunk[] {
+ const trimmed = value.trim();
+ if (!trimmed) return [];
+ return [{ path: trimmed, offsetMs: 0 }];
+}
+
+/** The chunk containing time `tMs` (the last one starting at or before it). */
+export function chunkContaining(chunks: StoredChunk[], tMs: number): StoredChunk | null {
+ if (!chunks.length) return null;
+ let found = chunks[0];
+ for (const chunk of chunks) {
+ if (chunk.offsetMs <= tMs) found = chunk;
+ }
+ return found;
+}
diff --git a/web/lib/episodes/month.ts b/web/lib/episodes/month.ts
new file mode 100644
index 0000000..289dab9
--- /dev/null
+++ b/web/lib/episodes/month.ts
@@ -0,0 +1,4 @@
+/** "2026-10" — quota month stamp for episodes.counts_quota (§7.1). */
+export function quotaMonthNow(now: Date = new Date()): string {
+ return now.toISOString().slice(0, 7);
+}
diff --git a/web/lib/episodes/service.ts b/web/lib/episodes/service.ts
new file mode 100644
index 0000000..c77125f
--- /dev/null
+++ b/web/lib/episodes/service.ts
@@ -0,0 +1,364 @@
+/**
+ * Intake service (WP-05): POST /api/episodes, POST /api/episodes/:id/transcribe,
+ * GET /api/episodes/:id (status read path for U4), GET /api/audio/:episodeId,
+ * POST /api/uploads/token (§7.5). DB/blob/ASR calls go through their documented
+ * boundaries (@/db/queries, @/lib/blob, @/lib/asr/groq) so tests mock the seams
+ * and keyless previews degrade with a clean 503.
+ *
+ * Quota: counted at create (§7.5) through the entitlements stub
+ * (lib/billing/entitlements — WP-08 replaces the bodies). Failures refund:
+ * unsupported_language / no_speech set counts_quota = false (U19: "This one
+ * wasn't charged"). Sample episodes never count (M2).
+ */
+import {
+ AUDIO_UPLOAD_CONTENT_TYPES,
+ MAX_UPLOAD_BYTES,
+ checkEpisodeQuota,
+ planLimits,
+ quotaWallCopy,
+} from "@/lib/billing/entitlements";
+import {
+ AUDIO_PLAYBACK_UNAVAILABLE_COPY,
+ NO_AUDIO_COPY,
+ NO_SHOW_COPY,
+ NO_SPEECH_COPY,
+ PASTE_TRANSCRIPT_FIRST_COPY,
+ SAVE_UNAVAILABLE_COPY,
+ TRANSCRIBE_FAILED_COPY,
+ UNSUPPORTED_LANGUAGE_COPY,
+ UPLOAD_AUDIO_FIRST_COPY,
+} from "@/lib/copy";
+import { isDbConfigured } from "@/lib/env";
+import { AsrError, transcribeWithGroq } from "@/lib/asr/groq";
+import { supportedInput } from "@/lib/asr/language";
+import { handleClientUpload, isBlobConfigured, issueAudioUrl } from "@/lib/blob";
+import type { ApiResult } from "@/lib/rss/preview";
+import type { Segment } from "@/lib/quotes/types";
+import * as queries from "@/db/queries";
+import { parseTranscript } from "@/lib/transcripts";
+import { quotaMonthNow } from "./month";
+import {
+ chunkContaining,
+ parseChunkManifest,
+ serializeChunkManifest,
+ type StoredChunk,
+} from "./manifest";
+import { episodeCreateSchema, episodeTranscribeSchema } from "./input";
+
+export type { ApiResult };
+
+export interface SerializedEpisode {
+ id: string;
+ title: string | null;
+ number: number | null;
+ source: "upload" | "paste" | "sample";
+ status: "uploading" | "queued" | "transcribing" | "writing" | "ready" | "failed";
+ errorCode: string | null;
+ durationMs: number | null;
+ hasTimestamps: boolean;
+ hasSpeakers: boolean;
+ countsQuota: boolean;
+}
+
+export function serializeEpisode(episode: queries.EpisodeRow): SerializedEpisode {
+ return {
+ id: episode.id,
+ title: episode.title,
+ number: episode.number,
+ source: episode.source,
+ status: episode.status,
+ errorCode: episode.errorCode,
+ durationMs: episode.durationMs,
+ hasTimestamps: episode.hasTimestamps,
+ hasSpeakers: episode.hasSpeakers,
+ countsQuota: episode.countsQuota,
+ };
+}
+
+/** §9: every app route resolves the episode through the caller's workspace. */
+export async function requireEpisode(userId: string, episodeId: string) {
+ return queries.getEpisodeForUser(userId, episodeId);
+}
+
+/** Chunks with a valid manifest entry; blobPaths[] + chunkOffsetsMs[] per §7.5. */
+function chunksFromBody(body: {
+ blobPaths?: string[];
+ chunkOffsetsMs?: number[];
+}): StoredChunk[] | null {
+ if (!body.blobPaths?.length) return null;
+ const offsets = body.chunkOffsetsMs ?? [];
+ return body.blobPaths.map((path, i) => ({ path, offsetMs: offsets[i] ?? 0 }));
+}
+
+/**
+ * POST /api/episodes — create {source, blobPaths[]?, chunkOffsetsMs[]?,
+ * transcriptText?, title?, number?, guests?} → {id, status} (§7.5).
+ * Upload → status uploading; paste/sample → transcript parsed into segments,
+ * status queued. Counts quota except for samples (M2).
+ */
+export async function handleEpisodesPost(userId: string, raw: unknown): Promise {
+ if (!isDbConfigured()) {
+ return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } };
+ }
+ const parsed = episodeCreateSchema.safeParse(raw);
+ if (!parsed.success) {
+ return { status: 400, json: { error: parsed.error.issues[0]?.message ?? "Invalid request." } };
+ }
+ const body = parsed.data;
+ const show = await queries.getShowByOwner(userId);
+ if (!show) {
+ return { status: 404, json: { error: NO_SHOW_COPY } };
+ }
+ const workspace = await queries.getOrCreateWorkspaceByOwner(userId);
+
+ const isSample = body.source === "sample";
+ if (!isSample) {
+ const quota = await checkEpisodeQuota(workspace);
+ if (!quota.allowed) {
+ return { status: 402, json: { errorCode: "quota_exceeded", error: quotaWallCopy(quota) } };
+ }
+ }
+
+ let segments: Segment[] = [];
+ let hasTimestamps = false;
+ let hasSpeakers = false;
+ if (body.source === "paste" || body.source === "sample") {
+ const text = (body.transcriptText ?? "").trim();
+ if (!text) {
+ return { status: 400, json: { errorCode: "empty_transcript", error: PASTE_TRANSCRIPT_FIRST_COPY } };
+ }
+ // English-only for the MVP — same gate as ASR (§8.3); nothing is created,
+ // so nothing is charged (U19).
+ if (!supportedInput(text)) {
+ return { status: 400, json: { errorCode: "unsupported_language", error: UNSUPPORTED_LANGUAGE_COPY } };
+ }
+ const parsedTranscript = parseTranscript(text);
+ segments = parsedTranscript.segments.map((s, idx) => ({
+ id: `seg_${String(idx).padStart(6, "0")}`,
+ idx,
+ speaker: s.speaker,
+ startMs: s.startMs,
+ endMs: s.endMs,
+ text: s.text,
+ }));
+ hasTimestamps = parsedTranscript.hasTimestamps;
+ hasSpeakers = parsedTranscript.hasSpeakers;
+ }
+
+ const chunks = chunksFromBody(body);
+ const episode = await queries.createEpisode({
+ showId: show.id,
+ title: body.title ?? null,
+ number: body.number ?? null,
+ guests: body.guests ?? null,
+ source: body.source,
+ audioBlobPath: chunks ? serializeChunkManifest(chunks) : null,
+ hasTimestamps,
+ hasSpeakers,
+ status: body.source === "upload" ? "uploading" : "queued",
+ quotaMonth: isSample ? null : quotaMonthNow(),
+ countsQuota: !isSample,
+ });
+
+ if (segments.length) {
+ await queries.replaceSegments(
+ episode.id,
+ segments.map((s) => ({
+ id: s.id,
+ idx: s.idx,
+ speaker: s.speaker,
+ startMs: s.startMs,
+ endMs: s.endMs,
+ text: s.text,
+ })),
+ );
+ }
+
+ return { status: 201, json: { id: episode.id, status: episode.status } };
+}
+
+/** GET /api/episodes/:id — episode + status (U4 resumable read path). */
+export async function handleEpisodeGet(userId: string, episodeId: string): Promise {
+ if (!isDbConfigured()) {
+ return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } };
+ }
+ const owned = await requireEpisode(userId, episodeId);
+ if (!owned) {
+ return { status: 404, json: { error: "Episode not found." } };
+ }
+ return { status: 200, json: { episode: serializeEpisode(owned.episode) } };
+}
+
+/** Maps AsrError codes to §6 error codes. */
+export function mapAsrErrorCode(code: string): string {
+ if (code === "unsupported_language") return "unsupported_language";
+ if (code === "empty_transcript" || code === "empty_audio") return "no_speech";
+ if (code === "too_large") return "too_long";
+ return "transcribe_failed";
+}
+
+export function errorCodeCopy(code: string): string {
+ switch (code) {
+ case "unsupported_language":
+ return UNSUPPORTED_LANGUAGE_COPY;
+ case "no_speech":
+ return NO_SPEECH_COPY;
+ case "too_long":
+ return "Episodes can be up to 2 hours on your plan.";
+ default:
+ return TRANSCRIBE_FAILED_COPY;
+ }
+}
+
+/**
+ * POST /api/episodes/:id/transcribe (§7.5) — runs Groq on the uploaded chunks
+ * → segments. Status transitions uploading→queued→transcribing; success →
+ * queued (queued-for-writing). Idempotent: transcribing/done states are
+ * returned as-is so a refresh or second tab resumes from DB state.
+ */
+export async function handleTranscribePost(
+ userId: string,
+ episodeId: string,
+ raw: unknown,
+): Promise {
+ if (!isDbConfigured()) {
+ return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } };
+ }
+ const owned = await requireEpisode(userId, episodeId);
+ if (!owned) {
+ return { status: 404, json: { error: "Episode not found." } };
+ }
+ const { episode, show } = owned;
+ if (episode.source !== "upload") {
+ return { status: 400, json: { error: NO_AUDIO_COPY } };
+ }
+ if (episode.status === "transcribing" || episode.status === "writing" || episode.status === "ready") {
+ return { status: 200, json: { episode: serializeEpisode(episode) } };
+ }
+
+ const parsed = episodeTranscribeSchema.safeParse(raw ?? {});
+ if (!parsed.success) {
+ return { status: 400, json: { error: parsed.error.issues[0]?.message ?? "Invalid request." } };
+ }
+ const chunks: StoredChunk[] = parsed.data.chunks?.length
+ ? parsed.data.chunks
+ : parseChunkManifest(episode.audioBlobPath);
+ if (!chunks.length) {
+ return { status: 400, json: { error: UPLOAD_AUDIO_FIRST_COPY } };
+ }
+
+ // uploading→queued→transcribing: two visible transitions (U4 status read path).
+ await queries.updateEpisode(episode.id, {
+ status: "queued",
+ audioBlobPath: serializeChunkManifest(chunks),
+ errorCode: null,
+ });
+ await queries.updateEpisode(episode.id, { status: "transcribing" });
+
+ const guests = Array.isArray(episode.guests) ? (episode.guests as string[]) : [];
+ const vocab = [...(show.vocabulary ?? []), ...guests];
+
+ try {
+ const segments = await transcribeWithGroq(
+ chunks.map((c) => ({ blobPath: c.path, offsetMs: c.offsetMs })),
+ { vocab },
+ );
+ await queries.replaceSegments(
+ episode.id,
+ segments.map((s) => ({
+ id: s.id,
+ idx: s.idx,
+ speaker: s.speaker,
+ startMs: s.startMs,
+ endMs: s.endMs,
+ text: s.text,
+ })),
+ );
+ const durationMs = segments.reduce((max, s) => Math.max(max, s.endMs ?? 0), 0) || null;
+ const updated = await queries.updateEpisode(episode.id, {
+ status: "queued", // queued-for-writing (§7.5); WP-06 drives writing
+ durationMs,
+ hasTimestamps: true,
+ hasSpeakers: false,
+ errorCode: null,
+ });
+ return { status: 200, json: { episode: serializeEpisode(updated) } };
+ } catch (err) {
+ const code = err instanceof AsrError ? mapAsrErrorCode(err.code) : "transcribe_failed";
+ // No quota used on failure (U19 / M10 refund).
+ const updated = await queries.updateEpisode(episode.id, {
+ status: "failed",
+ errorCode: code,
+ countsQuota: false,
+ });
+ return { status: 200, json: { episode: serializeEpisode(updated) } };
+ }
+}
+
+/**
+ * GET /api/audio/:episodeId?t= — short-lived signed URL for the chunk
+ * containing t (§7.5; quote playback in M5 seeks within that chunk).
+ */
+export async function handleAudioGet(
+ userId: string,
+ episodeId: string,
+ tMs = 0,
+): Promise {
+ if (!isDbConfigured()) {
+ return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } };
+ }
+ const owned = await requireEpisode(userId, episodeId);
+ if (!owned) {
+ return { status: 404, json: { error: "Episode not found." } };
+ }
+ const chunks = parseChunkManifest(owned.episode.audioBlobPath);
+ if (!chunks.length) {
+ return { status: 404, json: { error: NO_AUDIO_COPY } };
+ }
+ if (!isBlobConfigured()) {
+ return { status: 503, json: { error: AUDIO_PLAYBACK_UNAVAILABLE_COPY } };
+ }
+ const chunk = chunkContaining(chunks, Math.max(0, tMs));
+ if (!chunk) {
+ return { status: 404, json: { error: NO_AUDIO_COPY } };
+ }
+ try {
+ const { url, expiresAt } = await issueAudioUrl(chunk.path);
+ return { status: 200, json: { url, chunkOffsetMs: chunk.offsetMs, expiresAt } };
+ } catch {
+ return { status: 502, json: { error: "Audio storage error. Try again." } };
+ }
+}
+
+/**
+ * POST /api/uploads/token (§7.5) — the @vercel/blob client-upload token route.
+ * Type allow-list + ≤300 MB + quota check (via the entitlements stub). The
+ * real token is issued by handleUpload; keyless previews answer 503.
+ */
+export async function handleUploadsTokenPost(userId: string, request: Request): Promise {
+ if (!isDbConfigured()) {
+ return { status: 503, json: { error: SAVE_UNAVAILABLE_COPY } };
+ }
+ if (!isBlobConfigured()) {
+ return { status: 503, json: { error: "Audio upload isn't set up in this preview." } };
+ }
+ const show = await queries.getShowByOwner(userId);
+ if (!show) {
+ return { status: 404, json: { error: NO_SHOW_COPY } };
+ }
+ const workspace = await queries.getOrCreateWorkspaceByOwner(userId);
+ const quota = await checkEpisodeQuota(workspace);
+ if (!quota.allowed) {
+ return { status: 402, json: { errorCode: "quota_exceeded", error: quotaWallCopy(quota) } };
+ }
+ const result = await handleClientUpload(request, {
+ allowedContentTypes: AUDIO_UPLOAD_CONTENT_TYPES,
+ maximumSizeInBytes: MAX_UPLOAD_BYTES,
+ });
+ return { status: 200, json: { ok: true, result: result as Record } };
+}
+
+/** Plan duration limit for the client-side 2 h gate (§6 too_long copy). */
+export function maxDurationMsForPlan(plan: string): number {
+ return planLimits(plan).maxDurationMs;
+}
diff --git a/web/lib/transcripts/index.ts b/web/lib/transcripts/index.ts
new file mode 100644
index 0000000..e131a3b
--- /dev/null
+++ b/web/lib/transcripts/index.ts
@@ -0,0 +1,254 @@
+/**
+ * Transcript parsers for the paste path (M2): plain text, SRT, VTT and the
+ * speaker-timestamped exports of Riverside/Descript/Otter —
+ * `Name (00:12:03): text` and `[00:12:03] Name: text`.
+ *
+ * Detection returns the format label used by the U5 chip
+ * ("Riverside · speakers and timestamps found"); labels derive from the M2
+ * format names. Timestamps + speakers are kept when present (they become
+ * episodes.has_timestamps / has_speakers and the segments columns).
+ */
+
+export type TranscriptFormat = "plain" | "srt" | "vtt" | "riverside" | "descript" | "otter";
+
+export interface ParsedSegment {
+ speaker: string | null;
+ startMs: number | null;
+ endMs: number | null;
+ text: string;
+}
+
+export interface DetectResult {
+ format: TranscriptFormat;
+ hasTimestamps: boolean;
+ hasSpeakers: boolean;
+ /** U5 chip copy, e.g. "Riverside · speakers and timestamps found". */
+ chip: string;
+}
+
+export interface ParseResult extends DetectResult {
+ segments: ParsedSegment[];
+}
+
+/** `Name (00:12:03): text` — Riverside's classic export shape. */
+const PAREN_LINE = /^(.{1,60}?)\s*\((\d{1,2}:\d{1,2}(?::\d{1,2})?(?:[.,]\d{1,3})?)\):\s?(.*)$/;
+/** `[00:12:03] Name: text` — the bracket-timestamped export shape. */
+const BRACKET_LINE = /^\[(\d{1,2}:\d{1,2}(?::\d{1,2})?(?:[.,]\d{1,3})?)\]\s*(.{1,60}?):\s?(.*)$/;
+/** `Name: text` without timestamps (Descript plain export). */
+const SPEAKER_LINE = /^([A-Za-z0-9][A-Za-z0-9 ._'-]{0,39}):\s?(.+)$/;
+/** Speaker prefix inside an SRT/VTT cue, e.g. "Maya: text". */
+const CUE_SPEAKER = /^([A-Za-z0-9][A-Za-z0-9 ._'-]{0,39}):\s?(.+)$/;
+
+/**
+ * A plausible speaker label: 1–4 capitalized words ("Maya", "Speaker 2",
+ * "Dr Maya Patel"). Keeps prose like "a second: seconds are how…" or
+ * "scripts: I am offline…" from reading as speaker-labelled lines.
+ */
+function looksLikeSpeakerLabel(label: string): boolean {
+ const words = label.trim().split(/\s+/);
+ if (words.length > 4) return false;
+ return words.every((w) => /^(?:[A-Z][A-Za-z0-9'._-]*|\d+)$/.test(w));
+}
+
+const SRT_TIMING = /^(\d{1,2}:\d{1,2}(?::\d{1,2})?(?:[.,]\d{1,3})?)\s*-->\s*(\d{1,2}:\d{1,2}(?::\d{1,2})?(?:[.,]\d{1,3})?)/;
+
+/** "hh:mm:ss" | "mm:ss" (+ optional ",mmm"/".mmm") → ms. */
+export function timestampToMs(raw: string): number | null {
+ const m = raw.trim().match(/^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?(?:[.,](\d{1,3}))?$/);
+ if (!m) return null;
+ const a = Number(m[1]);
+ const b = Number(m[2]);
+ const c = m[3] !== undefined ? Number(m[3]) : null;
+ const frac = m[4] !== undefined ? Number(m[4].padEnd(3, "0")) : 0;
+ if (c === null) return (a * 60 + b) * 1000 + frac; // mm:ss
+ return (a * 3600 + b * 60 + c) * 1000 + frac; // hh:mm:ss
+}
+
+const normalize = (text: string): string => text.replace(/\r\n?/g, "\n");
+
+function countMatches(text: string, re: RegExp): number {
+ let n = 0;
+ for (const line of text.split("\n")) {
+ if (re.test(line)) n += 1;
+ }
+ return n;
+}
+
+function countSpeakerLines(text: string): number {
+ let n = 0;
+ for (const line of text.split("\n")) {
+ const m = line.match(SPEAKER_LINE);
+ if (m && looksLikeSpeakerLabel(m[1])) n += 1;
+ }
+ return n;
+}
+
+function chipFor(label: string, hasTimestamps: boolean, hasSpeakers: boolean): string {
+ const parts = [
+ hasSpeakers ? "speakers" : null,
+ hasTimestamps ? "timestamps" : null,
+ ].filter(Boolean);
+ return parts.length ? `${label} · ${parts.join(" and ")} found` : label;
+}
+
+const LABELS: Record = {
+ plain: "Plain text",
+ srt: "SRT",
+ vtt: "VTT",
+ riverside: "Riverside",
+ descript: "Descript",
+ otter: "Otter",
+};
+
+export function detectFormat(rawText: string): DetectResult {
+ const text = normalize(rawText);
+ const trimmed = text.trim();
+ if (!trimmed) {
+ return { format: "plain", hasTimestamps: false, hasSpeakers: false, chip: LABELS.plain };
+ }
+
+ const firstLine = trimmed.split("\n")[0].trim();
+ const timingLines = countMatches(text, SRT_TIMING);
+ if (!firstLine.startsWith("WEBVTT") && timingLines >= 1) {
+ return { format: "srt", hasTimestamps: true, hasSpeakers: false, chip: chipFor(LABELS.srt, true, false) };
+ }
+ if (firstLine.startsWith("WEBVTT")) {
+ return { format: "vtt", hasTimestamps: true, hasSpeakers: false, chip: chipFor(LABELS.vtt, true, false) };
+ }
+
+ const parenLines = countMatches(text, PAREN_LINE);
+ if (parenLines >= 2) {
+ return { format: "riverside", hasTimestamps: true, hasSpeakers: true, chip: chipFor(LABELS.riverside, true, true) };
+ }
+ const bracketLines = countMatches(text, BRACKET_LINE);
+ if (bracketLines >= 2) {
+ return { format: "otter", hasTimestamps: true, hasSpeakers: true, chip: chipFor(LABELS.otter, true, true) };
+ }
+
+ const speakerLines = countSpeakerLines(text);
+ if (speakerLines >= 2) {
+ return { format: "descript", hasTimestamps: false, hasSpeakers: true, chip: chipFor(LABELS.descript, false, true) };
+ }
+
+ return { format: "plain", hasTimestamps: false, hasSpeakers: false, chip: LABELS.plain };
+}
+
+function parseSrtVtt(text: string, format: "srt" | "vtt"): ParsedSegment[] {
+ const segments: ParsedSegment[] = [];
+ const blocks = text.split(/\n{2,}/);
+ for (const block of blocks) {
+ const lines = block.split("\n").map((l) => l.trim()).filter(Boolean);
+ const timingIndex = lines.findIndex((l) => SRT_TIMING.test(l));
+ if (timingIndex === -1) continue; // WEBVTT header, NOTE blocks, cue ids alone
+ const timing = lines[timingIndex].match(SRT_TIMING);
+ if (!timing) continue;
+ const startMs = timestampToMs(timing[1]);
+ const endMs = timestampToMs(timing[2]);
+ const cueText = lines.slice(timingIndex + 1).join(" ").trim();
+ if (!cueText || startMs === null) continue;
+ // Riverside-style SRT exports put "Name: text" inside the cue.
+ const speakerMatch = cueText.match(CUE_SPEAKER);
+ if (speakerMatch && endMs !== null && looksLikeSpeakerLabel(speakerMatch[1])) {
+ segments.push({ speaker: speakerMatch[1].trim(), startMs, endMs, text: speakerMatch[2].trim() });
+ } else {
+ segments.push({ speaker: null, startMs, endMs: endMs ?? null, text: cueText });
+ }
+ void format;
+ }
+ segments.sort((a, b) => (a.startMs ?? 0) - (b.startMs ?? 0));
+ return segments;
+}
+
+function parseSpeakerTimestamped(
+ text: string,
+ re: RegExp,
+ groups: { time: number; speaker: number; text: number },
+): ParsedSegment[] {
+ const segments: ParsedSegment[] = [];
+ for (const line of text.split("\n")) {
+ const m = line.match(re);
+ if (m) {
+ const startMs = timestampToMs(m[groups.time]);
+ segments.push({
+ speaker: m[groups.speaker].trim(),
+ startMs,
+ endMs: null,
+ text: m[groups.text].trim(),
+ });
+ } else if (line.trim() && segments.length > 0) {
+ // Continuation line: same speaker, same moment.
+ const prev = segments[segments.length - 1];
+ prev.text = `${prev.text} ${line.trim()}`;
+ }
+ }
+ if (segments.some((s) => s.startMs !== null)) {
+ segments.sort((a, b) => (a.startMs ?? 0) - (b.startMs ?? 0));
+ }
+ return segments;
+}
+
+function parseSpeakerOnly(text: string): ParsedSegment[] {
+ const segments: ParsedSegment[] = [];
+ for (const line of text.split("\n")) {
+ const m = line.match(SPEAKER_LINE);
+ if (m && looksLikeSpeakerLabel(m[1])) {
+ segments.push({ speaker: m[1].trim(), startMs: null, endMs: null, text: m[2].trim() });
+ } else if (line.trim() && segments.length > 0) {
+ const prev = segments[segments.length - 1];
+ prev.text = `${prev.text} ${line.trim()}`;
+ }
+ }
+ return segments;
+}
+
+function parsePlain(text: string): ParsedSegment[] {
+ return text
+ .split(/\n{2,}/)
+ .map((p) => p.replace(/\n/g, " ").trim())
+ .filter(Boolean)
+ .map((text) => ({ speaker: null, startMs: null, endMs: null, text }));
+}
+
+/**
+ * Parse pasted transcript text into segments. Detection and parsing share the
+ * same format rules, so the chip the user sees matches what gets stored.
+ */
+export function parseTranscript(rawText: string): ParseResult {
+ const detected = detectFormat(rawText);
+ const text = normalize(rawText);
+ const trimmed = text.trim();
+ if (!trimmed) {
+ return { ...detected, segments: [] };
+ }
+
+ let segments: ParsedSegment[];
+ switch (detected.format) {
+ case "srt":
+ case "vtt":
+ segments = parseSrtVtt(text, detected.format);
+ break;
+ case "riverside":
+ segments = parseSpeakerTimestamped(text, PAREN_LINE, { time: 2, speaker: 1, text: 3 });
+ break;
+ case "otter":
+ segments = parseSpeakerTimestamped(text, BRACKET_LINE, { time: 1, speaker: 2, text: 3 });
+ break;
+ case "descript":
+ segments = parseSpeakerOnly(text);
+ break;
+ default:
+ segments = parsePlain(text);
+ }
+
+ // The chip must not promise what parsing did not deliver (e.g. a single
+ // "(00:12)" line in an otherwise plain text block).
+ const hasTimestamps = segments.some((s) => s.startMs !== null);
+ const hasSpeakers = segments.some((s) => s.speaker !== null);
+ return {
+ format: detected.format,
+ hasTimestamps,
+ hasSpeakers,
+ chip: chipFor(LABELS[detected.format], hasTimestamps, hasSpeakers),
+ segments,
+ };
+}
diff --git a/web/package.json b/web/package.json
index aaa5a1c..98367a3 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"@clerk/nextjs": "^7.9.20",
"@neondatabase/serverless": "^1.2.0",
+ "@vercel/blob": "^2.8.2",
"class-variance-authority": "^0.7.1",
"cn": "^0.5.0",
"drizzle-orm": "^0.45.4",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 8087298..83366af 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -172,6 +172,9 @@ importers:
'@neondatabase/serverless':
specifier: ^1.2.0
version: 1.2.0
+ '@vercel/blob':
+ specifier: ^2.8.2
+ version: 2.8.2
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -2370,6 +2373,21 @@ packages:
cpu: [x64]
os: [win32]
+ '@vercel/blob@2.8.2':
+ resolution: {integrity: sha512-ZUUWO2VA00r9ZTRtKgH4va7FGOUxUtge2FaRr9WZmeeX2s3C3qVe2tOTLTbv/tkcd+L+ijxaI2eP9S1/mGHc9Q==}
+ engines: {node: '>=20.0.0'}
+
+ '@vercel/cli-config@0.3.1':
+ resolution: {integrity: sha512-sISbygqlFXYb8qfPu/+2Cf/A8IjrlAuZePdag3EDQr2PRA455s7UZtl9Ro674CWsB/saBRmz3jEkw0YsQs8OoQ==}
+
+ '@vercel/cli-exec@1.0.1':
+ resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==}
+ engines: {node: '>= 18'}
+
+ '@vercel/oidc@3.8.10':
+ resolution: {integrity: sha512-sPw1kIfQEA6/2dB+zlAJHnhhNBRJ8t/t4HfFvKO+g0EPBA7NbCKyGovuhgAV6Mnl865ka7HqCEguIy596czdAw==}
+ engines: {node: '>= 20'}
+
'@vitest/mocker@5.0.5':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
peerDependencies:
@@ -2494,6 +2512,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
+ async-retry@1.3.3:
+ resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
+
atomically@1.7.0:
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
engines: {node: '>=10.12.0'}
@@ -3418,6 +3439,10 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
+ is-buffer@2.0.5:
+ resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
+ engines: {node: '>=4'}
+
is-bun-module@2.0.0:
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
@@ -3488,6 +3513,9 @@ packages:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
+ is-node-process@1.2.0:
+ resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
+
is-number-object@1.1.1:
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
engines: {node: '>= 0.4'}
@@ -3593,6 +3621,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ jose@5.10.0:
+ resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
+
jose@6.2.13:
resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==}
@@ -4033,6 +4064,10 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
+ os-paths@4.4.0:
+ resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==}
+ engines: {node: '>= 6.0'}
+
own-keys@1.0.2:
resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
engines: {node: '>= 0.4'}
@@ -4268,6 +4303,10 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
+ retry@0.13.1:
+ resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
+ engines: {node: '>= 4'}
+
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -4522,6 +4561,10 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ throttleit@2.1.0:
+ resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
+ engines: {node: '>=18'}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4619,6 +4662,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici@6.29.1:
+ resolution: {integrity: sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==}
+ engines: {node: '>=18.17'}
+
undici@7.30.1:
resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==}
engines: {node: '>=20.18.1'}
@@ -4804,6 +4851,14 @@ packages:
resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==}
engines: {node: '>=20'}
+ xdg-app-paths@5.5.1:
+ resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==}
+ engines: {node: '>= 6.0'}
+
+ xdg-portable@7.3.0:
+ resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
+ engines: {node: '>= 6.0'}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4830,6 +4885,9 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
+ zod@4.1.11:
+ resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
+
zod@4.6.7:
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
@@ -6742,6 +6800,30 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
+ '@vercel/blob@2.8.2':
+ dependencies:
+ '@vercel/oidc': 3.8.10
+ async-retry: 1.3.3
+ is-buffer: 2.0.5
+ is-node-process: 1.2.0
+ throttleit: 2.1.0
+ undici: 6.29.1
+
+ '@vercel/cli-config@0.3.1':
+ dependencies:
+ xdg-app-paths: 5.5.1
+ zod: 4.1.11
+
+ '@vercel/cli-exec@1.0.1':
+ dependencies:
+ execa: 5.1.1
+
+ '@vercel/oidc@3.8.10':
+ dependencies:
+ '@vercel/cli-config': 0.3.1
+ '@vercel/cli-exec': 1.0.1
+ jose: 5.10.0
+
'@vitest/mocker@5.0.5(vite@8.3.5(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -6881,6 +6963,10 @@ snapshots:
async-function@1.0.0: {}
+ async-retry@1.3.3:
+ dependencies:
+ retry: 0.13.1
+
atomically@1.7.0: {}
available-typed-arrays@1.0.7:
@@ -7927,6 +8013,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-buffer@2.0.5: {}
+
is-bun-module@2.0.0:
dependencies:
semver: 7.8.5
@@ -7986,6 +8074,8 @@ snapshots:
is-negative-zero@2.0.3: {}
+ is-node-process@1.2.0: {}
+
is-number-object@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -8075,6 +8165,8 @@ snapshots:
jiti@2.7.0: {}
+ jose@5.10.0: {}
+
jose@6.2.13: {}
js-cookie@3.0.8: {}
@@ -8464,6 +8556,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
+ os-paths@4.4.0: {}
+
own-keys@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -8737,6 +8831,8 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
+ retry@0.13.1: {}
+
reusify@1.1.0: {}
rolldown@1.2.15:
@@ -9103,6 +9199,8 @@ snapshots:
tapable@2.3.3: {}
+ throttleit@2.1.0: {}
+
tiny-invariant@1.3.3: {}
tinybench@6.2.2: {}
@@ -9218,6 +9316,8 @@ snapshots:
undici-types@6.21.0: {}
+ undici@6.29.1: {}
+
undici@7.30.1: {}
unicorn-magic@0.3.0: {}
@@ -9379,6 +9479,15 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.2.1
+ xdg-app-paths@5.5.1:
+ dependencies:
+ os-paths: 4.4.0
+ xdg-portable: 7.3.0
+
+ xdg-portable@7.3.0:
+ dependencies:
+ os-paths: 4.4.0
+
yallist@3.1.1: {}
yocto-queue@0.1.0: {}
@@ -9397,4 +9506,6 @@ snapshots:
dependencies:
zod: 4.6.7
+ zod@4.1.11: {}
+
zod@4.6.7: {}
diff --git a/web/public/samples/demo-episode.txt b/web/public/samples/demo-episode.txt
new file mode 100644
index 0000000..251e68e
--- /dev/null
+++ b/web/public/samples/demo-episode.txt
@@ -0,0 +1,58 @@
+Welcome back to the show. Today I want to talk about focus, because focus is the
+most expensive resource in modern work and almost nobody manages it like money.
+We budget our dollars, we calendar our hours, and then we let anyone with a
+notification spend our attention for free.
+
+Here is the uncomfortable truth about deep work: it is not a personality trait,
+it is an environment you build. Every one of us knows the version of ourselves
+that opens a document and actually finishes it, and we all know the version that
+checks one message and surfaces forty minutes later wondering where the morning
+went. The difference between those two people is rarely discipline; it is
+friction. Notice how the distracted version of you is never one decision deep —
+you made one small permissive choice and the environment did the rest.
+
+Because attention compounds, the cost of an interruption is not the five
+minutes it takes. The research on task switching says it can take over twenty
+minutes to get back to full depth, which means a single ping can quietly tax
+the entire morning. Remember that the next time someone says it will only take
+a second: seconds are how attention debt is issued, and the interest rate is
+brutal.
+
+So what actually works? The key is deciding before the day starts. Calendars
+beat to-do lists because a to-do item is a wish while a calendar block is a
+promise. Imagine looking at your Tuesday and seeing two ninety-minute blocks
+with your name on them, notifications off, one tab open; for most people that
+single change doubles their output before lunch, because output was never the
+problem — protected time was.
+
+And let us be honest about the tools, because this is the part nobody wants to
+hear. Every app that promises to organize your attention is also competing for
+it. Therefore the real upgrade is usually subtraction: fewer inputs, fewer
+tabs, fewer meetings that could have been a paragraph. The professional with
+three tools and a clear calendar will out-ship the one with thirty tools and a
+badge for organizing them all.
+
+There is also a social layer we skip. Focus is treated as a personal virtue,
+which makes it feel rude to defend; so we let the loudest agenda in the room
+spend everyone's attention. Boundaries are the fix, and boundaries sound like
+scripts: I am offline until noon; send it and I will get there this afternoon;
+let us make this a document instead of a meeting. Saying them out loud once
+costs less than resenting the interruption for a month.
+
+So here is the challenge for this week. Pick one ninety-minute block, put it on
+the calendar, silence everything, and work on the single thing that has been
+sitting on your list the longest. Notice how it feels; most people tell me it
+feels like getting a whole extra day back. Then keep the block. The version of
+your week with protected focus in it is the version that compounds.
+
+Because here is the takeaway I want you to carry out of this episode: your
+attention is the budget everything else is bought with. Spend it like it is
+yours, because it is.
+
+Thanks for listening; show notes and the full transcript are on the site, and
+if this helped, send it to the person who keeps pinging you at nine in the
+morning.
+
+---
+License: CC0 (no rights reserved). Original text written as a deterministic demo
+input for EpisodeKit — you may freely reuse, modify, and republish it.
diff --git a/web/tests/e2e/new-episode.spec.ts b/web/tests/e2e/new-episode.spec.ts
new file mode 100644
index 0000000..f1b9fed
--- /dev/null
+++ b/web/tests/e2e/new-episode.spec.ts
@@ -0,0 +1,123 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { expect, test } from "@playwright/test";
+
+/**
+ * WP-05 e2e — keyless (no Clerk keys / DATABASE_URL / BLOB token here):
+ * MK-4 intake renders; the U5 chip appears for a pasted Riverside transcript;
+ * the sample button fills the paste tab; U4/U19 resume-status paths render
+ * from the server status read path (network-intercepted — the real DB-backed
+ * status route is covered by unit tests at the @/db/queries boundary).
+ */
+
+const riverside = [
+ "Maya (00:00:00): Welcome back to the show. Today we talk about focus.",
+ "Dev (00:00:12): Glad to be here. Focus is the most expensive resource in modern work.",
+ "Maya (00:01:03): We budget our dollars, we calendar our hours.",
+].join("\n");
+
+test("/app/episodes/new renders the MK-4 intake: tabs, drop zone, details", async ({ page }) => {
+ await page.goto("/app/episodes/new");
+ await expect(page.getByRole("heading", { name: "New episode" })).toBeVisible();
+ await expect(page.getByTestId("tab-upload")).toContainText("Upload audio");
+ await expect(page.getByTestId("tab-paste")).toContainText("Paste transcript");
+ await expect(page.getByTestId("drop-zone")).toContainText("Drop audio or video, or browse");
+ await expect(page.getByTestId("drop-zone")).toContainText("up to 2 hours");
+ await expect(page.getByTestId("sample-button")).toBeVisible();
+ await expect(page.getByTestId("episode-title")).toBeVisible();
+ await expect(page.getByTestId("add-guest")).toBeVisible();
+ await expect(page.getByTestId("create-button")).toBeDisabled();
+});
+
+test("U5: pasting a Riverside transcript shows the exact format chip", async ({ page }) => {
+ await page.goto("/app/episodes/new");
+ await page.getByTestId("tab-paste").click();
+ await page.getByTestId("paste-textarea").fill(riverside);
+ await expect(page.getByTestId("format-chip")).toHaveText(
+ "Riverside · speakers and timestamps found",
+ );
+});
+
+test("paste tab chip reflects plain text and SRT variants", async ({ page }) => {
+ await page.goto("/app/episodes/new");
+ await page.getByTestId("tab-paste").click();
+ await page.getByTestId("paste-textarea").fill("Just a plain paragraph about focus.\n\nAnother one.");
+ await expect(page.getByTestId("format-chip")).toHaveText("Plain text");
+
+ await page.getByTestId("paste-textarea").fill(
+ ["1", "00:00:00,000 --> 00:00:04,000", "Hello there.", ""].join("\n"),
+ );
+ await expect(page.getByTestId("format-chip")).toHaveText("SRT · timestamps found");
+});
+
+test("'Try with a sample episode' fills the paste tab and marks the free sample", async ({ page }) => {
+ await page.goto("/app/episodes/new");
+ await page.getByTestId("sample-button").click();
+ const sample = readFileSync(join(process.cwd(), "public/samples/demo-episode.txt"), "utf8");
+ await expect(page.getByTestId("paste-textarea")).toHaveValue(sample); // raw bytes incl. trailing newline
+ await expect(page.getByTestId("format-chip")).toHaveText("Plain text");
+ await expect(page.getByText("The sample is free")).toBeVisible();
+});
+
+test("U4: ?id= resumes from server status (transcribing)", async ({ page }) => {
+ await page.route("**/api/episodes/ep-test-1", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ episode: {
+ id: "ep-test-1",
+ title: "Ep 42",
+ number: 42,
+ source: "upload",
+ status: "transcribing",
+ errorCode: null,
+ durationMs: null,
+ hasTimestamps: false,
+ hasSpeakers: false,
+ countsQuota: true,
+ },
+ }),
+ });
+ });
+ await page.goto("/app/episodes/new?id=ep-test-1");
+ await expect(page.getByTestId("status-pill")).toContainText("Transcribing");
+});
+
+test("U19: failed unsupported-language episode shows the exact copy", async ({ page }) => {
+ await page.route("**/api/episodes/ep-test-2", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ episode: {
+ id: "ep-test-2",
+ title: null,
+ number: null,
+ source: "upload",
+ status: "failed",
+ errorCode: "unsupported_language",
+ durationMs: null,
+ hasTimestamps: false,
+ hasSpeakers: false,
+ countsQuota: false,
+ },
+ }),
+ });
+ });
+ await page.goto("/app/episodes/new?id=ep-test-2");
+ await expect(page.getByTestId("status-pill")).toContainText("Failed");
+ await expect(page.getByTestId("status-error")).toHaveText(
+ "EpisodeKit works with English episodes for now. This one wasn't charged.",
+ );
+});
+
+test("non-audio file selection shows the type error", async ({ page }) => {
+ await page.goto("/app/episodes/new");
+ await page.getByTestId("file-input").setInputFiles({
+ name: "notes.txt",
+ mimeType: "text/plain",
+ buffer: Buffer.from("not audio"),
+ });
+ await expect(page.getByTestId("intake-error")).toContainText("That file type isn't supported");
+});
diff --git a/web/tests/fixtures/transcripts/descript.txt b/web/tests/fixtures/transcripts/descript.txt
new file mode 100644
index 0000000..879ba36
--- /dev/null
+++ b/web/tests/fixtures/transcripts/descript.txt
@@ -0,0 +1,3 @@
+Maya: Welcome back to the show.
+Dev: Glad to be here. Focus is the most expensive resource in modern work.
+Maya: We budget our dollars, we calendar our hours.
diff --git a/web/tests/fixtures/transcripts/otter.txt b/web/tests/fixtures/transcripts/otter.txt
new file mode 100644
index 0000000..f4a495c
--- /dev/null
+++ b/web/tests/fixtures/transcripts/otter.txt
@@ -0,0 +1,3 @@
+[00:00:00] Maya: Welcome back to the show.
+[00:00:12] Dev: Glad to be here.
+[00:01:03] Maya: We budget our dollars, we calendar our hours.
diff --git a/web/tests/fixtures/transcripts/plain.txt b/web/tests/fixtures/transcripts/plain.txt
new file mode 100644
index 0000000..49305b3
--- /dev/null
+++ b/web/tests/fixtures/transcripts/plain.txt
@@ -0,0 +1,6 @@
+Welcome back to the show. Today I want to talk about focus, because focus is the
+most expensive resource in modern work and almost nobody manages it like money.
+
+Here is the uncomfortable truth about deep work: it is not a personality trait,
+it is an environment you build. The difference between those two people is
+rarely discipline; it is friction.
diff --git a/web/tests/fixtures/transcripts/riverside-crlf.txt b/web/tests/fixtures/transcripts/riverside-crlf.txt
new file mode 100644
index 0000000..80b3601
--- /dev/null
+++ b/web/tests/fixtures/transcripts/riverside-crlf.txt
@@ -0,0 +1,3 @@
+Maya (00:00:00): Welcome back to the show.
+Dev (00:00:12): Glad to be here.
+Maya (00:01:03): We budget our dollars.
diff --git a/web/tests/fixtures/transcripts/riverside.txt b/web/tests/fixtures/transcripts/riverside.txt
new file mode 100644
index 0000000..09c1297
--- /dev/null
+++ b/web/tests/fixtures/transcripts/riverside.txt
@@ -0,0 +1,3 @@
+Maya (00:00:00): Welcome back to the show. Today we talk about focus.
+Dev (00:00:12): Glad to be here. Focus is the most expensive resource in modern work.
+Maya (00:01:03): We budget our dollars, we calendar our hours.
diff --git a/web/tests/fixtures/transcripts/srt-unsorted.txt b/web/tests/fixtures/transcripts/srt-unsorted.txt
new file mode 100644
index 0000000..b849eca
--- /dev/null
+++ b/web/tests/fixtures/transcripts/srt-unsorted.txt
@@ -0,0 +1,7 @@
+2
+00:00:41,000 --> 00:00:45,300
+We budget our dollars, we calendar our hours.
+
+1
+00:00:00,000 --> 00:00:04,120
+Welcome back to the show.
diff --git a/web/tests/fixtures/transcripts/srt.txt b/web/tests/fixtures/transcripts/srt.txt
new file mode 100644
index 0000000..6719d42
--- /dev/null
+++ b/web/tests/fixtures/transcripts/srt.txt
@@ -0,0 +1,11 @@
+1
+00:00:00,000 --> 00:00:04,120
+Welcome back to the show.
+
+2
+00:00:04,120 --> 00:00:09,480
+Today we talk about focus, the most expensive resource in modern work.
+
+3
+00:00:41,000 --> 00:00:45,300
+We budget our dollars, we calendar our hours.
diff --git a/web/tests/fixtures/transcripts/vtt.txt b/web/tests/fixtures/transcripts/vtt.txt
new file mode 100644
index 0000000..276ca27
--- /dev/null
+++ b/web/tests/fixtures/transcripts/vtt.txt
@@ -0,0 +1,11 @@
+WEBVTT
+
+00:00:00.000 --> 00:00:04.120
+Welcome back to the show.
+
+00:00:04.120 --> 00:00:09.480
+Today we talk about focus, the most expensive resource in modern work.
+
+maya
+00:01:12.500 --> 00:01:16.000
+Almost nobody manages it like money.
diff --git a/web/tests/unit/asr-fake.test.ts b/web/tests/unit/asr-fake.test.ts
new file mode 100644
index 0000000..c1be464
--- /dev/null
+++ b/web/tests/unit/asr-fake.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { fakeTranscribe } from "@/lib/asr/fake";
+
+describe("fakeTranscribe (in-memory adapter for tests)", () => {
+ it("returns deterministic segments with absolute timestamps", () => {
+ const segments = fakeTranscribe([
+ { blobPath: "a.webm", offsetMs: 0 },
+ { blobPath: "b.webm", offsetMs: 598_000 },
+ ]);
+ expect(segments.map((s) => s.text)).toEqual([
+ "Chunk 1 transcript line.",
+ "Chunk 2 transcript line.",
+ ]);
+ expect(segments[1].startMs).toBe(598_500);
+ expect(segments.map((s) => s.id)).toEqual(["seg_000000", "seg_000001"]);
+ });
+
+ it("accepts a scripted line list per chunk", () => {
+ const segments = fakeTranscribe([{ blobPath: "a.webm", offsetMs: 0 }], {
+ linesPerChunk: [["One.", "Two.", "Three."]],
+ });
+ expect(segments.map((s) => s.text)).toEqual(["One.", "Two.", "Three."]);
+ });
+
+ it("returns empty for no chunks", () => {
+ expect(fakeTranscribe([])).toEqual([]);
+ });
+});
diff --git a/web/tests/unit/asr-groq.test.ts b/web/tests/unit/asr-groq.test.ts
new file mode 100644
index 0000000..1df83bd
--- /dev/null
+++ b/web/tests/unit/asr-groq.test.ts
@@ -0,0 +1,143 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AsrError, GROQ_MODEL, transcribeWithGroq } from "@/lib/asr/groq";
+
+const CHUNKS = [
+ { blobPath: "episodes/e1/chunk-0.webm", offsetMs: 0 },
+ { blobPath: "episodes/e1/chunk-1.webm", offsetMs: 598_000 },
+];
+
+function groqResponse(segments: { start: number; end: number; text: string }[], language = "english") {
+ return {
+ text: segments.map((s) => s.text).join(" "),
+ language,
+ duration: segments.at(-1)?.end ?? 0,
+ segments,
+ };
+}
+
+/** Fetch mock: map URL → JSON body; tracks in-flight count for concurrency. */
+function mockFetch(responses: Record, opts: { status?: number; trackConcurrency?: boolean } = {}) {
+ let inFlight = 0;
+ let maxInFlight = 0;
+ const calls: { url: string; form: FormData }[] = [];
+ const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
+ const form = init?.body as FormData;
+ calls.push({ url: String(input), form });
+ if (opts.trackConcurrency) {
+ inFlight += 1;
+ maxInFlight = Math.max(maxInFlight, inFlight);
+ await new Promise((r) => setTimeout(r, 5));
+ inFlight -= 1;
+ }
+ const filename = (form.get("file") as File | null)?.name ?? "";
+ const key = Object.keys(responses).find((k) => filename.includes(k));
+ const body = key ? responses[key] : Object.values(responses)[0];
+ return new Response(JSON.stringify(body), {
+ status: opts.status ?? 200,
+ headers: { "content-type": "application/json" },
+ });
+ });
+ return { fetchImpl, calls, maxInFlight: () => maxInFlight };
+}
+
+const fetchChunk = async (blobPath: string) => ({
+ body: new ArrayBuffer(8),
+ contentType: "audio/webm",
+ filename: blobPath.split("/").pop() ?? "chunk.webm",
+});
+
+afterEach(() => {
+ delete process.env.ASR_API_KEY;
+ delete process.env.GROQ_API_KEY;
+ delete process.env.ASR_PROVIDER;
+});
+
+describe("transcribeWithGroq", () => {
+ it("posts verbose_json with segment granularity, language=en and the model", async () => {
+ const { fetchImpl, calls } = mockFetch({ "audio/transcriptions": groqResponse([{ start: 0, end: 2, text: "Hello." }]) });
+ const segments = await transcribeWithGroq([CHUNKS[0]], { vocab: [] }, { fetchImpl, fetchChunk, apiKey: "k" });
+ expect(segments).toHaveLength(1);
+ expect(calls).toHaveLength(1);
+ expect(calls[0].url).toBe("https://api.groq.com/openai/v1/audio/transcriptions");
+ const form = calls[0].form;
+ expect(form.get("model")).toBe(GROQ_MODEL);
+ expect(GROQ_MODEL).toBe("whisper-large-v3-turbo");
+ expect(form.get("response_format")).toBe("verbose_json");
+ expect(form.get("timestamp_granularities[]")).toBe("segment");
+ expect(form.get("language")).toBe("en");
+ const file = form.get("file");
+ expect(file).toBeInstanceOf(File);
+ expect((file as File).name).toBe("chunk-0.webm");
+ });
+
+ it("builds the prompt from vocabulary (≤200 tokens ≈ 800 chars)", async () => {
+ const { fetchImpl, calls } = mockFetch({ "audio/transcriptions": groqResponse([{ start: 0, end: 1, text: "Hi." }]) });
+ const vocab = Array.from({ length: 400 }, (_, i) => `term${i}-with-a-long-name`);
+ await transcribeWithGroq([CHUNKS[0]], { vocab }, { fetchImpl, fetchChunk, apiKey: "k" });
+ const prompt = String(calls[0].form.get("prompt"));
+ expect(prompt.startsWith("term0-with-a-long-name")).toBe(true);
+ expect(prompt.length).toBeLessThanOrEqual(800);
+ });
+
+ it("merges chunk results in order with offsets (concurrency 3)", async () => {
+ const { fetchImpl, maxInFlight } = mockFetch(
+ {
+ "chunk-0": groqResponse([{ start: 0, end: 4, text: "First." }]),
+ "chunk-1": groqResponse([{ start: 2.5, end: 6, text: "Second." }]),
+ "c2": groqResponse([{ start: 1, end: 5, text: "Third." }]),
+ "c3": groqResponse([{ start: 2, end: 4, text: "Fourth." }]),
+ },
+ { trackConcurrency: true },
+ );
+ const segments = await transcribeWithGroq(
+ [CHUNKS[0], CHUNKS[1], { blobPath: "c2", offsetMs: 1_196_000 }, { blobPath: "c3", offsetMs: 1_794_000 }],
+ { vocab: [] },
+ { fetchImpl, fetchChunk, apiKey: "k" },
+ );
+ expect(maxInFlight()).toBeLessThanOrEqual(3);
+ expect(segments.map((s) => s.text)).toEqual(["First.", "Second.", "Third.", "Fourth."]);
+ expect(segments[1].startMs).toBe(600_500);
+ expect(segments[3].startMs).toBe(1_796_000);
+ });
+
+ it("throws unsupported_language when the whisper language is not English", async () => {
+ const { fetchImpl } = mockFetch({ "audio/transcriptions": groqResponse([{ start: 0, end: 1, text: "こんにちは" }], "japanese") });
+ await expect(
+ transcribeWithGroq(CHUNKS, { vocab: [] }, { fetchImpl, fetchChunk, apiKey: "k" }),
+ ).rejects.toMatchObject({ code: "unsupported_language", status: 422 });
+ });
+
+ it("throws unsupported_language when the text fails the Latin ratio", async () => {
+ const { fetchImpl } = mockFetch({ "audio/transcriptions": groqResponse([{ start: 0, end: 1, text: "Привет привет привет привет" }], "english") });
+ await expect(
+ transcribeWithGroq(CHUNKS, { vocab: [] }, { fetchImpl, fetchChunk, apiKey: "k" }),
+ ).rejects.toMatchObject({ code: "unsupported_language" });
+ });
+
+ it("maps a Groq error response to AsrError groq_", async () => {
+ const { fetchImpl } = mockFetch({ "audio/transcriptions": { error: { message: "bad key" } } }, { status: 401 });
+ await expect(
+ transcribeWithGroq(CHUNKS, { vocab: [] }, { fetchImpl, fetchChunk, apiKey: "k" }),
+ ).rejects.toMatchObject({ code: "groq_401", status: 502 });
+ });
+
+ it("throws empty_transcript when the chunk has no segments (no_speech)", async () => {
+ const { fetchImpl } = mockFetch({ "audio/transcriptions": groqResponse([], "english") });
+ await expect(
+ transcribeWithGroq(CHUNKS, { vocab: [] }, { fetchImpl, fetchChunk, apiKey: "k" }),
+ ).rejects.toMatchObject({ code: "empty_transcript", status: 422 });
+ });
+
+ it("throws not_configured without a key", async () => {
+ await expect(
+ transcribeWithGroq(CHUNKS, { vocab: [] }, { fetchImpl: mockFetch({}).fetchImpl, fetchChunk }),
+ ).rejects.toBeInstanceOf(AsrError);
+ });
+
+ it("accepts ASR_API_KEY/GROQ_API_KEY from env when no apiKey option", async () => {
+ process.env.GROQ_API_KEY = "env-key";
+ const { fetchImpl, calls } = mockFetch({ "audio/transcriptions": groqResponse([{ start: 0, end: 1, text: "Hi." }]) });
+ await transcribeWithGroq(CHUNKS, { vocab: [] }, { fetchImpl, fetchChunk });
+ expect(calls[0].form).toBeInstanceOf(FormData); // request attempted with env key
+ });
+});
diff --git a/web/tests/unit/asr-language.test.ts b/web/tests/unit/asr-language.test.ts
new file mode 100644
index 0000000..e8c559f
--- /dev/null
+++ b/web/tests/unit/asr-language.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { isSupportedAsrLanguage, supportedInput } from "@/lib/asr/language";
+
+// supportedInput is ported from api/generate-suite.js: Latin ratio < 0.6 fails.
+describe("supportedInput (ported)", () => {
+ it("accepts English text", () => {
+ expect(supportedInput("Welcome back to the show, focus is a budget.")).toBe(true);
+ });
+
+ it("fails when the Latin ratio is below 0.6 (U19)", () => {
+ expect(supportedInput("これは日本語のテキストです。文字化けではなく、意図的に日本語で書かれています。")).toBe(false);
+ expect(supportedInput("Привет, это русский текст для проверки проверки проверки")).toBe(false);
+ });
+
+ it("accepts mixed text as long as Latin letters are >= 60%", () => {
+ expect(supportedInput("hello world with one 中文 word")).toBe(true);
+ });
+
+ it("fails text with no letters at all", () => {
+ expect(supportedInput("123 456 !!! ---")).toBe(false);
+ expect(supportedInput("")).toBe(false);
+ });
+});
+
+// ASR results: the Whisper `language` field AND the Latin ratio both gate.
+describe("isSupportedAsrLanguage", () => {
+ it("accepts english/en whisper field with English text", () => {
+ expect(isSupportedAsrLanguage("english", "Hello there, welcome back.")).toBe(true);
+ expect(isSupportedAsrLanguage("en", "Hello there, welcome back.")).toBe(true);
+ });
+
+ it("fails a non-English whisper field regardless of text", () => {
+ expect(isSupportedAsrLanguage("dutch", "Hello there, welcome back.")).toBe(false);
+ expect(isSupportedAsrLanguage("japanese", "こんにちは")).toBe(false);
+ });
+
+ it("fails a missing whisper field when the text is not Latin enough", () => {
+ expect(isSupportedAsrLanguage(undefined, "これは日本語です")).toBe(false);
+ });
+
+ it("fails English field with non-Latin text (misdetected audio)", () => {
+ expect(isSupportedAsrLanguage("english", "これは日本語です")).toBe(false);
+ });
+});
diff --git a/web/tests/unit/asr-merge.test.ts b/web/tests/unit/asr-merge.test.ts
new file mode 100644
index 0000000..57cf5a3
--- /dev/null
+++ b/web/tests/unit/asr-merge.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import { mergeChunks, mergeSentenceGroups, OVERLAP_MS } from "@/lib/asr/merge";
+
+interface Raw {
+ start: number; // seconds, chunk-relative
+ end: number;
+ text: string;
+}
+
+const chunk = (offsetMs: number, segments: Raw[]) => ({ offsetMs, segments });
+
+const sec = (s: number) => s * 1000;
+
+describe("mergeChunks — overlap dedupe by timestamp", () => {
+ it("offsets chunk-relative times to absolute ms", () => {
+ const merged = mergeChunks([
+ chunk(0, [{ start: 0, end: 4.1, text: "Hello" }]),
+ chunk(sec(598), [{ start: 2.5, end: 6, text: "World" }]),
+ ]);
+ expect(merged.map((s) => [s.startMs, s.endMs])).toEqual([
+ [0, 4100],
+ [sec(600.5), sec(604)],
+ ]);
+ });
+
+ it("drops later-chunk segments inside the 2 s overlap window", () => {
+ // Chunk 2 starts at 598 s; its first 2 s duplicate chunk 1's tail.
+ const merged = mergeChunks([
+ chunk(0, [
+ { start: 596, end: 600, text: "tail of chunk one." },
+ ]),
+ chunk(sec(598), [
+ { start: 0.5, end: 1.9, text: "tail of chunk one." }, // duplicate in overlap
+ { start: 2.4, end: 5, text: "fresh content." },
+ ]),
+ ]);
+ expect(merged.map((s) => s.text)).toEqual(["tail of chunk one.", "fresh content."]);
+ expect(merged.every((s) => !s.text.includes("duplicate") || s.text === "tail of chunk one")).toBe(true);
+ });
+
+ it("keeps overlap-window segments when the previous chunk has no coverage there", () => {
+ // Previous chunk's transcription ended early (no segment near the seam):
+ // dropping would lose words, so the overlap segment survives.
+ const merged = mergeChunks([
+ chunk(0, [{ start: 0, end: 500, text: "early." }]),
+ chunk(sec(598), [{ start: 0.5, end: 1.9, text: "seam words." }, { start: 3, end: 6, text: "next." }]),
+ ]);
+ expect(merged.map((s) => s.text)).toEqual(["early.", "seam words.", "next."]);
+ });
+
+ it("exposes the 2 s overlap constant", () => {
+ expect(OVERLAP_MS).toBe(2000);
+ });
+});
+
+describe("mergeSentenceGroups — ≤45 s / ≤500 chars", () => {
+ const seg = (startMs: number, endMs: number, text: string) => ({
+ id: `seg_${String(startMs).padStart(6, "0")}`,
+ idx: 0,
+ speaker: null,
+ startMs,
+ endMs,
+ text,
+ });
+
+ it("groups segments up to sentence-ending punctuation", () => {
+ const groups = mergeSentenceGroups([
+ seg(0, 1000, "Hello there,"),
+ seg(1000, 2000, "welcome back."),
+ seg(2000, 3000, "New thought starts"),
+ ]);
+ expect(groups.map((g) => g.text)).toEqual(["Hello there, welcome back.", "New thought starts"]);
+ });
+
+ it("flushes a group before exceeding 45 s of span", () => {
+ const groups = mergeSentenceGroups([
+ seg(0, 20_000, "A sentence without an end"),
+ seg(20_000, 44_000, "still going"),
+ seg(44_000, 50_000, "this one pushes past the limit."),
+ ]);
+ // Adding the third would make the span 50 s > 45 s, so it flushes first.
+ expect(groups.map((g) => g.text)).toEqual([
+ "A sentence without an end still going",
+ "this one pushes past the limit.",
+ ]);
+ });
+
+ it("flushes a group before exceeding 500 chars", () => {
+ const long = "word ".repeat(120).trim(); // 600 chars, no sentence end
+ const groups = mergeSentenceGroups([seg(0, 1000, long), seg(1000, 2000, "tail")]);
+ expect(groups).toHaveLength(2);
+ expect(groups[0].text).toBe(long);
+ expect(groups[1].text).toBe("tail");
+ });
+
+ it("flushes a group when adding the next segment alone crosses 500 chars", () => {
+ const long = "w".repeat(499);
+ const groups = mergeSentenceGroups([seg(0, 1000, long), seg(1000, 2000, "plus one more")]);
+ expect(groups).toHaveLength(2);
+ });
+
+ it("renumbers ids and idx sequentially", () => {
+ const groups = mergeSentenceGroups([
+ seg(0, 1000, "One."),
+ seg(1000, 2000, "Two."),
+ seg(2000, 3000, "Three."),
+ ]);
+ expect(groups.map((g) => g.id)).toEqual(["seg_000000", "seg_000001", "seg_000002"]);
+ expect(groups.map((g) => g.idx)).toEqual([0, 1, 2]);
+ });
+
+ it("keeps null timestamps (paste path) grouped by sentence only", () => {
+ const groups = mergeSentenceGroups([
+ { id: "seg_0", idx: 0, speaker: null, startMs: null, endMs: null, text: "One." },
+ { id: "seg_1", idx: 1, speaker: null, startMs: null, endMs: null, text: "Two." },
+ ]);
+ // No timestamps → span checks skip; sentence ends flush each.
+ expect(groups.map((g) => g.text)).toEqual(["One.", "Two."]);
+ });
+
+ it("preserves the speaker of the first segment in a group", () => {
+ const groups = mergeSentenceGroups([
+ { id: "seg_0", idx: 0, speaker: "Maya", startMs: null, endMs: null, text: "One," },
+ { id: "seg_1", idx: 1, speaker: "Dev", startMs: null, endMs: null, text: "two." },
+ ]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0].speaker).toBe("Maya");
+ expect(groups[0].text).toBe("One, two.");
+ });
+});
diff --git a/web/tests/unit/compression.test.ts b/web/tests/unit/compression.test.ts
new file mode 100644
index 0000000..809b361
--- /dev/null
+++ b/web/tests/unit/compression.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import {
+ BITRATE_BPS,
+ CHUNK_SEC,
+ MAX_DURATION_SEC,
+ OVERLAP_SEC,
+ SAMPLE_RATE,
+ chooseMimeType,
+ estimateChunkBytes,
+ planChunks,
+} from "@/lib/compress/plan";
+
+// Pure decision functions ported from site/app.html (decode → resample 16 kHz
+// mono → Opus re-encode → ≤10-min chunks with 2 s overlap).
+describe("compression plan (ported from site/app.html)", () => {
+ it("keeps the ported constants", () => {
+ expect(SAMPLE_RATE).toBe(16_000);
+ expect(BITRATE_BPS).toBe(24_000); // ~24 kbps → ~1.8 MB per 10-min chunk
+ expect(CHUNK_SEC).toBe(600);
+ expect(OVERLAP_SEC).toBe(2);
+ expect(MAX_DURATION_SEC).toBe(2 * 3600); // ≤2 h for MVP
+ });
+
+ it("plans one chunk for a short episode", () => {
+ const chunks = planChunks(300);
+ expect(chunks).toEqual([{ index: 0, startSec: 0, endSec: 300, offsetMs: 0, hasOverlapPrefix: false }]);
+ });
+
+ it("plans ≤10-min chunks with a 2 s overlap prefix", () => {
+ const chunks = planChunks(1200); // 20 min
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]).toMatchObject({ startSec: 0, endSec: 600, offsetMs: 0 });
+ expect(chunks[1]).toMatchObject({ startSec: 598, endSec: 1200, offsetMs: 598_000, hasOverlapPrefix: true });
+ // Absorbing the 2 s trailing stub may extend a chunk by ≤ OVERLAP_SEC.
+ expect(chunks[1].endSec - chunks[1].startSec).toBeLessThanOrEqual(CHUNK_SEC + OVERLAP_SEC);
+ });
+
+ it("plans the last chunk without exceeding the duration", () => {
+ const chunks = planChunks(1300);
+ expect(chunks.at(-1)).toMatchObject({ startSec: 1196, endSec: 1300 });
+ expect(planChunks(1200.4)).toHaveLength(3);
+ });
+
+ it("estimates ~1.8 MB for a 10-minute chunk at 24 kbps", () => {
+ const bytes = estimateChunkBytes(CHUNK_SEC);
+ expect(bytes).toBe((600 * 24_000) / 8);
+ expect(bytes / 1e6).toBeCloseTo(1.8, 1);
+ });
+
+ it("prefers webm/opus, falls back to mp4, else null", () => {
+ expect(chooseMimeType((t) => t === "audio/webm;codecs=opus")).toEqual({
+ mimeType: "audio/webm;codecs=opus",
+ extension: "webm",
+ container: "audio/webm",
+ });
+ expect(chooseMimeType((t) => t === "audio/mp4")).toEqual({
+ mimeType: "audio/mp4",
+ extension: "mp4",
+ container: "audio/mp4",
+ });
+ expect(chooseMimeType(() => false)).toBeNull();
+ });
+});
diff --git a/web/tests/unit/episodes-service.test.ts b/web/tests/unit/episodes-service.test.ts
new file mode 100644
index 0000000..1f2eee6
--- /dev/null
+++ b/web/tests/unit/episodes-service.test.ts
@@ -0,0 +1,445 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * WP-05 route-handler tests at the documented boundaries:
+ * @/db/queries (WP-02 seam), @/lib/blob, @/lib/asr/groq. DB is "configured"
+ * via DATABASE_URL in these tests (labelled: mocked boundary mode, not a
+ * faked quota success — the entitlements stub counts what the mocked
+ * countQuotaEpisodes returns).
+ */
+vi.mock("@/db/queries", () => ({
+ getShowByOwner: vi.fn(),
+ getOrCreateWorkspaceByOwner: vi.fn(),
+ createEpisode: vi.fn(),
+ getEpisodeForUser: vi.fn(),
+ updateEpisode: vi.fn(),
+ replaceSegments: vi.fn(),
+ countQuotaEpisodes: vi.fn(),
+}));
+
+vi.mock("@/lib/asr/groq", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, transcribeWithGroq: vi.fn() };
+});
+
+vi.mock("@/lib/blob", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ issueAudioUrl: vi.fn(),
+ handleClientUpload: vi.fn(),
+ };
+});
+
+import * as queries from "@/db/queries";
+import { AsrError, transcribeWithGroq } from "@/lib/asr/groq";
+import { handleClientUpload, issueAudioUrl } from "@/lib/blob";
+import {
+ UNSUPPORTED_LANGUAGE_COPY,
+ NO_SPEECH_COPY,
+ SAVE_UNAVAILABLE_COPY,
+ UPLOAD_AUDIO_FIRST_COPY,
+} from "@/lib/copy";
+import {
+ handleAudioGet,
+ handleEpisodeGet,
+ handleEpisodesPost,
+ handleTranscribePost,
+ handleUploadsTokenPost,
+ mapAsrErrorCode,
+} from "@/lib/episodes/service";
+
+const mocked = vi.mocked(queries);
+const mockTranscribe = vi.mocked(transcribeWithGroq);
+const mockIssueAudioUrl = vi.mocked(issueAudioUrl);
+const mockHandleUpload = vi.mocked(handleClientUpload);
+
+const showRow = {
+ id: "show-1",
+ workspaceId: "ws-1",
+ name: "The Focus Hour",
+ rssUrl: null,
+ artworkUrl: null,
+ listenUrl: null,
+ defaultCta: null,
+ vocabulary: ["attention"],
+ voiceProfile: null,
+ voiceSamples: [],
+ createdAt: new Date(),
+};
+const workspaceRow = {
+ id: "ws-1",
+ ownerId: "u1",
+ plan: "free" as const,
+ planSource: null,
+ founding: false,
+ lsCustomerId: null,
+ lsSubscriptionId: null,
+ status: "active" as const,
+ currentPeriodEnd: null,
+ createdAt: new Date(),
+};
+const episodeRow = (overrides: Partial = {}): queries.EpisodeRow => ({
+ id: "ep-1",
+ showId: "show-1",
+ title: null,
+ number: null,
+ guests: null,
+ source: "upload",
+ audioBlobPath: null,
+ durationMs: null,
+ hasTimestamps: false,
+ hasSpeakers: false,
+ status: "uploading",
+ errorCode: null,
+ quotaMonth: "2026-10",
+ countsQuota: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+});
+
+beforeEach(() => {
+ process.env.DATABASE_URL = "postgres://test";
+ process.env.BLOB_READ_WRITE_TOKEN = "blob_test";
+ mocked.getShowByOwner.mockResolvedValue(showRow);
+ mocked.getOrCreateWorkspaceByOwner.mockResolvedValue(workspaceRow);
+ mocked.countQuotaEpisodes.mockResolvedValue(0);
+ mocked.createEpisode.mockImplementation(async (values) => episodeRow({ id: "ep-new", ...values }));
+ mocked.updateEpisode.mockImplementation(async (_id, values) =>
+ episodeRow({ ...values } as Partial),
+ );
+});
+
+afterEach(() => {
+ delete process.env.DATABASE_URL;
+ delete process.env.BLOB_READ_WRITE_TOKEN;
+ vi.clearAllMocks();
+});
+
+describe("handleEpisodesPost — create (§7.5)", () => {
+ it("returns 503 in keyless preview (no DATABASE_URL)", async () => {
+ delete process.env.DATABASE_URL;
+ const result = await handleEpisodesPost("u1", { source: "paste", transcriptText: "Hi." });
+ expect(result.status).toBe(503);
+ expect(result.json).toEqual({ error: SAVE_UNAVAILABLE_COPY });
+ });
+
+ it("creates a paste episode, parsing speakers + timestamps into segments", async () => {
+ const riverside = [
+ "Maya (00:00:00): Welcome back to the show.",
+ "Dev (00:00:12): Glad to be here.",
+ ].join("\n");
+ mocked.createEpisode.mockResolvedValue(episodeRow({ id: "ep-paste", status: "queued" }));
+
+ const result = await handleEpisodesPost("u1", { source: "paste", transcriptText: riverside });
+
+ expect(result.status).toBe(201);
+ expect(result.json).toEqual({ id: "ep-paste", status: "queued" });
+ expect(mocked.createEpisode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ source: "paste",
+ status: "queued",
+ hasTimestamps: true,
+ hasSpeakers: true,
+ countsQuota: true,
+ quotaMonth: expect.stringMatching(/^\d{4}-\d{2}$/),
+ }),
+ );
+ const segments = mocked.replaceSegments.mock.calls[0][1];
+ expect(segments).toHaveLength(2);
+ expect(segments[0]).toMatchObject({ id: "seg_000000", speaker: "Maya", startMs: 0 });
+ expect(segments[1]).toMatchObject({ id: "seg_000001", speaker: "Dev", startMs: 12_000 });
+ });
+
+ it("sample episodes never count against quota (M2)", async () => {
+ mocked.createEpisode.mockResolvedValue(episodeRow({ id: "ep-sample", status: "queued" }));
+ const result = await handleEpisodesPost("u1", {
+ source: "sample",
+ transcriptText: "A perfectly ordinary English transcript about focus and attention. ".repeat(3),
+ });
+ expect(result.status).toBe(201);
+ expect(mocked.createEpisode).toHaveBeenCalledWith(
+ expect.objectContaining({ source: "sample", countsQuota: false, quotaMonth: null }),
+ );
+ expect(mocked.countQuotaEpisodes).not.toHaveBeenCalled();
+ });
+
+ it("returns 402 with the §6 quota-wall copy when over quota", async () => {
+ mocked.countQuotaEpisodes.mockResolvedValue(2); // free limit
+ const result = await handleEpisodesPost("u1", { source: "paste", transcriptText: "Hello there. " });
+ expect(result.status).toBe(402);
+ expect(result.json).toEqual({
+ errorCode: "quota_exceeded",
+ error: "You've used 2 of 2 episodes this month. Upgrade for more, or wait until 2026-11-01.",
+ });
+ expect(mocked.createEpisode).not.toHaveBeenCalled();
+ });
+
+ it("rejects non-English paste with the exact U19 copy and creates nothing", async () => {
+ const result = await handleEpisodesPost("u1", {
+ source: "paste",
+ transcriptText: "これは日本語のテキストです。文字化けではなく、意図的に日本語で書かれています。",
+ });
+ expect(result.status).toBe(400);
+ expect(result.json).toEqual({ errorCode: "unsupported_language", error: UNSUPPORTED_LANGUAGE_COPY });
+ expect(mocked.createEpisode).not.toHaveBeenCalled();
+ });
+
+ it("serializes the chunk manifest from blobPaths + chunkOffsetsMs (§8.3)", async () => {
+ mocked.createEpisode.mockResolvedValue(episodeRow({ id: "ep-up", status: "uploading" }));
+ const result = await handleEpisodesPost("u1", {
+ source: "upload",
+ blobPaths: ["episodes/e/chunk-0.webm", "episodes/e/chunk-1.webm"],
+ chunkOffsetsMs: [0, 598000],
+ title: "Ep 42",
+ number: 42,
+ guests: ["Dev"],
+ });
+ expect(result.status).toBe(201);
+ expect(mocked.createEpisode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ source: "upload",
+ status: "uploading",
+ audioBlobPath: JSON.stringify([
+ { path: "episodes/e/chunk-0.webm", offsetMs: 0 },
+ { path: "episodes/e/chunk-1.webm", offsetMs: 598000 },
+ ]),
+ title: "Ep 42",
+ number: 42,
+ guests: ["Dev"],
+ }),
+ );
+ });
+
+ it("returns 404 when the user has no show yet", async () => {
+ mocked.getShowByOwner.mockResolvedValue(null);
+ const result = await handleEpisodesPost("u1", { source: "paste", transcriptText: "Hi." });
+ expect(result.status).toBe(404);
+ });
+
+ it("rejects an empty paste", async () => {
+ const result = await handleEpisodesPost("u1", { source: "paste", transcriptText: " " });
+ expect(result.status).toBe(400);
+ });
+});
+
+describe("handleTranscribePost — ASR orchestration (§8.3)", () => {
+ const manifest = JSON.stringify([
+ { path: "episodes/e/chunk-0.webm", offsetMs: 0 },
+ { path: "episodes/e/chunk-1.webm", offsetMs: 598000 },
+ ]);
+ const owned = (episode: queries.EpisodeRow) => {
+ mocked.getEpisodeForUser.mockResolvedValue({ episode, show: showRow, workspace: workspaceRow });
+ };
+
+ it("transitions uploading→queued→transcribing, stores merged segments, lands queued", async () => {
+ owned(episodeRow({ audioBlobPath: manifest, status: "uploading" }));
+ mockTranscribe.mockResolvedValue([
+ { id: "seg_000000", idx: 0, speaker: null, startMs: 0, endMs: 4000, text: "First." },
+ { id: "seg_000001", idx: 1, speaker: null, startMs: 600500, endMs: 604000, text: "Second." },
+ ]);
+
+ const result = await handleTranscribePost("u1", "ep-1", {});
+
+ expect(result.status).toBe(200);
+ expect(mockTranscribe).toHaveBeenCalledWith(
+ [
+ { blobPath: "episodes/e/chunk-0.webm", offsetMs: 0 },
+ { blobPath: "episodes/e/chunk-1.webm", offsetMs: 598000 },
+ ],
+ { vocab: ["attention"] },
+ );
+ // two visible status transitions before the ASR call
+ const statuses = mocked.updateEpisode.mock.calls.map((c) => (c[1] as { status?: string }).status);
+ expect(statuses).toEqual(["queued", "transcribing", "queued"]);
+ expect(mocked.replaceSegments).toHaveBeenCalledWith("ep-1", [
+ expect.objectContaining({ id: "seg_000000", startMs: 0 }),
+ expect.objectContaining({ id: "seg_000001", startMs: 600500 }),
+ ]);
+ expect(mocked.updateEpisode).toHaveBeenLastCalledWith("ep-1", {
+ status: "queued",
+ durationMs: 604000,
+ hasTimestamps: true,
+ hasSpeakers: false,
+ errorCode: null,
+ });
+ });
+
+ it("includes guest names in the ASR vocab prompt", async () => {
+ owned(episodeRow({ audioBlobPath: manifest, guests: ["Dev", "Maya"] }));
+ mockTranscribe.mockResolvedValue([
+ { id: "seg_000000", idx: 0, speaker: null, startMs: 0, endMs: 1000, text: "Hi." },
+ ]);
+ await handleTranscribePost("u1", "ep-1", {});
+ expect(mockTranscribe).toHaveBeenCalledWith(expect.anything(), { vocab: ["attention", "Dev", "Maya"] });
+ });
+
+ it("accepts the chunk manifest from the request body and persists it", async () => {
+ owned(episodeRow({ audioBlobPath: null, status: "uploading" }));
+ mockTranscribe.mockResolvedValue([
+ { id: "seg_000000", idx: 0, speaker: null, startMs: 0, endMs: 1000, text: "Hi." },
+ ]);
+ await handleTranscribePost("u1", "ep-1", {
+ chunks: [{ path: "episodes/e/chunk-0.webm", offsetMs: 0 }],
+ });
+ expect(mocked.updateEpisode).toHaveBeenCalledWith("ep-1", {
+ status: "queued",
+ audioBlobPath: JSON.stringify([{ path: "episodes/e/chunk-0.webm", offsetMs: 0 }]),
+ errorCode: null,
+ });
+ });
+
+ it("is idempotent while transcribing (refresh/second tab)", async () => {
+ owned(episodeRow({ status: "transcribing" }));
+ const result = await handleTranscribePost("u1", "ep-1", {});
+ expect(result.status).toBe(200);
+ expect(mockTranscribe).not.toHaveBeenCalled();
+ expect(mocked.updateEpisode).not.toHaveBeenCalled();
+ });
+
+ it("maps unsupported_language to the exact U19 copy and refunds quota", async () => {
+ owned(episodeRow({ audioBlobPath: manifest, status: "queued", countsQuota: true }));
+ mockTranscribe.mockRejectedValue(new AsrError("unsupported_language", 422));
+
+ const result = await handleTranscribePost("u1", "ep-1", {});
+
+ expect(result.json).toEqual({
+ episode: expect.objectContaining({ status: "failed", errorCode: "unsupported_language", countsQuota: false }),
+ });
+ expect(mocked.updateEpisode).toHaveBeenLastCalledWith("ep-1", {
+ status: "failed",
+ errorCode: "unsupported_language",
+ countsQuota: false,
+ });
+ });
+
+ it("maps empty transcripts to no_speech with the §6 copy", async () => {
+ owned(episodeRow({ audioBlobPath: manifest, status: "queued" }));
+ mockTranscribe.mockRejectedValue(new AsrError("empty_transcript", 422));
+ const result = await handleTranscribePost("u1", "ep-1", {});
+ expect((result.json as { episode: { errorCode: string } }).episode.errorCode).toBe("no_speech");
+ expect(mapAsrErrorCode("empty_transcript")).toBe("no_speech");
+ });
+
+ it("returns 400 when there is no audio to transcribe yet", async () => {
+ owned(episodeRow({ audioBlobPath: null, status: "uploading" }));
+ const result = await handleTranscribePost("u1", "ep-1", {});
+ expect(result.status).toBe(400);
+ expect(result.json).toEqual({ error: UPLOAD_AUDIO_FIRST_COPY });
+ });
+
+ it("refuses paste episodes (nothing to transcribe)", async () => {
+ owned(episodeRow({ source: "paste", status: "queued" }));
+ const result = await handleTranscribePost("u1", "ep-1", {});
+ expect(result.status).toBe(400);
+ expect(mockTranscribe).not.toHaveBeenCalled();
+ });
+
+ it("maps provider outages to transcribe_failed", async () => {
+ owned(episodeRow({ audioBlobPath: manifest, status: "queued" }));
+ mockTranscribe.mockRejectedValue(new AsrError("groq_500", 502));
+ const result = await handleTranscribePost("u1", "ep-1", {});
+ expect((result.json as { episode: { errorCode: string } }).episode.errorCode).toBe("transcribe_failed");
+ });
+});
+
+describe("handleEpisodeGet — resumable status read path (U4)", () => {
+ it("returns the serialized episode", async () => {
+ mocked.getEpisodeForUser.mockResolvedValue({
+ episode: episodeRow({ status: "transcribing" }),
+ show: showRow,
+ workspace: workspaceRow,
+ });
+ const result = await handleEpisodeGet("u1", "ep-1");
+ expect(result.status).toBe(200);
+ expect(result.json).toEqual({
+ episode: expect.objectContaining({ id: "ep-1", status: "transcribing" }),
+ });
+ });
+
+ it("returns 404 for another user's episode (§9 ownership)", async () => {
+ mocked.getEpisodeForUser.mockResolvedValue(null);
+ expect((await handleEpisodeGet("u1", "ep-1")).status).toBe(404);
+ expect((await handleTranscribePost("u1", "ep-1", {})).status).toBe(404);
+ expect((await handleAudioGet("u1", "ep-1")).status).toBe(404);
+ });
+});
+
+describe("handleAudioGet — signed playback URL (§7.5)", () => {
+ it("returns a signed URL for the chunk containing t", async () => {
+ mocked.getEpisodeForUser.mockResolvedValue({
+ episode: episodeRow({
+ audioBlobPath: JSON.stringify([
+ { path: "a.webm", offsetMs: 0 },
+ { path: "b.webm", offsetMs: 598000 },
+ ]),
+ }),
+ show: showRow,
+ workspace: workspaceRow,
+ });
+ mockIssueAudioUrl.mockResolvedValue({ url: "https://blob.example/signed", expiresAt: "2026-10-27T19:00:00Z" });
+
+ const result = await handleAudioGet("u1", "ep-1", 600_000);
+
+ expect(result.status).toBe(200);
+ expect(mockIssueAudioUrl).toHaveBeenCalledWith("b.webm");
+ expect(result.json).toEqual({
+ url: "https://blob.example/signed",
+ chunkOffsetMs: 598000,
+ expiresAt: "2026-10-27T19:00:00Z",
+ });
+ });
+
+ it("answers 503 when BLOB_READ_WRITE_TOKEN is missing (owner blocker)", async () => {
+ delete process.env.BLOB_READ_WRITE_TOKEN;
+ mocked.getEpisodeForUser.mockResolvedValue({
+ episode: episodeRow({ audioBlobPath: JSON.stringify([{ path: "a.webm", offsetMs: 0 }]) }),
+ show: showRow,
+ workspace: workspaceRow,
+ });
+ const result = await handleAudioGet("u1", "ep-1");
+ expect(result.status).toBe(503);
+ });
+});
+
+describe("handleUploadsTokenPost — §7.5", () => {
+ const request = new Request("https://app.example/api/uploads/token", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+
+ it("issues the blob client token within type + size constraints", async () => {
+ mockHandleUpload.mockResolvedValue({ type: "blob.generate-client-token", clientToken: "tok" });
+ const result = await handleUploadsTokenPost("u1", request);
+ expect(result.status).toBe(200);
+ expect(mockHandleUpload).toHaveBeenCalledWith(request, {
+ allowedContentTypes: expect.arrayContaining(["audio/webm", "video/quicktime", "audio/mpeg"]),
+ maximumSizeInBytes: 300_000_000,
+ });
+ });
+
+ it("answers 503 when BLOB_READ_WRITE_TOKEN is missing", async () => {
+ delete process.env.BLOB_READ_WRITE_TOKEN;
+ const result = await handleUploadsTokenPost("u1", request);
+ expect(result.status).toBe(503);
+ expect(mockHandleUpload).not.toHaveBeenCalled();
+ });
+
+ it("returns 402 at the quota wall before issuing a token", async () => {
+ mocked.countQuotaEpisodes.mockResolvedValue(2);
+ const result = await handleUploadsTokenPost("u1", request);
+ expect(result.status).toBe(402);
+ expect(mockHandleUpload).not.toHaveBeenCalled();
+ });
+});
+
+describe("error-code copy (§6)", () => {
+ it("serves the verbatim U19 and no_speech copy", () => {
+ expect(mapAsrErrorCode("unsupported_language")).toBe("unsupported_language");
+ expect(NO_SPEECH_COPY).toBe("We couldn't hear any speech in this file. Check the audio and try again.");
+ expect(UNSUPPORTED_LANGUAGE_COPY).toBe(
+ "EpisodeKit works with English episodes for now. This one wasn't charged.",
+ );
+ });
+});
diff --git a/web/tests/unit/transcripts.test.ts b/web/tests/unit/transcripts.test.ts
new file mode 100644
index 0000000..914719b
--- /dev/null
+++ b/web/tests/unit/transcripts.test.ts
@@ -0,0 +1,222 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { detectFormat, parseTranscript } from "@/lib/transcripts";
+
+const fixture = (name: string) =>
+ readFileSync(join(process.cwd(), "tests/fixtures/transcripts", name), "utf8");
+
+// U5 chip copy is exactly " · speakers and timestamps found" for
+// Riverside; other formats derive their labels from the M2 format names.
+describe("detectFormat", () => {
+ it("labels Riverside with both speakers and timestamps", () => {
+ const d = detectFormat(fixture("riverside.txt"));
+ expect(d.format).toBe("riverside");
+ expect(d.hasTimestamps).toBe(true);
+ expect(d.hasSpeakers).toBe(true);
+ expect(d.chip).toBe("Riverside · speakers and timestamps found");
+ });
+
+ it("labels bracket-timestamped speaker lines as Otter", () => {
+ const d = detectFormat(fixture("otter.txt"));
+ expect(d.format).toBe("otter");
+ expect(d.hasTimestamps).toBe(true);
+ expect(d.hasSpeakers).toBe(true);
+ expect(d.chip).toBe("Otter · speakers and timestamps found");
+ });
+
+ it("labels speaker-only lines as Descript", () => {
+ const d = detectFormat(fixture("descript.txt"));
+ expect(d.format).toBe("descript");
+ expect(d.hasTimestamps).toBe(false);
+ expect(d.hasSpeakers).toBe(true);
+ expect(d.chip).toBe("Descript · speakers found");
+ });
+
+ it("labels SRT with timestamps only", () => {
+ const d = detectFormat(fixture("srt.txt"));
+ expect(d.format).toBe("srt");
+ expect(d.hasTimestamps).toBe(true);
+ expect(d.hasSpeakers).toBe(false);
+ expect(d.chip).toBe("SRT · timestamps found");
+ });
+
+ it("labels VTT with timestamps only", () => {
+ const d = detectFormat(fixture("vtt.txt"));
+ expect(d.format).toBe("vtt");
+ expect(d.hasTimestamps).toBe(true);
+ expect(d.hasSpeakers).toBe(false);
+ expect(d.chip).toBe("VTT · timestamps found");
+ });
+
+ it("labels plain text without extras", () => {
+ const d = detectFormat(fixture("plain.txt"));
+ expect(d.format).toBe("plain");
+ expect(d.hasTimestamps).toBe(false);
+ expect(d.hasSpeakers).toBe(false);
+ expect(d.chip).toBe("Plain text");
+ });
+
+ it("empty input is plain with nothing found", () => {
+ expect(detectFormat(" \n ")).toMatchObject({ format: "plain", hasTimestamps: false, hasSpeakers: false });
+ });
+});
+
+describe("parseTranscript — SRT", () => {
+ it("parses cues into timestamped segments sorted by start", () => {
+ const r = parseTranscript(fixture("srt.txt"));
+ expect(r.segments).toHaveLength(3);
+ expect(r.segments[0]).toEqual({
+ speaker: null,
+ startMs: 0,
+ endMs: 4120,
+ text: "Welcome back to the show.",
+ });
+ expect(r.segments[2]).toMatchObject({ startMs: 41_000, text: "We budget our dollars, we calendar our hours." });
+ });
+
+ it("sorts unsorted timestamps by start (edge case)", () => {
+ const r = parseTranscript(fixture("srt-unsorted.txt"));
+ expect(r.segments.map((s) => s.startMs)).toEqual([0, 41_000]);
+ expect(r.segments[0].text).toBe("Welcome back to the show.");
+ });
+
+ it("parses speaker-prefixed cue text", () => {
+ const text = "1\n00:00:01,000 --> 00:00:02,000\nMaya: Hello there.\n";
+ const r = parseTranscript(text);
+ expect(r.hasSpeakers).toBe(true);
+ expect(r.segments[0]).toMatchObject({ speaker: "Maya", text: "Hello there." });
+ });
+
+ it("parses speakerless cues (edge case)", () => {
+ const r = parseTranscript(fixture("srt.txt"));
+ expect(r.hasSpeakers).toBe(false);
+ expect(r.segments.every((s) => s.speaker === null)).toBe(true);
+ });
+});
+
+describe("parseTranscript — VTT", () => {
+ it("parses cues, keeps cue identifiers as non-speakers", () => {
+ const r = parseTranscript(fixture("vtt.txt"));
+ expect(r.format).toBe("vtt");
+ expect(r.segments).toHaveLength(3);
+ expect(r.segments[0]).toEqual({
+ speaker: null,
+ startMs: 0,
+ endMs: 4120,
+ text: "Welcome back to the show.",
+ });
+ expect(r.segments[2]).toMatchObject({ startMs: 72_500, text: "Almost nobody manages it like money." });
+ });
+
+ it("handles NOTE blocks and cue settings", () => {
+ const text = [
+ "WEBVTT",
+ "",
+ "NOTE this is a comment",
+ "",
+ "00:00.000 --> 00:02.000 position:50% align:start",
+ "Two minute cue.",
+ ].join("\n");
+ const r = parseTranscript(text);
+ expect(r.segments).toHaveLength(1);
+ expect(r.segments[0]).toMatchObject({ startMs: 0, endMs: 2000, text: "Two minute cue." });
+ });
+});
+
+describe("parseTranscript — Riverside / Otter speaker-timestamped", () => {
+ it("parses Name (00:12:03): text with speakers and ms timestamps", () => {
+ const r = parseTranscript(fixture("riverside.txt"));
+ expect(r.segments.map((s) => s.speaker)).toEqual(["Maya", "Dev", "Maya"]);
+ expect(r.segments[0]).toMatchObject({ startMs: 0, text: "Welcome back to the show. Today we talk about focus." });
+ expect(r.segments[1]).toMatchObject({ startMs: 12_000 });
+ expect(r.segments[2]).toMatchObject({ startMs: 63_000 });
+ });
+
+ it("keeps continuation lines attached to the previous speaker segment", () => {
+ const text = ["Maya (00:00:01): First line.", "continues here.", "Dev (00:02:00): Second."].join("\n");
+ const r = parseTranscript(text);
+ expect(r.segments).toHaveLength(2);
+ expect(r.segments[0].text).toBe("First line. continues here.");
+ expect(r.segments[0].speaker).toBe("Maya");
+ });
+
+ it("parses CRLF input (edge case)", () => {
+ const r = parseTranscript(fixture("riverside-crlf.txt"));
+ expect(r.format).toBe("riverside");
+ expect(r.segments).toHaveLength(3);
+ expect(r.segments[0]).toMatchObject({ speaker: "Maya", text: "Welcome back to the show." });
+ });
+
+ it("parses [00:12:03] Name: text (Otter shape) with minutes-only times", () => {
+ const text = ["[0:05] Maya: short form.", "[12:03] Dev: long form."].join("\n");
+ const r = parseTranscript(text);
+ expect(r.format).toBe("otter");
+ expect(r.segments[0]).toMatchObject({ speaker: "Maya", startMs: 5_000 });
+ expect(r.segments[1]).toMatchObject({ speaker: "Dev", startMs: 723_000 });
+ });
+
+ it("parses a speakerless timestamped shape only when a name is present is NOT plain", () => {
+ // A line like "(00:12:03) text" has no speaker; it stays plain text.
+ const r = parseTranscript("(00:12:03) Just a parenthetical remark.\nAnother line.");
+ expect(r.format).toBe("plain");
+ expect(r.hasSpeakers).toBe(false);
+ });
+});
+
+describe("parseTranscript — Descript / plain", () => {
+ it("parses Speaker: text without timestamps, preserving order", () => {
+ const r = parseTranscript(fixture("descript.txt"));
+ expect(r.segments).toHaveLength(3);
+ expect(r.segments.every((s) => s.startMs === null && s.endMs === null)).toBe(true);
+ expect(r.segments[1]).toMatchObject({ speaker: "Dev" });
+ });
+
+ it("plain text becomes one segment per paragraph without speakers", () => {
+ const r = parseTranscript(fixture("plain.txt"));
+ expect(r.segments).toHaveLength(2);
+ expect(r.segments[0].text).toContain("focus is the");
+ expect(r.segments.every((s) => s.speaker === null && s.startMs === null)).toBe(true);
+ });
+
+ it("trims and drops empty paragraphs", () => {
+ const r = parseTranscript("First.\n\n\n\nSecond.\n");
+ expect(r.segments.map((s) => s.text)).toEqual(["First.", "Second."]);
+ });
+
+ it("empty input yields no segments", () => {
+ expect(parseTranscript(" ").segments).toEqual([]);
+ });
+});
+
+describe("parseTranscript — stored flags", () => {
+ it("reports hasTimestamps/hasSpeakers used for episodes columns", () => {
+ expect(parseTranscript(fixture("riverside.txt"))).toMatchObject({ hasTimestamps: true, hasSpeakers: true });
+ expect(parseTranscript(fixture("srt.txt"))).toMatchObject({ hasTimestamps: true, hasSpeakers: false });
+ expect(parseTranscript(fixture("descript.txt"))).toMatchObject({ hasTimestamps: false, hasSpeakers: true });
+ expect(parseTranscript(fixture("plain.txt"))).toMatchObject({ hasTimestamps: false, hasSpeakers: false });
+ });
+});
+
+describe("detection false positives (plain prose with colons)", () => {
+ it("does not read prose like 'a second: …' as speaker labels", () => {
+ const prose = [
+ "Remember that the next time someone says it will only take",
+ "a second: seconds are how attention debt is issued, and the interest rate is",
+ "brutal.",
+ "",
+ "Every app that promises to organize your attention is also competing for",
+ "scripts: I am offline until noon; send it and I will get there.",
+ ].join("\n");
+ const r = parseTranscript(prose);
+ expect(r.format).toBe("plain");
+ expect(r.hasSpeakers).toBe(false);
+ });
+
+ it("still accepts multi-word capitalized names and Speaker N labels", () => {
+ const text = ["Dr Maya Patel: Welcome.", "Speaker 2: Thanks for having me."].join("\n");
+ const r = parseTranscript(text);
+ expect(r.format).toBe("descript");
+ expect(r.segments.map((s) => s.speaker)).toEqual(["Dr Maya Patel", "Speaker 2"]);
+ });
+});
From a974dedc70bfcaf8bbb10a734958b893adbf9544 Mon Sep 17 00:00:00 2001
From: Abid Hasan
Date: Tue, 27 Oct 2026 20:27:52 +0600
Subject: [PATCH 2/2] integrate: WP-05 quota stub -> WP-08 real billing
boundary
---
web/db/queries.ts | 14 --------------
web/tests/unit/episodes-service.test.ts | 9 ++++++++-
2 files changed, 8 insertions(+), 15 deletions(-)
diff --git a/web/db/queries.ts b/web/db/queries.ts
index b60f052..3d893e9 100644
--- a/web/db/queries.ts
+++ b/web/db/queries.ts
@@ -79,20 +79,6 @@ export async function listSegments(episodeId: string): Promise {
return db.select().from(segments).where(eq(segments.episodeId, episodeId));
}
-/** Episodes created in `month` that count against the plan quota (M10). */
-export async function countQuotaEpisodes(
- workspaceId: string,
- month: string,
-): Promise {
- const db = getDb();
- const rows = await db
- .select({ id: episodes.id })
- .from(episodes)
- .innerJoin(shows, eq(episodes.showId, shows.id))
- .where(and(eq(shows.workspaceId, workspaceId), eq(episodes.quotaMonth, month), eq(episodes.countsQuota, true)));
- return rows.length;
-}
-
export async function upsertUser(input: {
id: string;
email: string | null;
diff --git a/web/tests/unit/episodes-service.test.ts b/web/tests/unit/episodes-service.test.ts
index 1f2eee6..27ab56f 100644
--- a/web/tests/unit/episodes-service.test.ts
+++ b/web/tests/unit/episodes-service.test.ts
@@ -10,6 +10,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/db/queries", () => ({
getShowByOwner: vi.fn(),
getOrCreateWorkspaceByOwner: vi.fn(),
+ getWorkspaceById: vi.fn(),
+ getWorkspaceByOwnerEmail: vi.fn(),
+ updateWorkspacePlan: vi.fn(),
+ markEpisodeQuotaCounted: vi.fn(),
+ refundEpisodeQuota: vi.fn(),
+ countFoundingWorkspaces: vi.fn(),
+ getUserEmail: vi.fn(),
createEpisode: vi.fn(),
getEpisodeForUser: vi.fn(),
updateEpisode: vi.fn(),
@@ -171,7 +178,7 @@ describe("handleEpisodesPost — create (§7.5)", () => {
expect(result.status).toBe(402);
expect(result.json).toEqual({
errorCode: "quota_exceeded",
- error: "You've used 2 of 2 episodes this month. Upgrade for more, or wait until 2026-11-01.",
+ error: "You've used 2 of 2 episodes this month. Upgrade for more, or wait until Nov 1.",
});
expect(mocked.createEpisode).not.toHaveBeenCalled();
});