SurfSense/surfsense_backend/app/podcasts/api/routes.py

361 lines
12 KiB
Python
Raw Normal View History

2026-06-10 18:44:03 +02:00
"""HTTP surface for the podcast lifecycle.
Status is observed by the frontend through Zero, so these routes are about
actions (create, edit/approve the brief, regenerate, cancel) and audio delivery.
2026-06-10 18:44:03 +02:00
Each mutating route performs the guarded transition via the service, commits,
then enqueues the matching Celery task; lifecycle errors map to 409/422.
"""
from __future__ import annotations
import os
2026-06-11 15:31:43 -07:00
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
2026-06-10 18:44:03 +02:00
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
2026-06-10 18:44:03 +02:00
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config as app_config
2026-06-10 18:44:03 +02:00
from app.db import (
Permission,
SearchSpace,
SearchSpaceMembership,
User,
get_async_session,
)
from app.podcasts.generation.brief import propose_brief
from app.podcasts.persistence import Podcast, PodcastRepository, PodcastStatus
2026-06-10 18:44:03 +02:00
from app.podcasts.service import (
2026-06-11 15:31:43 -07:00
InvalidTransitionError,
2026-06-10 18:44:03 +02:00
PodcastService,
2026-06-11 15:31:43 -07:00
PreconditionFailedError,
SpecConflictError,
2026-06-10 18:44:03 +02:00
)
from app.podcasts.storage import audio_exists, open_audio_stream, purge_audio
from app.podcasts.tasks import draft_transcript_task
from app.podcasts.tts import get_text_to_speech
from app.podcasts.voices import (
get_voice_catalog,
provider_from_service,
render_voice_preview,
2026-06-10 18:44:03 +02:00
)
from app.users import current_active_user
from app.utils.rbac import check_permission
from .schemas import (
CreatePodcastRequest,
2026-06-12 07:38:38 +02:00
LanguageOptions,
2026-06-10 18:44:03 +02:00
PodcastDetail,
PodcastSummary,
UpdateSpecRequest,
VoiceOption,
)
router = APIRouter()
@router.get("/podcasts", response_model=list[PodcastSummary])
async def list_podcasts(
search_space_id: int | None = None,
skip: int = 0,
limit: int = 100,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
if skip < 0 or limit < 1:
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
if search_space_id is not None:
await _require(session, user, search_space_id, Permission.PODCASTS_READ)
query = (
select(Podcast)
.where(Podcast.search_space_id == search_space_id)
.order_by(Podcast.created_at.desc())
.offset(skip)
.limit(limit)
)
else:
query = (
select(Podcast)
.join(SearchSpace)
.join(SearchSpaceMembership)
.where(SearchSpaceMembership.user_id == user.id)
.order_by(Podcast.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/podcasts/voices", response_model=list[VoiceOption])
async def list_voices(language: str | None = None):
"""Voices the active TTS provider offers, optionally filtered by language."""
if not app_config.TTS_SERVICE:
raise HTTPException(status_code=503, detail="No TTS provider configured")
provider = provider_from_service(app_config.TTS_SERVICE)
catalog = get_voice_catalog()
voices = (
catalog.for_language(provider, language)
if language
else catalog.for_provider(provider)
)
return [
VoiceOption(
voice_id=v.voice_id,
display_name=v.display_name,
language=v.language,
gender=v.gender.value,
)
for v in voices
]
2026-06-12 07:38:38 +02:00
@router.get("/podcasts/languages", response_model=LanguageOptions)
async def list_languages():
"""Languages the active TTS provider can offer the brief editor."""
if not app_config.TTS_SERVICE:
raise HTTPException(status_code=503, detail="No TTS provider configured")
provider = provider_from_service(app_config.TTS_SERVICE)
offering = get_voice_catalog().offerable_languages(provider)
return LanguageOptions(
languages=offering.languages,
allows_custom=offering.allows_custom,
)
@router.get("/podcasts/voices/{voice_id}/preview")
async def preview_voice(
voice_id: str,
user: User = Depends(current_active_user),
):
"""A short audio sample of a voice, so users pick by sound."""
if not app_config.TTS_SERVICE:
raise HTTPException(status_code=503, detail="No TTS provider configured")
provider = provider_from_service(app_config.TTS_SERVICE)
try:
voice = get_voice_catalog().get(voice_id)
except KeyError:
raise HTTPException(status_code=404, detail="Unknown voice") from None
if voice.provider is not provider:
raise HTTPException(
status_code=404, detail="Voice not offered by the active TTS provider"
)
data, content_type = await render_voice_preview(voice, get_text_to_speech())
return Response(content=data, media_type=content_type)
2026-06-10 18:44:03 +02:00
@router.post("/podcasts", response_model=PodcastDetail, status_code=201)
async def create_podcast(
body: CreatePodcastRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
await _require(session, user, body.search_space_id, Permission.PODCASTS_CREATE)
service = PodcastService(session)
podcast = await service.create(
2026-06-10 18:44:03 +02:00
title=body.title,
search_space_id=body.search_space_id,
thread_id=body.thread_id,
)
podcast.source_content = body.source_content
spec = await propose_brief(
session,
search_space_id=body.search_space_id,
speaker_count=body.speaker_count,
min_seconds=body.min_seconds,
max_seconds=body.max_seconds,
focus=body.focus,
)
await service.attach_brief(podcast, spec)
await session.commit()
2026-06-10 18:44:03 +02:00
return PodcastDetail.of(podcast)
@router.get("/podcasts/{podcast_id}", response_model=PodcastDetail)
async def get_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_READ)
return PodcastDetail.of(podcast)
@router.patch("/podcasts/{podcast_id}/spec", response_model=PodcastDetail)
async def update_spec(
podcast_id: int,
body: UpdateSpecRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).update_spec(
podcast, body.spec, body.expected_version
)
await session.commit()
return PodcastDetail.of(podcast)
@router.post("/podcasts/{podcast_id}/brief/approve", response_model=PodcastDetail)
async def approve_brief(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Approve the brief and start drafting the transcript."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).begin_drafting(podcast)
await session.commit()
draft_transcript_task.delay(podcast.id, podcast.search_space_id)
return PodcastDetail.of(podcast)
@router.post(
"/podcasts/{podcast_id}/transcript/regenerate", response_model=PodcastDetail
)
async def regenerate_transcript(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Reopen the brief gate for a fresh take; drafting waits for re-approval."""
2026-06-10 18:44:03 +02:00
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).regenerate(podcast)
await session.commit()
return PodcastDetail.of(podcast)
@router.post("/podcasts/{podcast_id}/regenerate/revert", response_model=PodcastDetail)
async def revert_regeneration(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Back out of a regeneration and return to the finished episode."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).revert_regeneration(podcast)
await session.commit()
return PodcastDetail.of(podcast)
2026-06-10 18:44:03 +02:00
@router.post("/podcasts/{podcast_id}/cancel", response_model=PodcastDetail)
async def cancel_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).cancel(podcast)
await session.commit()
return PodcastDetail.of(podcast)
@router.delete("/podcasts/{podcast_id}", response_model=dict)
async def delete_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_DELETE)
await purge_audio(podcast)
await session.delete(podcast)
await session.commit()
return {"message": "Podcast deleted successfully"}
@router.get("/podcasts/{podcast_id}/stream")
async def stream_podcast(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_READ)
if podcast.storage_key:
# Verify first so a missing object is a 404, not a mid-stream crash.
if not await audio_exists(podcast):
raise HTTPException(
status_code=404, detail="Podcast audio is no longer available"
)
2026-06-10 18:44:03 +02:00
return StreamingResponse(
open_audio_stream(podcast),
media_type="audio/mpeg",
headers={"Accept-Ranges": "bytes"},
)
# Back-compat: rows rendered before the storage migration kept a local path.
if podcast.file_location and os.path.isfile(podcast.file_location):
path = podcast.file_location
def iterfile():
with open(path, mode="rb") as handle:
yield from handle
return StreamingResponse(
iterfile(),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",
"Content-Disposition": f"inline; filename={Path(path).name}",
},
)
# No audio: terminal states never will have any, otherwise it's in flight.
if PodcastStatus(podcast.status).is_terminal:
raise HTTPException(status_code=404, detail="Podcast audio not found")
raise HTTPException(status_code=409, detail="Podcast audio is not ready yet")
2026-06-10 18:44:03 +02:00
async def _require(
session: AsyncSession,
user: User,
search_space_id: int,
permission: Permission,
) -> None:
await check_permission(
session,
user,
search_space_id,
permission.value,
"You don't have permission for podcasts in this search space",
)
async def _load(
session: AsyncSession,
user: User,
podcast_id: int,
permission: Permission,
) -> Podcast:
podcast = await PodcastRepository(session).get(podcast_id)
if podcast is None:
raise HTTPException(status_code=404, detail="Podcast not found")
await _require(session, user, podcast.search_space_id, permission)
return podcast
2026-06-11 15:31:43 -07:00
@asynccontextmanager
async def _lifecycle_errors() -> AsyncIterator[None]:
2026-06-10 18:44:03 +02:00
"""Map service lifecycle errors onto HTTP responses."""
2026-06-11 15:31:43 -07:00
try:
yield
except (SpecConflictError, InvalidTransitionError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except PreconditionFailedError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc