Base code
This commit is contained in:
324
backend/api/podcast/handlers/audio.py
Normal file
324
backend/api/podcast/handlers/audio.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Podcast Audio Handlers
|
||||
|
||||
Audio generation, combining, and serving endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
import tempfile
|
||||
import uuid
|
||||
import shutil
|
||||
|
||||
from services.database import get_db
|
||||
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
from models.story_models import StoryAudioResult
|
||||
from loguru import logger
|
||||
from ..constants import PODCAST_AUDIO_DIR, audio_service
|
||||
from ..models import (
|
||||
PodcastAudioRequest,
|
||||
PodcastAudioResponse,
|
||||
PodcastCombineAudioRequest,
|
||||
PodcastCombineAudioResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/audio", response_model=PodcastAudioResponse)
|
||||
async def generate_podcast_audio(
|
||||
request: PodcastAudioRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate AI audio for a podcast scene using shared audio service.
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not request.text or not request.text.strip():
|
||||
raise HTTPException(status_code=400, detail="Text is required")
|
||||
|
||||
try:
|
||||
result: StoryAudioResult = audio_service.generate_ai_audio(
|
||||
scene_number=0,
|
||||
scene_title=request.scene_title,
|
||||
text=request.text.strip(),
|
||||
user_id=user_id,
|
||||
voice_id=request.voice_id or "Wise_Woman",
|
||||
speed=request.speed or 1.0, # Normal speed (was 0.9, but too slow - causing duration issues)
|
||||
volume=request.volume or 1.0,
|
||||
pitch=request.pitch or 0.0, # Normal pitch (0.0 = neutral)
|
||||
emotion=request.emotion or "neutral",
|
||||
english_normalization=request.english_normalization or False,
|
||||
sample_rate=request.sample_rate,
|
||||
bitrate=request.bitrate,
|
||||
channel=request.channel,
|
||||
format=request.format,
|
||||
language_boost=request.language_boost,
|
||||
enable_sync_mode=request.enable_sync_mode,
|
||||
)
|
||||
|
||||
# Override URL to use podcast endpoint instead of story endpoint
|
||||
if result.get("audio_url") and "/api/story/audio/" in result.get("audio_url", ""):
|
||||
audio_filename = result.get("audio_filename", "")
|
||||
result["audio_url"] = f"/api/podcast/audio/{audio_filename}"
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}")
|
||||
|
||||
# Save to asset library (podcast module)
|
||||
try:
|
||||
if result.get("audio_url"):
|
||||
save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="audio",
|
||||
source_module="podcast_maker",
|
||||
filename=result.get("audio_filename", ""),
|
||||
file_url=result.get("audio_url", ""),
|
||||
file_path=result.get("audio_path"),
|
||||
file_size=result.get("file_size"),
|
||||
mime_type="audio/mpeg",
|
||||
title=f"{request.scene_title} - Podcast",
|
||||
description="Podcast scene narration",
|
||||
tags=["podcast", "audio", request.scene_id],
|
||||
provider=result.get("provider"),
|
||||
model=result.get("model"),
|
||||
cost=result.get("cost"),
|
||||
asset_metadata={
|
||||
"scene_id": request.scene_id,
|
||||
"scene_title": request.scene_title,
|
||||
"status": "completed",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Podcast] Failed to save audio asset: {e}")
|
||||
|
||||
return PodcastAudioResponse(
|
||||
scene_id=request.scene_id,
|
||||
scene_title=request.scene_title,
|
||||
audio_filename=result.get("audio_filename", ""),
|
||||
audio_url=result.get("audio_url", ""),
|
||||
provider=result.get("provider", "wavespeed"),
|
||||
model=result.get("model", "minimax/speech-02-hd"),
|
||||
voice_id=result.get("voice_id", request.voice_id or "Wise_Woman"),
|
||||
text_length=result.get("text_length", len(request.text)),
|
||||
file_size=result.get("file_size", 0),
|
||||
cost=result.get("cost", 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/combine-audio", response_model=PodcastCombineAudioResponse)
|
||||
async def combine_podcast_audio(
|
||||
request: PodcastCombineAudioRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Combine multiple scene audio files into a single podcast audio file.
|
||||
"""
|
||||
user_id = require_authenticated_user(current_user)
|
||||
|
||||
if not request.scene_ids or not request.scene_audio_urls:
|
||||
raise HTTPException(status_code=400, detail="Scene IDs and audio URLs are required")
|
||||
|
||||
if len(request.scene_ids) != len(request.scene_audio_urls):
|
||||
raise HTTPException(status_code=400, detail="Scene IDs and audio URLs count must match")
|
||||
|
||||
try:
|
||||
# Import moviepy for audio concatenation
|
||||
try:
|
||||
from moviepy import AudioFileClip, concatenate_audioclips
|
||||
except ImportError:
|
||||
logger.error("[Podcast] MoviePy not available for audio combination")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Audio combination requires MoviePy. Please install: pip install moviepy"
|
||||
)
|
||||
|
||||
# Create temporary directory for audio processing
|
||||
temp_dir = Path(tempfile.gettempdir()) / f"podcast_combine_{uuid.uuid4().hex[:8]}"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
audio_clips = []
|
||||
total_duration = 0.0
|
||||
|
||||
try:
|
||||
# Log incoming request for debugging
|
||||
logger.info(f"[Podcast] Combining audio: {len(request.scene_audio_urls)} URLs received")
|
||||
for idx, url in enumerate(request.scene_audio_urls):
|
||||
logger.info(f"[Podcast] URL {idx+1}: {url}")
|
||||
|
||||
# Download and load each audio file from podcast_audio directory
|
||||
for idx, audio_url in enumerate(request.scene_audio_urls):
|
||||
try:
|
||||
# Normalize audio URL - handle both absolute and relative paths
|
||||
if audio_url.startswith("http"):
|
||||
# External URL - would need to download
|
||||
logger.error(f"[Podcast] External URLs not supported: {audio_url}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"External URLs not supported. Please use local file paths."
|
||||
)
|
||||
|
||||
# Handle relative paths - only /api/podcast/audio/... URLs are supported
|
||||
audio_path = None
|
||||
if audio_url.startswith("/api/"):
|
||||
# Extract filename from URL
|
||||
parsed = urlparse(audio_url)
|
||||
path = parsed.path if parsed.scheme else audio_url
|
||||
|
||||
# Handle both /api/podcast/audio/ and /api/story/audio/ URLs (for backward compatibility)
|
||||
if "/api/podcast/audio/" in path:
|
||||
filename = path.split("/api/podcast/audio/", 1)[1].split("?", 1)[0].strip()
|
||||
elif "/api/story/audio/" in path:
|
||||
# Convert story audio URLs to podcast audio (they're in the same directory now)
|
||||
filename = path.split("/api/story/audio/", 1)[1].split("?", 1)[0].strip()
|
||||
logger.info(f"[Podcast] Converting story audio URL to podcast: {audio_url} -> {filename}")
|
||||
else:
|
||||
logger.error(f"[Podcast] Unsupported audio URL format: {audio_url}. Expected /api/podcast/audio/ or /api/story/audio/ URLs.")
|
||||
continue
|
||||
|
||||
if not filename:
|
||||
logger.error(f"[Podcast] Could not extract filename from URL: {audio_url}")
|
||||
continue
|
||||
|
||||
# Podcast audio files are stored in podcast_audio directory
|
||||
audio_path = (PODCAST_AUDIO_DIR / filename).resolve()
|
||||
|
||||
# Security check: ensure path is within PODCAST_AUDIO_DIR
|
||||
if not str(audio_path).startswith(str(PODCAST_AUDIO_DIR)):
|
||||
logger.error(f"[Podcast] Attempted path traversal when resolving audio: {audio_url}")
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"[Podcast] Non-API URL format, treating as direct path: {audio_url}")
|
||||
audio_path = Path(audio_url)
|
||||
|
||||
if not audio_path or not audio_path.exists():
|
||||
logger.error(f"[Podcast] Audio file not found: {audio_path} (from URL: {audio_url})")
|
||||
continue
|
||||
|
||||
# Load audio clip
|
||||
audio_clip = AudioFileClip(str(audio_path))
|
||||
audio_clips.append(audio_clip)
|
||||
total_duration += audio_clip.duration
|
||||
logger.info(f"[Podcast] Loaded audio {idx+1}/{len(request.scene_audio_urls)}: {audio_path.name} ({audio_clip.duration:.2f}s)")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Podcast] Failed to load audio {idx+1}: {e}", exc_info=True)
|
||||
# Continue with other audio files
|
||||
continue
|
||||
|
||||
if not audio_clips:
|
||||
raise HTTPException(status_code=400, detail="No valid audio files found to combine")
|
||||
|
||||
# Concatenate all audio clips
|
||||
logger.info(f"[Podcast] Combining {len(audio_clips)} audio clips (total duration: {total_duration:.2f}s)")
|
||||
combined_audio = concatenate_audioclips(audio_clips)
|
||||
|
||||
# Generate output filename
|
||||
output_filename = f"podcast_combined_{request.project_id}_{uuid.uuid4().hex[:8]}.mp3"
|
||||
output_path = PODCAST_AUDIO_DIR / output_filename
|
||||
|
||||
# Write combined audio file
|
||||
combined_audio.write_audiofile(
|
||||
str(output_path),
|
||||
codec="mp3",
|
||||
bitrate="192k",
|
||||
logger=None, # Suppress moviepy logging
|
||||
)
|
||||
|
||||
# Close audio clips to free resources
|
||||
for clip in audio_clips:
|
||||
clip.close()
|
||||
combined_audio.close()
|
||||
|
||||
file_size = output_path.stat().st_size
|
||||
audio_url = f"/api/podcast/audio/{output_filename}"
|
||||
|
||||
logger.info(f"[Podcast] Combined audio saved: {output_path} ({file_size} bytes)")
|
||||
|
||||
# Save to asset library
|
||||
try:
|
||||
save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="audio",
|
||||
source_module="podcast_maker",
|
||||
filename=output_filename,
|
||||
file_url=audio_url,
|
||||
file_path=str(output_path),
|
||||
file_size=file_size,
|
||||
mime_type="audio/mpeg",
|
||||
title=f"Combined Podcast - {request.project_id}",
|
||||
description=f"Combined podcast audio from {len(request.scene_ids)} scenes",
|
||||
tags=["podcast", "audio", "combined", request.project_id],
|
||||
asset_metadata={
|
||||
"project_id": request.project_id,
|
||||
"scene_ids": request.scene_ids,
|
||||
"scene_count": len(request.scene_ids),
|
||||
"total_duration": total_duration,
|
||||
"status": "completed",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Podcast] Failed to save combined audio asset: {e}")
|
||||
|
||||
return PodcastCombineAudioResponse(
|
||||
combined_audio_url=audio_url,
|
||||
combined_audio_filename=output_filename,
|
||||
total_duration=total_duration,
|
||||
file_size=file_size,
|
||||
scene_count=len(request.scene_ids),
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup temporary directory
|
||||
try:
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Podcast] Failed to cleanup temp directory: {e}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"[Podcast] Audio combination failed: {exc}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Audio combination failed: {exc}")
|
||||
|
||||
|
||||
@router.get("/audio/{filename}")
|
||||
async def serve_podcast_audio(
|
||||
filename: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
||||
):
|
||||
"""Serve generated podcast scene audio files.
|
||||
|
||||
Supports authentication via Authorization header or token query parameter.
|
||||
Query parameter is useful for HTML elements like <audio> that cannot send custom headers.
|
||||
"""
|
||||
require_authenticated_user(current_user)
|
||||
|
||||
# Security check: ensure filename doesn't contain path traversal
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
audio_path = (PODCAST_AUDIO_DIR / filename).resolve()
|
||||
|
||||
# Security check: ensure path is within PODCAST_AUDIO_DIR
|
||||
if not str(audio_path).startswith(str(PODCAST_AUDIO_DIR)):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if not audio_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
return FileResponse(audio_path, media_type="audio/mpeg")
|
||||
|
||||
Reference in New Issue
Block a user