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
This commit is contained in:
Luis Novo 2026-02-17 21:24:57 -03:00 committed by GitHub
parent 96525b4457
commit c666966b8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 239 additions and 25 deletions

View file

@ -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

View file

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

View file

@ -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 (<think> 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 <think> 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

View file

@ -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> | void
deleting?: boolean
onRetry?: (episodeId: string) => Promise<void> | 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<string | undefined>()
const [audioError, setAudioError] = useState<string | null>(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 (
<Card className="shadow-sm">
<CardContent className="space-y-4 p-4">
@ -371,6 +381,17 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
</div>
</DialogContent>
</Dialog>
{isFailed && onRetry ? (
<Button
variant="outline"
size="sm"
onClick={handleRetry}
disabled={retrying}
>
<RefreshCcw className={cn('mr-2 h-4 w-4', retrying && 'animate-spin')} />
{retrying ? t.podcasts.retrying : t.podcasts.retry}
</Button>
) : null}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive">
@ -401,6 +422,13 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
) : audioError ? (
<p className="text-sm text-destructive">{audioError}</p>
) : null}
{isFailed && episode.error_message ? (
<div className="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950/30">
<p className="text-xs font-medium text-red-800 dark:text-red-300">{t.podcasts.errorDetails}</p>
<p className="mt-1 text-xs whitespace-pre-wrap text-red-700 dark:text-red-400">{episode.error_message}</p>
</div>
) : null}
</CardContent>
</Card>
)

View file

