From c666966b8c187a04a1fdc5add4d1aadeb8ca2326 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Tue, 17 Feb 2026 21:24:57 -0300 Subject: [PATCH] fix: podcast failure recovery and retry (1.7.3) (#595) * fix: surface podcast errors and enable retry for failed episodes Fixes #335, #300 Re-raise exceptions in podcast command so surreal-commands marks jobs as failed instead of completed. Surface error_message in API responses and add a retry endpoint that deletes the failed episode and re-submits the generation job. Frontend shows error details on failed episodes with a retry button. Translations added for all 8 locales. * fix: bump podcast-creator to >= 0.10 Fixes #302 * chore: release 1.7.3 - podcast failure recovery and retry Bump podcast-creator to >= 0.11.2, disable automatic retries for podcast generation to prevent duplicate episodes, and bump version to 1.7.3. Fixes #211, #218, #185, #355, #300, #302 * fix: resolve TypeScript error in handleRetry return type --- CHANGELOG.md | 16 ++++ api/routers/podcasts.py | 74 ++++++++++++++++++- commands/podcast_commands.py | 13 ++-- .../src/components/podcasts/EpisodeCard.tsx | 34 ++++++++- .../src/components/podcasts/EpisodesTab.tsx | 10 ++- frontend/src/lib/api/podcasts.ts | 7 ++ frontend/src/lib/hooks/use-podcasts.ts | 24 ++++++ frontend/src/lib/locales/en-US/index.ts | 6 ++ frontend/src/lib/locales/fr-FR/index.ts | 6 ++ frontend/src/lib/locales/it-IT/index.ts | 6 ++ frontend/src/lib/locales/ja-JP/index.ts | 6 ++ frontend/src/lib/locales/pt-BR/index.ts | 6 ++ frontend/src/lib/locales/ru-RU/index.ts | 6 ++ frontend/src/lib/locales/zh-CN/index.ts | 6 ++ frontend/src/lib/locales/zh-TW/index.ts | 6 ++ frontend/src/lib/types/podcasts.ts | 1 + open_notebook/podcasts/models.py | 18 +++++ pyproject.toml | 4 +- uv.lock | 15 ++-- 19 files changed, 239 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18aecb2..2efc4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.3] - 2026-02-17 + +### Added +- Retry button for failed podcast episodes in the UI (#211, #218) +- Error details displayed on failed podcast episodes (#185, #355) +- `POST /podcasts/episodes/{id}/retry` API endpoint for re-submitting failed episodes +- `error_message` field in podcast episode API responses + +### Fixed +- Podcast generation failures now correctly marked as "failed" instead of "completed" (#300, #335) +- Disabled automatic retries for podcast generation to prevent duplicate episode records (#302) + +### Dependencies +- Bump podcast-creator to >= 0.11.2 +- Bump esperanto to >= 2.19.4 + ## [1.7.2] - 2026-02-16 ### Added diff --git a/api/routers/podcasts.py b/api/routers/podcasts.py index b716808..042f66a 100644 --- a/api/routers/podcasts.py +++ b/api/routers/podcasts.py @@ -28,6 +28,7 @@ class PodcastEpisodeResponse(BaseModel): outline: Optional[dict] = None created: Optional[str] = None job_status: Optional[str] = None + error_message: Optional[str] = None def _resolve_audio_path(audio_file: str) -> Path: @@ -94,11 +95,14 @@ async def list_podcast_episodes(): if not episode.command and not episode.audio_file: continue - # Get job status if available + # Get job status and error message if available job_status = None + error_message = None if episode.command: try: - job_status = await episode.get_job_status() + detail = await episode.get_job_detail() + job_status = detail["status"] + error_message = detail["error_message"] except Exception: job_status = "unknown" else: @@ -124,6 +128,7 @@ async def list_podcast_episodes(): outline=episode.outline, created=str(episode.created) if episode.created else None, job_status=job_status, + error_message=error_message, ) ) @@ -142,11 +147,14 @@ async def get_podcast_episode(episode_id: str): try: episode = await PodcastService.get_episode(episode_id) - # Get job status if available + # Get job status and error message if available job_status = None + error_message = None if episode.command: try: - job_status = await episode.get_job_status() + detail = await episode.get_job_detail() + job_status = detail["status"] + error_message = detail["error_message"] except Exception: job_status = "unknown" else: @@ -171,6 +179,7 @@ async def get_podcast_episode(episode_id: str): outline=episode.outline, created=str(episode.created) if episode.created else None, job_status=job_status, + error_message=error_message, ) except Exception as e: @@ -203,6 +212,63 @@ async def stream_podcast_episode_audio(episode_id: str): ) +@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 + if episode.audio_file: + audio_path = _resolve_audio_path(episode.audio_file) + if audio_path.exists(): + try: + audio_path.unlink() + except Exception as e: + logger.warning(f"Failed to delete audio file {audio_path}: {e}") + + # 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 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""" diff --git a/commands/podcast_commands.py b/commands/podcast_commands.py index 2021f61..3ab4e03 100644 --- a/commands/podcast_commands.py +++ b/commands/podcast_commands.py @@ -46,7 +46,7 @@ class PodcastGenerationOutput(CommandOutput): error_message: Optional[str] = None -@command("generate_podcast", app="open_notebook") +@command("generate_podcast", app="open_notebook", retry={"max_attempts": 1}) async def generate_podcast_command( input_data: PodcastGenerationInput, ) -> PodcastGenerationOutput: @@ -166,22 +166,19 @@ async def generate_podcast_command( processing_time=processing_time, ) + except ValueError: + raise + except Exception as e: - processing_time = time.time() - start_time logger.error(f"Podcast generation failed: {e}") logger.exception(e) - # Check for specific GPT-5 extended thinking issue error_msg = str(e) if "Invalid json output" in error_msg or "Expecting value" in error_msg: - # This often happens with GPT-5 models that use extended thinking ( tags) - # and put all output inside thinking blocks error_msg += ( "\n\nNOTE: This error commonly occurs with GPT-5 models that use extended thinking. " "The model may be putting all output inside tags, leaving nothing to parse. " "Try using gpt-4o, gpt-4o-mini, or gpt-4-turbo instead in your episode profile." ) - return PodcastGenerationOutput( - success=False, processing_time=processing_time, error_message=error_msg - ) + raise RuntimeError(error_msg) from e diff --git a/frontend/src/components/podcasts/EpisodeCard.tsx b/frontend/src/components/podcasts/EpisodeCard.tsx index 4f506a8..2caea50 100644 --- a/frontend/src/components/podcasts/EpisodeCard.tsx +++ b/frontend/src/components/podcasts/EpisodeCard.tsx @@ -3,10 +3,10 @@ import { useEffect, useMemo, useState } from 'react' import { formatDistanceToNow } from 'date-fns' import { getDateLocale } from '@/lib/utils/date-locale' -import { InfoIcon, Trash2 } from 'lucide-react' +import { InfoIcon, RefreshCcw, Trash2 } from 'lucide-react' import { resolvePodcastAssetUrl } from '@/lib/api/podcasts' -import { EpisodeStatus, PodcastEpisode } from '@/lib/types/podcasts' +import { EpisodeStatus, FAILED_EPISODE_STATUSES, PodcastEpisode } from '@/lib/types/podcasts' import { cn } from '@/lib/utils' import { AlertDialog, @@ -39,6 +39,8 @@ interface EpisodeCardProps { episode: PodcastEpisode onDelete: (episodeId: string) => Promise | void deleting?: boolean + onRetry?: (episodeId: string) => Promise | void + retrying?: boolean } const getSTATUS_META = (t: TranslationKeys): Record< @@ -136,7 +138,7 @@ function extractTranscriptEntries(transcript: unknown): TranscriptEntry[] { return [] } -export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) { +export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }: EpisodeCardProps) { const { t, language } = useTranslation() const [audioSrc, setAudioSrc] = useState() const [audioError, setAudioError] = useState(null) @@ -217,6 +219,14 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) { void onDelete(episode.id) } + const handleRetry = () => { + if (onRetry) { + void onRetry(episode.id) + } + } + + const isFailed = FAILED_EPISODE_STATUSES.includes(episode.job_status as EpisodeStatus) + return ( @@ -371,6 +381,17 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) { + {isFailed && onRetry ? ( + + ) : null}