diff --git a/backend/api/podcast/constants.py b/backend/api/podcast/constants.py index 09a3d5f5..493af908 100644 --- a/backend/api/podcast/constants.py +++ b/backend/api/podcast/constants.py @@ -5,6 +5,7 @@ Centralized constants and directory configuration for podcast module. """ from pathlib import Path +from typing import Literal from services.story_writer.audio_generation_service import StoryAudioGenerationService # Directory paths @@ -17,15 +18,54 @@ ROOT_DIR = Path(__file__).resolve().parents[3] # root/ DATA_MEDIA_DIR = ROOT_DIR / "data" / "media" PODCAST_AUDIO_DIR = (DATA_MEDIA_DIR / "podcast_audio").resolve() -PODCAST_AUDIO_DIR.mkdir(parents=True, exist_ok=True) PODCAST_IMAGES_DIR = (DATA_MEDIA_DIR / "podcast_images").resolve() -PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True) PODCAST_VIDEOS_DIR = (DATA_MEDIA_DIR / "podcast_videos").resolve() -PODCAST_VIDEOS_DIR.mkdir(parents=True, exist_ok=True) # Video subdirectory AI_VIDEO_SUBDIR = Path("AI_Videos") -# Initialize audio service -audio_service = StoryAudioGenerationService(output_dir=str(PODCAST_AUDIO_DIR)) +MediaType = Literal["audio", "image", "video"] + +def _sanitize_user_id(user_id: str) -> str: + return "".join(c for c in user_id if c.isalnum() or c in ("-", "_")) + + +def get_podcast_media_dir( + media_type: MediaType, + user_id: str | None = None, + *, + ensure_exists: bool = False, +) -> Path: + """Resolve podcast media directory (tenant workspace first, legacy global fallback).""" + media_subdir = { + "audio": "podcast_audio", + "image": "podcast_images", + "video": "podcast_videos", + }[media_type] + + if user_id: + tenant_media_dir = ROOT_DIR / "workspace" / f"workspace_{_sanitize_user_id(user_id)}" / "media" / media_subdir + resolved_dir = tenant_media_dir.resolve() + else: + resolved_dir = (DATA_MEDIA_DIR / media_subdir).resolve() + + if ensure_exists: + resolved_dir.mkdir(parents=True, exist_ok=True) + + return resolved_dir + + +def get_podcast_media_read_dirs(media_type: MediaType, user_id: str | None = None) -> list[Path]: + """Return ordered directories to search (tenant path first, then legacy global path).""" + dirs: list[Path] = [] + if user_id: + dirs.append(get_podcast_media_dir(media_type, user_id)) + dirs.append(get_podcast_media_dir(media_type, None)) + return dirs + + +def get_podcast_audio_service(user_id: str | None = None) -> StoryAudioGenerationService: + """Build audio service lazily so directory creation happens only when needed.""" + output_dir = get_podcast_media_dir("audio", user_id, ensure_exists=True) + return StoryAudioGenerationService(output_dir=str(output_dir)) diff --git a/backend/api/podcast/handlers/audio.py b/backend/api/podcast/handlers/audio.py index c6518a9f..b2ace1b8 100644 --- a/backend/api/podcast/handlers/audio.py +++ b/backend/api/podcast/handlers/audio.py @@ -20,7 +20,8 @@ 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 ..constants import get_podcast_audio_service, get_podcast_media_dir +from ..utils import _resolve_podcast_media_file from ..models import ( PodcastAudioRequest, PodcastAudioResponse, @@ -62,7 +63,8 @@ async def upload_podcast_audio( file_ext = Path(file.filename).suffix or '.mp3' unique_id = str(uuid.uuid4())[:8] audio_filename = f"audio_{project_id or 'temp'}_{unique_id}{file_ext}" - audio_path = PODCAST_AUDIO_DIR / audio_filename + audio_base_dir = get_podcast_media_dir("audio", user_id, ensure_exists=True) + audio_path = audio_base_dir / audio_filename # Save file with open(audio_path, "wb") as f: @@ -123,6 +125,7 @@ async def generate_podcast_audio( raise HTTPException(status_code=400, detail="Text is required") try: + audio_service = get_podcast_audio_service(user_id) result: StoryAudioResult = audio_service.generate_ai_audio( scene_number=0, scene_title=request.scene_title, @@ -267,12 +270,7 @@ async def combine_podcast_audio( 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 + audio_path = _resolve_podcast_media_file(filename, "audio", user_id) else: logger.warning(f"[Podcast] Non-API URL format, treating as direct path: {audio_url}") audio_path = Path(audio_url) @@ -303,7 +301,8 @@ async def combine_podcast_audio( # Generate output filename output_filename = f"podcast_combined_{request.project_id}_{uuid.uuid4().hex[:8]}.mp3" - output_path = PODCAST_AUDIO_DIR / output_filename + audio_base_dir = get_podcast_media_dir("audio", user_id, ensure_exists=True) + output_path = audio_base_dir / output_filename # Write combined audio file combined_audio.write_audiofile( @@ -382,20 +381,13 @@ async def serve_podcast_audio( Supports authentication via Authorization header or token query parameter. Query parameter is useful for HTML elements like