@ -3,7 +3,7 @@
import { useCallback, useState } from 'react'
import { AlertCircle, Loader2, RefreshCcw } from 'lucide-react'
import { useDeletePodcastEpisode, usePodcastEpisodes } from '@/lib/hooks/use-podcasts'
import { useDeletePodcastEpisode, usePodcastEpisodes, useRetryPodcastEpisode } from '@/lib/hooks/use-podcasts'
import { EpisodeCard } from '@/components/podcasts/EpisodeCard'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
@ -62,6 +62,7 @@ export function EpisodesTab() {
isFetching,
} = usePodcastEpisodes()
const deleteEpisode = useDeletePodcastEpisode()
const retryEpisode = useRetryPodcastEpisode()
const handleRefresh = useCallback(() => {
void refetch()
@ -72,6 +73,11 @@ export function EpisodesTab() {
[deleteEpisode]
)
const handleRetry = useCallback(
async (episodeId: string) => { await retryEpisode.mutateAsync(episodeId) },
[retryEpisode]
)
const emptyState = !isLoading && episodes.length === 0
return (
@ -158,6 +164,8 @@ export function EpisodesTab() {
episode={episode}
onDelete={handleDelete}
deleting={deleteEpisode.isPending}
onRetry={handleRetry}
retrying={retryEpisode.isPending}
/>
))}
</div>

View file

@ -39,6 +39,13 @@ export const podcastsApi = {
await apiClient.delete(`/podcasts/episodes/${episodeId}`)
},
retryEpisode: async (episodeId: string) => {
const response = await apiClient.post<{ job_id: string; message: string }>(
`/podcasts/episodes/${episodeId}/retry`
)
return response.data
},
listEpisodeProfiles: async () => {
const response = await apiClient.get<EpisodeProfile[]>('/episode-profiles')
return response.data

View file

@ -80,6 +80,30 @@ export function usePodcastEpisodes(options?: { autoRefresh?: boolean }) {
}
}
export function useRetryPodcastEpisode() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: (episodeId: string) => podcastsApi.retryEpisode(episodeId),
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({
title: t.podcasts.retryStarted,
description: t.podcasts.retryStartedDesc,
})
},
onError: (error: unknown) => {
toast({
title: t.podcasts.failedToRetry,
description: getApiErrorKey(error, t.common.error),
variant: 'destructive',
})
},
})
}
export function useDeletePodcastEpisode() {
const queryClient = useQueryClient()
const { toast } = useToast()

View file

@ -699,6 +699,12 @@ export const enUS = {
speakerCountMax: "You can configure up to 4 speakers",
delete: "Delete",
failedToDelete: "Failed to delete podcast",
retry: "Retry",
retrying: "Retrying…",
retryStarted: "Retry Started",
retryStartedDesc: "A new podcast generation job has been submitted.",
failedToRetry: "Failed to retry episode",
errorDetails: "Error details",
},
settings: {
contentProcessing: "Content Processing",

View file

@ -699,6 +699,12 @@ export const frFR = {
speakerCountMax: "Vous pouvez configurer jusqu'à 4 intervenants",
delete: "Supprimer",
failedToDelete: "Échec de la suppression du podcast",
retry: "Réessayer",
retrying: "Nouvelle tentative…",
retryStarted: "Nouvelle tentative lancée",
retryStartedDesc: "Un nouveau travail de génération de podcast a été soumis.",
failedToRetry: "Échec de la nouvelle tentative",
errorDetails: "Détails de l'erreur",
},
settings: {
contentProcessing: "Traitement du contenu",

View file

@ -699,6 +699,12 @@ export const itIT = {
speakerCountMax: "Puoi configurare fino a 4 speaker",
delete: "Elimina",
failedToDelete: "Impossibile eliminare il podcast",
retry: "Riprova",
retrying: "Nuovo tentativo…",
retryStarted: "Nuovo tentativo avviato",
retryStartedDesc: "Un nuovo lavoro di generazione podcast è stato inviato.",
failedToRetry: "Impossibile riprovare",
errorDetails: "Dettagli errore",
},
settings: {
contentProcessing: "Elaborazione contenuti",

View file

@ -699,6 +699,12 @@ export const jaJP = {
speakerCountMax: "最大4人まで設定できます",
delete: "削除",
failedToDelete: "ポッドキャストの削除に失敗しました",
retry: "再試行",
retrying: "再試行中…",
retryStarted: "再試行を開始しました",
retryStartedDesc: "新しいポッドキャスト生成ジョブが送信されました。",
failedToRetry: "再試行に失敗しました",
errorDetails: "エラー詳細",
},
settings: {
contentProcessing: "コンテンツ処理",

View file

@ -699,6 +699,12 @@ export const ptBR = {
speakerCountMax: "Você pode configurar até 4 locutores",
delete: "Excluir",
failedToDelete: "Falha ao excluir podcast",
retry: "Tentar novamente",
retrying: "Tentando novamente…",
retryStarted: "Nova tentativa iniciada",
retryStartedDesc: "Um novo trabalho de geração de podcast foi enviado.",
failedToRetry: "Falha ao tentar novamente",
errorDetails: "Detalhes do erro",
},
settings: {
contentProcessing: "Processamento de Conteúdo",

View file

@ -699,6 +699,12 @@ export const ruRU = {
speakerCountMax: "Можно настроить до 4 говорящих",
delete: "Удалить",
failedToDelete: "Не удалось удалить подкаст",
retry: "Повторить",
retrying: "Повтор…",
retryStarted: "Повтор запущен",
retryStartedDesc: "Новое задание на генерацию подкаста отправлено.",
failedToRetry: "Не удалось повторить",
errorDetails: "Подробности ошибки",
},
settings: {
contentProcessing: "Обработка контента",

View file

@ -699,6 +699,12 @@ export const zhCN = {
speakerCountMax: "最多只能配置 4 个发言人",
delete: "删除",
failedToDelete: "删除播客失败",
retry: "重试",
retrying: "重试中…",
retryStarted: "已开始重试",
retryStartedDesc: "已提交新的播客生成任务。",
failedToRetry: "重试失败",
errorDetails: "错误详情",
},
settings: {
contentProcessing: "内容处理",

View file

@ -699,6 +699,12 @@ export const zhTW = {
speakerCountMax: "最多只能設定 4 個發言人",
delete: "刪除",
failedToDelete: "刪除播客失敗",
retry: "重試",
retrying: "重試中…",
retryStarted: "已開始重試",
retryStartedDesc: "已提交新的播客生成任務。",
failedToRetry: "重試失敗",
errorDetails: "錯誤詳情",
},
settings: {
contentProcessing: "內容處理",

View file

@ -49,6 +49,7 @@ export interface PodcastEpisode {
outline?: Record<string, unknown> | null
created?: string | null
job_status?: EpisodeStatus | null
error_message?: string | null
}
export interface PodcastGenerationRequest {

View file

@ -129,6 +129,24 @@ class PodcastEpisode(ObjectModel):
except Exception:
return "unknown"
async def get_job_detail(self) -> dict:
"""Get status and error_message of the associated command"""
if not self.command:
return {"status": None, "error_message": None}
try:
from surreal_commands import get_command_status
status = await get_command_status(str(self.command))
if not status:
return {"status": "unknown", "error_message": None}
return {
"status": status.status,
"error_message": getattr(status, "error_message", None),
}
except Exception:
return {"status": "unknown", "error_message": None}
@field_validator("command", mode="before")
@classmethod
def parse_command(cls, value):

View file

@ -1,6 +1,6 @@
[project]
name = "open-notebook"
version = "1.7.2"
version = "1.7.3"
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
authors = [
{name = "Luis Novo", email = "lfnovo@gmail.com"}
@ -36,7 +36,7 @@ dependencies = [
"ai-prompter>=0.3,<1",
"esperanto>=2.19.3,<3",
"surrealdb>=1.0.4",
"podcast-creator>=0.9.4,<1",
"podcast-creator>=0.11.2,<1",
"surreal-commands>=1.3.1,<2",
"numpy>=2.4.1",
]

15
uv.lock
View file

@ -637,15 +637,15 @@ wheels = [
[[package]]
name = "esperanto"
version = "2.19.3"
version = "2.19.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/9d/51d65e448e52aa76ceaaa9eb4d50cebb7c16ead6508eb56bf159cc22718d/esperanto-2.19.3.tar.gz", hash = "sha256:3be09ac90bd976b9299f26ef829bd1a46d3aa6137e09700032512cad883d531a", size = 832672, upload-time = "2026-02-15T02:07:48.58Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/85/8b4761f16675a11fcc3615186ae8fdbb4612b0a05fe54b9ec29176c7301d/esperanto-2.19.4.tar.gz", hash = "sha256:c52506b1c9ff877a8c8723745826589f064af93395fa38a20bb4ad1c6cc71184", size = 833497, upload-time = "2026-02-17T21:17:08.962Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/9b/fc5963cf21165b1e4f135b365ce7d78c9d8fd64e959434ff05521ca11c30/esperanto-2.19.3-py3-none-any.whl", hash = "sha256:a0dfe5f65e24a4b892fbe43c0abc091ae10406bd40e9aed9b0372f75f7358d40", size = 202177, upload-time = "2026-02-15T02:07:50.213Z" },
{ url = "https://files.pythonhosted.org/packages/70/b4/32094bd554e8b8d574bc4ff6c25ec558f14da30244248e504eef9210250a/esperanto-2.19.4-py3-none-any.whl", hash = "sha256:af605d5f2e63382b2f4426fff97aee59f9e000dcd2d7ddebf94d9fd7199703e9", size = 202495, upload-time = "2026-02-17T21:17:10.083Z" },
]
[[package]]
@ -2168,7 +2168,7 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.2" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11.1" },
{ name = "numpy", specifier = ">=2.4.1" },
{ name = "podcast-creator", specifier = ">=0.9.4,<1" },
{ name = "podcast-creator", specifier = ">=0.11.2,<1" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" },
{ name = "pydantic", specifier = ">=2.9.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
@ -2519,7 +2519,7 @@ wheels = [
[[package]]
name = "podcast-creator"
version = "0.9.4"
version = "0.11.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ai-prompter" },
@ -2533,11 +2533,12 @@ dependencies = [
{ name = "pydub" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "tenacity" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/4a/9f23b55659d7d236645593a4b75141837ed88568ba6a6a370b01d97827e6/podcast_creator-0.9.4.tar.gz", hash = "sha256:9e40a77c105d0b02f04a3eef7881a34454ef556fabd8297fe68d50307ca5f926", size = 472357, upload-time = "2026-02-17T20:21:57.257Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/13/a1e4d01eacd385c018ffc92e74852ff90dbb64c5eb3779eba2862b466a0f/podcast_creator-0.11.2.tar.gz", hash = "sha256:3f5474323980427cc1764ebdb353c41002eac2bf8e28b74decf701f4ca6444dd", size = 480385, upload-time = "2026-02-18T00:03:56.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ac/b331aae683771964f0574189c8dbc1bc0c7b22aca9a376d61c3248180848/podcast_creator-0.9.4-py3-none-any.whl", hash = "sha256:2bd1138cbd1a4deda9da657e7e2b9c8a7d8c0cc43c649506af4837aeb708d46f", size = 74844, upload-time = "2026-02-17T20:21:58.271Z" },
{ url = "https://files.pythonhosted.org/packages/97/2e/c7e8d0c540d5296d26c43c5e41a7803906b86c3d79e8a57f9ee9a0f2b4ad/podcast_creator-0.11.2-py3-none-any.whl", hash = "sha256:17c9361306a6e7223e7f5d12fd4bdad4514231ce33df73ad3052e35cfdd92fd9", size = 77885, upload-time = "2026-02-18T00:03:55.804Z" },
]
[[package]]