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:
parent
96525b4457
commit
c666966b8c
19 changed files with 239 additions and 25 deletions
16
CHANGELOG.md
16
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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -699,6 +699,12 @@ export const jaJP = {
|
|||
speakerCountMax: "最大4人まで設定できます",
|
||||
delete: "削除",
|
||||
failedToDelete: "ポッドキャストの削除に失敗しました",
|
||||
retry: "再試行",
|
||||
retrying: "再試行中…",
|
||||
retryStarted: "再試行を開始しました",
|
||||
retryStartedDesc: "新しいポッドキャスト生成ジョブが送信されました。",
|
||||
failedToRetry: "再試行に失敗しました",
|
||||
errorDetails: "エラー詳細",
|
||||
},
|
||||
settings: {
|
||||
contentProcessing: "コンテンツ処理",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -699,6 +699,12 @@ export const ruRU = {
|
|||
speakerCountMax: "Можно настроить до 4 говорящих",
|
||||
delete: "Удалить",
|
||||
failedToDelete: "Не удалось удалить подкаст",
|
||||
retry: "Повторить",
|
||||
retrying: "Повтор…",
|
||||
retryStarted: "Повтор запущен",
|
||||
retryStartedDesc: "Новое задание на генерацию подкаста отправлено.",
|
||||
failedToRetry: "Не удалось повторить",
|
||||
errorDetails: "Подробности ошибки",
|
||||
},
|
||||
settings: {
|
||||
contentProcessing: "Обработка контента",
|
||||
|
|
|
|||
|
|
@ -699,6 +699,12 @@ export const zhCN = {
|
|||
speakerCountMax: "最多只能配置 4 个发言人",
|
||||
delete: "删除",
|
||||
failedToDelete: "删除播客失败",
|
||||
retry: "重试",
|
||||
retrying: "重试中…",
|
||||
retryStarted: "已开始重试",
|
||||
retryStartedDesc: "已提交新的播客生成任务。",
|
||||
failedToRetry: "重试失败",
|
||||
errorDetails: "错误详情",
|
||||
},
|
||||
settings: {
|
||||
contentProcessing: "内容处理",
|
||||
|
|
|
|||
|
|
@ -699,6 +699,12 @@ export const zhTW = {
|
|||
speakerCountMax: "最多只能設定 4 個發言人",
|
||||
delete: "刪除",
|
||||
failedToDelete: "刪除播客失敗",
|
||||
retry: "重試",
|
||||
retrying: "重試中…",
|
||||
retryStarted: "已開始重試",
|
||||
retryStartedDesc: "已提交新的播客生成任務。",
|
||||
failedToRetry: "重試失敗",
|
||||
errorDetails: "錯誤詳情",
|
||||
},
|
||||
settings: {
|
||||
contentProcessing: "內容處理",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
15
uv.lock
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue