mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
feat(podcasts): add frontend contracts and lifecycle api service
This commit is contained in:
parent
c84525897b
commit
64b36f2622
2 changed files with 178 additions and 0 deletions
120
surfsense_web/contracts/types/podcast.types.ts
Normal file
120
surfsense_web/contracts/types/podcast.types.ts
Normal file
|
|
@ -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<typeof podcastStatus>;
|
||||
|
||||
/** States waiting on user input before the lifecycle can proceed. */
|
||||
export const GATE_STATUSES: ReadonlySet<PodcastStatus> = new Set([
|
||||
"awaiting_brief",
|
||||
"awaiting_review",
|
||||
]);
|
||||
|
||||
/** States from which no further transition is possible. */
|
||||
export const TERMINAL_STATUSES: ReadonlySet<PodcastStatus> = 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<typeof speakerRole>;
|
||||
|
||||
export const podcastStyle = z.enum([
|
||||
"conversational",
|
||||
"interview",
|
||||
"debate",
|
||||
"monologue",
|
||||
"narrative",
|
||||
]);
|
||||
export type PodcastStyle = z.infer<typeof podcastStyle>;
|
||||
|
||||
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<typeof speakerSpec>;
|
||||
|
||||
export const durationTarget = z.object({
|
||||
min_minutes: z.number().int().min(1),
|
||||
max_minutes: z.number().int().min(1),
|
||||
});
|
||||
export type DurationTarget = z.infer<typeof durationTarget>;
|
||||
|
||||
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<typeof podcastSpec>;
|
||||
|
||||
// =============================================================================
|
||||
// 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<typeof transcriptTurn>;
|
||||
|
||||
export const transcript = z.object({
|
||||
turns: z.array(transcriptTurn).min(1),
|
||||
});
|
||||
export type Transcript = z.infer<typeof transcript>;
|
||||
|
||||
// =============================================================================
|
||||
// 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<typeof voiceOption>;
|
||||
|
||||
export const updateSpecRequest = z.object({
|
||||
spec: podcastSpec,
|
||||
expected_version: z.number().int().min(1),
|
||||
});
|
||||
export type UpdateSpecRequest = z.infer<typeof updateSpecRequest>;
|
||||
|
||||
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<typeof podcastDetail>;
|
||||
58
surfsense_web/lib/apis/podcasts-api.service.ts
Normal file
58
surfsense_web/lib/apis/podcasts-api.service.ts
Normal file
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue