Files
lfnovo--open-notebook/api/routers/podcasts.py
T
2026-07-13 12:10:23 +08:00

433 lines
15 KiB
Python

from typing import List, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from loguru import logger
from pydantic import BaseModel
from api.podcast_service import (
PodcastGenerationRequest,
PodcastGenerationResponse,
PodcastService,
)
from open_notebook.ai.models import Model
from open_notebook.exceptions import OpenNotebookError
from open_notebook.podcasts.audio_paths import resolve_contained_audio_path
from open_notebook.podcasts.models import PodcastEpisode
router = APIRouter()
# Model reference fields stored in the denormalized profile snapshots on an
# episode, mapped to the resolved display fields the frontend renders
# ("provider / name" rows in EpisodeCard). Mirrors the speaker_config ->
# speaker_config_name precedent in api/routers/episode_profiles.py.
_EPISODE_PROFILE_MODEL_FIELDS = {
"outline_llm": ("outline_model_provider", "outline_model_name"),
"transcript_llm": ("transcript_model_provider", "transcript_model_name"),
}
_SPEAKER_PROFILE_MODEL_FIELDS = {
"voice_model": ("voice_model_provider", "voice_model_name"),
}
def _collect_snapshot_model_ids(episodes: List[PodcastEpisode]) -> List[str]:
"""Collect the distinct model record IDs referenced by episode snapshots."""
ids = set()
for episode in episodes:
for field in _EPISODE_PROFILE_MODEL_FIELDS:
ref = (episode.episode_profile or {}).get(field)
if ref:
ids.add(str(ref))
for field in _SPEAKER_PROFILE_MODEL_FIELDS:
ref = (episode.speaker_profile or {}).get(field)
if ref:
ids.add(str(ref))
return sorted(ids)
def _with_resolved_model_fields(
snapshot: dict,
field_map: dict,
models_by_id: dict,
) -> dict:
"""Return a copy of a profile snapshot with resolved model display fields.
Only sets the display fields when the reference resolves; unresolvable
references (deleted model) and legacy snapshots without references are
left untouched so the frontend can fall back to the historical
provider/model strings, then to a placeholder.
"""
enriched = dict(snapshot or {})
for ref_field, (provider_field, name_field) in field_map.items():
ref = enriched.get(ref_field)
info = models_by_id.get(str(ref)) if ref else None
if info:
enriched[provider_field] = info["provider"]
enriched[name_field] = info["name"]
return enriched
async def _resolve_snapshot_models(
episodes: List[PodcastEpisode],
) -> dict:
"""Batch-resolve every model reference in the episodes' snapshots.
One query for the whole list (see Model.get_display_info_for_ids) - a
failure degrades to no resolved fields rather than failing the request.
"""
try:
return await Model.get_display_info_for_ids(
_collect_snapshot_model_ids(episodes)
)
except Exception as e:
logger.warning(f"Error batch-resolving snapshot model references: {str(e)}")
return {}
def _delete_episode_audio(episode: PodcastEpisode, episode_id: str) -> None:
"""Best-effort unlink of an episode's audio file, refusing invalid paths.
Shared by the delete and retry endpoints. Legacy/escaping audio_file
values (resolve_contained_audio_path -> None) are logged and skipped.
"""
if not episode.audio_file:
return
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is None:
logger.warning(
f"Refusing to delete audio file outside podcasts directory "
f"for episode {episode_id}: {episode.audio_file}"
)
elif audio_path.exists():
try:
audio_path.unlink()
logger.info(f"Deleted audio file: {audio_path}")
except Exception as e:
logger.warning(f"Failed to delete audio file {audio_path}: {e}")
class PodcastEpisodeResponse(BaseModel):
id: str
name: str
episode_profile: dict
speaker_profile: dict
briefing: str
audio_file: Optional[str] = None
audio_url: Optional[str] = None
transcript: Optional[dict] = None
outline: Optional[dict] = None
created: Optional[str] = None
job_status: Optional[str] = None
error_message: Optional[str] = None
@router.post("/podcasts/generate", response_model=PodcastGenerationResponse)
async def generate_podcast(request: PodcastGenerationRequest):
"""
Generate a podcast episode using Episode Profiles.
Returns immediately with job ID for status tracking.
"""
try:
job_id = await PodcastService.submit_generation_job(
episode_profile_name=request.episode_profile,
speaker_profile_name=request.speaker_profile,
episode_name=request.episode_name,
notebook_id=request.notebook_id,
content=request.content,
briefing_suffix=request.briefing_suffix,
)
return PodcastGenerationResponse(
job_id=job_id,
status="submitted",
message=f"Podcast generation started for episode '{request.episode_name}'",
episode_profile=request.episode_profile,
episode_name=request.episode_name,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error generating podcast: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to generate podcast"
)
@router.get("/podcasts/jobs/{job_id}")
async def get_podcast_job_status(job_id: str):
"""Get the status of a podcast generation job"""
try:
status_data = await PodcastService.get_job_status(job_id)
return status_data
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching podcast job status: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to fetch job status"
)
@router.get("/podcasts/episodes", response_model=List[PodcastEpisodeResponse])
async def list_podcast_episodes():
"""List all podcast episodes"""
try:
episodes = await PodcastService.list_episodes()
# Batch-fetch job status for every episode with a command in one
# query instead of one round trip per episode (see
# PodcastEpisode.get_job_details_for_commands docstring).
try:
details_by_command = await PodcastEpisode.get_job_details_for_commands(
[episode.command for episode in episodes if episode.command]
)
except Exception as e:
logger.warning(f"Error batch-fetching podcast job statuses: {str(e)}")
details_by_command = {}
# Batch-resolve the snapshots' model references (outline_llm,
# transcript_llm, voice_model) to display fields in one query
# instead of one lookup per episode.
models_by_id = await _resolve_snapshot_models(episodes)
response_episodes = []
for episode in episodes:
# Skip incomplete episodes without command or audio
if not episode.command and not episode.audio_file:
continue
# Get job status and error message if available
job_status = None
error_message = None
if episode.command:
detail = details_by_command.get(str(episode.command))
if detail is not None:
job_status = detail["status"]
error_message = detail["error_message"]
else:
job_status = "unknown"
else:
# No command but has audio file = completed import
job_status = "completed"
audio_url = None
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is not None and audio_path.exists():
audio_url = f"/api/podcasts/episodes/{episode.id}/audio"
response_episodes.append(
PodcastEpisodeResponse(
id=str(episode.id),
name=episode.name,
episode_profile=_with_resolved_model_fields(
episode.episode_profile,
_EPISODE_PROFILE_MODEL_FIELDS,
models_by_id,
),
speaker_profile=_with_resolved_model_fields(
episode.speaker_profile,
_SPEAKER_PROFILE_MODEL_FIELDS,
models_by_id,
),
briefing=episode.briefing,
audio_file=episode.audio_file,
audio_url=audio_url,
transcript=episode.transcript,
outline=episode.outline,
created=str(episode.created) if episode.created else None,
job_status=job_status,
error_message=error_message,
)
)
return response_episodes
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error listing podcast episodes: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to list podcast episodes"
)
@router.get("/podcasts/episodes/{episode_id}", response_model=PodcastEpisodeResponse)
async def get_podcast_episode(episode_id: str):
"""Get a specific podcast episode"""
try:
episode = await PodcastService.get_episode(episode_id)
# Get job status and error message if available
job_status = None
error_message = None
if episode.command:
try:
detail = await episode.get_job_detail()
job_status = detail["status"]
error_message = detail["error_message"]
except Exception:
job_status = "unknown"
else:
# No command but has audio file = completed import
job_status = "completed" if episode.audio_file else "unknown"
audio_url = None
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is not None and audio_path.exists():
audio_url = f"/api/podcasts/episodes/{episode.id}/audio"
models_by_id = await _resolve_snapshot_models([episode])
return PodcastEpisodeResponse(
id=str(episode.id),
name=episode.name,
episode_profile=_with_resolved_model_fields(
episode.episode_profile,
_EPISODE_PROFILE_MODEL_FIELDS,
models_by_id,
),
speaker_profile=_with_resolved_model_fields(
episode.speaker_profile,
_SPEAKER_PROFILE_MODEL_FIELDS,
models_by_id,
),
briefing=episode.briefing,
audio_file=episode.audio_file,
audio_url=audio_url,
transcript=episode.transcript,
outline=episode.outline,
created=str(episode.created) if episode.created else None,
job_status=job_status,
error_message=error_message,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching podcast episode: {str(e)}")
raise HTTPException(status_code=404, detail="Episode not found")
@router.get("/podcasts/episodes/{episode_id}/audio")
async def stream_podcast_episode_audio(episode_id: str):
"""Stream the audio file associated with a podcast episode"""
try:
episode = await PodcastService.get_episode(episode_id)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching podcast episode for audio: {str(e)}")
raise HTTPException(status_code=404, detail="Episode not found")
if not episode.audio_file:
raise HTTPException(status_code=404, detail="Episode has no audio file")
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is None:
logger.warning(
f"Blocked audio access outside podcasts directory for episode "
f"{episode_id}: {episode.audio_file}"
)
raise HTTPException(status_code=403, detail="Access to file denied")
if not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found on disk")
return FileResponse(
audio_path,
media_type="audio/mpeg",
filename=audio_path.name,
)
@router.post("/podcasts/episodes/{episode_id}/retry")
async def retry_podcast_episode(episode_id: str):
"""Retry a failed podcast episode by deleting it and submitting a new job"""
try:
episode = await PodcastService.get_episode(episode_id)
# Validate episode is in a failed state
detail = await episode.get_job_detail()
if detail["status"] not in ("failed", "error"):
raise HTTPException(
status_code=400,
detail=f"Episode is not in a failed state (current: {detail['status']})",
)
# Extract params for re-submission
ep_profile_name = episode.episode_profile.get("name")
sp_profile_name = episode.speaker_profile.get("name")
episode_name = episode.name
content = episode.content
if not ep_profile_name or not sp_profile_name:
raise HTTPException(
status_code=400,
detail="Cannot retry: episode or speaker profile name missing from stored data",
)
# Delete audio file if any
_delete_episode_audio(episode, episode_id)
# Delete the failed episode
await episode.delete()
# Submit a new job
job_id = await PodcastService.submit_generation_job(
episode_profile_name=ep_profile_name,
speaker_profile_name=sp_profile_name,
episode_name=episode_name,
content=content,
)
return {"job_id": job_id, "message": "Retry submitted successfully"}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error retrying podcast episode: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to retry episode"
)
@router.delete("/podcasts/episodes/{episode_id}")
async def delete_podcast_episode(episode_id: str):
"""Delete a podcast episode and its associated audio file"""
try:
# Get the episode first to check if it exists and get the audio file path
episode = await PodcastService.get_episode(episode_id)
# Delete the physical audio file if it exists
_delete_episode_audio(episode, episode_id)
# Delete the episode from the database
await episode.delete()
logger.info(f"Deleted podcast episode: {episode_id}")
return {"message": "Episode deleted successfully", "episode_id": episode_id}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting podcast episode: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to delete episode"
)