From 64b36f2622f41cba1c52e636636edabe1da2998a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 11 Jun 2026 10:04:51 +0200 Subject: [PATCH] feat(podcasts): add frontend contracts and lifecycle api service --- .../contracts/types/podcast.types.ts | 120 ++++++++++++++++++ .../lib/apis/podcasts-api.service.ts | 58 +++++++++ 2 files changed, 178 insertions(+) create mode 100644 surfsense_web/contracts/types/podcast.types.ts create mode 100644 surfsense_web/lib/apis/podcasts-api.service.ts diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts new file mode 100644 index 000000000..13bf530e7 --- /dev/null +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +// ============================================================================= +// Lifecycle — mirror app/podcasts/persistence/enums/podcast_status.py +// ============================================================================= + +export const podcastStatus = z.enum([ + "pending", + "awaiting_brief", + "drafting", + "awaiting_review", + "rendering", + "ready", + "failed", + "cancelled", +]); +export type PodcastStatus = z.infer; + +/** States waiting on user input before the lifecycle can proceed. */ +export const GATE_STATUSES: ReadonlySet = new Set([ + "awaiting_brief", + "awaiting_review", +]); + +/** States from which no further transition is possible. */ +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + "ready", + "failed", + "cancelled", +]); + +// ============================================================================= +// Brief (spec) — mirror app/podcasts/schemas/spec.py +// ============================================================================= + +export const speakerRole = z.enum(["host", "cohost", "guest", "expert", "narrator"]); +export type SpeakerRole = z.infer; + +export const podcastStyle = z.enum([ + "conversational", + "interview", + "debate", + "monologue", + "narrative", +]); +export type PodcastStyle = z.infer; + +export const MAX_SPEAKERS = 6; + +export const speakerSpec = z.object({ + slot: z.number().int().min(0), + name: z.string().min(1).max(120), + role: speakerRole, + voice_id: z.string().min(1), +}); +export type SpeakerSpec = z.infer; + +export const durationTarget = z.object({ + min_minutes: z.number().int().min(1), + max_minutes: z.number().int().min(1), +}); +export type DurationTarget = z.infer; + +export const podcastSpec = z.object({ + language: z.string().min(2), + style: podcastStyle, + speakers: z.array(speakerSpec).min(1).max(MAX_SPEAKERS), + duration: durationTarget, + focus: z.string().max(2000).nullable().optional(), +}); +export type PodcastSpec = z.infer; + +// ============================================================================= +// Transcript — mirror app/podcasts/schemas/transcript.py +// ============================================================================= + +export const transcriptTurn = z.object({ + speaker: z.number().int().min(0), + text: z.string().min(1), +}); +export type TranscriptTurn = z.infer; + +export const transcript = z.object({ + turns: z.array(transcriptTurn).min(1), +}); +export type Transcript = z.infer; + +// ============================================================================= +// API shapes — mirror app/podcasts/api/schemas.py +// ============================================================================= + +export const voiceOption = z.object({ + voice_id: z.string(), + display_name: z.string(), + language: z.string(), + gender: z.string(), +}); +export type VoiceOption = z.infer; + +export const updateSpecRequest = z.object({ + spec: podcastSpec, + expected_version: z.number().int().min(1), +}); +export type UpdateSpecRequest = z.infer; + +export const podcastDetail = z.object({ + id: z.number(), + title: z.string(), + status: podcastStatus, + spec_version: z.number(), + spec: podcastSpec.nullable(), + transcript: transcript.nullable(), + has_audio: z.boolean(), + duration_seconds: z.number().nullable(), + error: z.string().nullable(), + created_at: z.string(), + search_space_id: z.number(), + thread_id: z.number().nullable(), +}); +export type PodcastDetail = z.infer; diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts new file mode 100644 index 000000000..f47269654 --- /dev/null +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { + type PodcastSpec, + podcastDetail, + updateSpecRequest, + voiceOption, +} from "@/contracts/types/podcast.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +const BASE = "/api/v1/podcasts"; + +const voiceOptionList = z.array(voiceOption); + +class PodcastsApiService { + // Full state including the deserialized brief and transcript; thin lifecycle + // fields (status, spec, spec_version) also arrive live via Zero. + getDetail = async (podcastId: number) => { + return baseApiService.get(`${BASE}/${podcastId}`, podcastDetail); + }; + + // Guarded by the version the caller last saw; the backend answers 409 when + // the brief changed underneath them. + updateSpec = async (podcastId: number, spec: PodcastSpec, expectedVersion: number) => { + const parsed = updateSpecRequest.safeParse({ spec, expected_version: expectedVersion }); + if (!parsed.success) { + throw new ValidationError( + `Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}` + ); + } + return baseApiService.patch(`${BASE}/${podcastId}/spec`, podcastDetail, { + body: parsed.data, + }); + }; + + approveBrief = async (podcastId: number) => { + return baseApiService.post(`${BASE}/${podcastId}/brief/approve`, podcastDetail); + }; + + approveTranscript = async (podcastId: number) => { + return baseApiService.post(`${BASE}/${podcastId}/transcript/approve`, podcastDetail); + }; + + regenerateTranscript = async (podcastId: number) => { + return baseApiService.post(`${BASE}/${podcastId}/transcript/regenerate`, podcastDetail); + }; + + cancel = async (podcastId: number) => { + return baseApiService.post(`${BASE}/${podcastId}/cancel`, podcastDetail); + }; + + listVoices = async (language?: string) => { + const qs = language ? `?${new URLSearchParams({ language })}` : ""; + return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList); + }; +} + +export const podcastsApiService = new PodcastsApiService();