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" )