Refactor podcast media storage to lazy tenant resolver

This commit is contained in:
ي
2026-03-12 14:59:03 +05:30
parent bc49329ed6
commit d4baf8828e
4 changed files with 133 additions and 97 deletions

View File

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

View File

@@ -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 <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")
user_id = require_authenticated_user(current_user)
audio_path = _resolve_podcast_media_file(filename, "audio", user_id)
return FileResponse(audio_path, media_type="audio/mpeg")

View File

@@ -24,8 +24,8 @@ from services.subscription import PricingService
from services.subscription.preflight_validator import validate_scene_animation_operation
from api.story_writer.task_manager import task_manager
from loguru import logger
from ..constants import AI_VIDEO_SUBDIR, PODCAST_VIDEOS_DIR
from ..utils import load_podcast_audio_bytes, load_podcast_image_bytes
from ..constants import AI_VIDEO_SUBDIR, get_podcast_media_dir, get_podcast_media_read_dirs
from ..utils import _resolve_podcast_media_file, load_podcast_audio_bytes, load_podcast_image_bytes
from services.podcast_service import PodcastService
from ..models import (
PodcastVideoGenerationRequest,
@@ -164,10 +164,11 @@ def _execute_podcast_video_task(
task_id, "processing", progress=80.0, message="Saving video file..."
)
# Use podcast-specific video directory
ai_video_dir = PODCAST_VIDEOS_DIR / AI_VIDEO_SUBDIR
# Use podcast-specific tenant workspace video directory
videos_base_dir = get_podcast_media_dir("video", user_id, ensure_exists=True)
ai_video_dir = videos_base_dir / AI_VIDEO_SUBDIR
ai_video_dir.mkdir(parents=True, exist_ok=True)
video_service = PodcastVideoCombinationService(output_dir=str(PODCAST_VIDEOS_DIR / "Final_Videos"))
video_service = PodcastVideoCombinationService(output_dir=str(videos_base_dir / "Final_Videos"))
save_result = video_service.save_scene_video(
video_bytes=animation_result["video_bytes"],
@@ -277,7 +278,7 @@ async def generate_podcast_video(
)
# Load audio bytes
audio_bytes = load_podcast_audio_bytes(body.audio_url)
audio_bytes = load_podcast_audio_bytes(body.audio_url, user_id)
# Validate resolution
if body.resolution not in {"480p", "720p"}:
@@ -354,24 +355,25 @@ async def serve_podcast_video(
Supports authentication via Authorization header or token query parameter.
Query parameter is useful for HTML elements like <video> 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")
# Look for video in podcast_videos directory (including AI_Videos subdirectory)
user_id = require_authenticated_user(current_user)
# Look for video in tenant workspace first, then legacy podcast_videos directory
video_path = None
possible_paths = [
PODCAST_VIDEOS_DIR / filename,
PODCAST_VIDEOS_DIR / AI_VIDEO_SUBDIR / filename,
]
for path in possible_paths:
resolved_path = path.resolve()
# Security check: ensure path is within PODCAST_VIDEOS_DIR
if str(resolved_path).startswith(str(PODCAST_VIDEOS_DIR)) and resolved_path.exists():
video_path = resolved_path
for base_dir in get_podcast_media_read_dirs("video", user_id):
possible_paths = [
base_dir / filename,
base_dir / AI_VIDEO_SUBDIR / filename,
]
for path in possible_paths:
resolved_path = path.resolve()
if str(resolved_path).startswith(str(base_dir.resolve())) and resolved_path.exists():
video_path = resolved_path
break
if video_path:
break
if not video_path:
@@ -394,39 +396,29 @@ async def list_podcast_videos(
logger.info(f"[Podcast] Listing videos for user_id={user_id}, project_id={project_id}")
# Look in podcast_videos/AI_Videos directory
ai_video_dir = PODCAST_VIDEOS_DIR / AI_VIDEO_SUBDIR
ai_video_dir.mkdir(parents=True, exist_ok=True)
videos = []
if ai_video_dir.exists():
# Pattern: scene_{scene_number}_{user_id}_{timestamp}.mp4
# Extract user_id from current user (same logic as save_scene_video)
clean_user_id = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in user_id[:16])
# Pattern: scene_{scene_number}_{user_id}_{timestamp}.mp4
clean_user_id = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in user_id[:16])
scene_video_map: Dict[int, Dict[str, Any]] = {}
for base_dir in get_podcast_media_read_dirs("video", user_id):
ai_video_dir = base_dir / AI_VIDEO_SUBDIR
if not ai_video_dir.exists():
continue
logger.info(f"[Podcast] Looking for videos with clean_user_id={clean_user_id} in {ai_video_dir}")
# Map scene_number -> (most recent video info)
scene_video_map: Dict[int, Dict[str, Any]] = {}
all_files = list(ai_video_dir.glob("*.mp4"))
logger.info(f"[Podcast] Found {len(all_files)} MP4 files in directory")
for video_file in all_files:
filename = video_file.name
# Match pattern: scene_{number}_{user_id}_{hash}.mp4
# Use greedy match for user_id and match hash as "anything except underscore before .mp4"
match = re.match(r"scene_(\d+)_(.+)_([^_]+)\.mp4", filename)
if match:
scene_number = int(match.group(1))
file_user_id = match.group(2)
hash_part = match.group(3)
# Only include videos for this user
if file_user_id == clean_user_id:
video_url = f"/api/podcast/videos/{filename}"
file_mtime = video_file.stat().st_mtime
# Keep the most recent video for each scene
if scene_number not in scene_video_map or file_mtime > scene_video_map[scene_number]["mtime"]:
scene_video_map[scene_number] = {
"scene_number": scene_number,
@@ -435,15 +427,10 @@ async def list_podcast_videos(
"file_size": video_file.stat().st_size,
"mtime": file_mtime,
}
# Convert map to list and sort by scene number
videos = list(scene_video_map.values())
videos.sort(key=lambda v: v["scene_number"])
logger.info(f"[Podcast] Returning {len(videos)} videos for user: {[v['scene_number'] for v in videos]}")
else:
logger.warning(f"[Podcast] Video directory does not exist: {ai_video_dir}")
videos = list(scene_video_map.values())
videos.sort(key=lambda v: v["scene_number"])
logger.info(f"[Podcast] Returning {len(videos)} videos for user: {[v['scene_number'] for v in videos]}")
return {"videos": videos}
except Exception as e:
@@ -526,10 +513,10 @@ def _execute_combine_videos_task(
for video_url in scene_video_urls:
# Extract filename from URL (e.g., /api/podcast/videos/scene_1_user_xxx.mp4)
filename = video_url.split("/")[-1].split("?")[0] # Remove query params
video_path = PODCAST_VIDEOS_DIR / AI_VIDEO_SUBDIR / filename
if not video_path.exists():
logger.warning(f"[Podcast] Scene video not found: {video_path}")
try:
video_path = _resolve_podcast_media_file(filename, "video", user_id, subdir=AI_VIDEO_SUBDIR)
except HTTPException:
logger.warning(f"[Podcast] Scene video not found: {filename}")
continue
scene_video_paths.append(str(video_path))
@@ -544,7 +531,8 @@ def _execute_combine_videos_task(
)
# Use dedicated PodcastVideoCombinationService
final_videos_dir = PODCAST_VIDEOS_DIR / "Final_Videos"
videos_base_dir = get_podcast_media_dir("video", user_id, ensure_exists=True)
final_videos_dir = videos_base_dir / "Final_Videos"
final_videos_dir.mkdir(parents=True, exist_ok=True)
video_service = PodcastVideoCombinationService(output_dir=str(final_videos_dir))
@@ -627,11 +615,15 @@ async def serve_final_podcast_video(
):
"""Serve the final combined podcast video with authentication."""
user_id = require_authenticated_user(current_user)
final_videos_dir = PODCAST_VIDEOS_DIR / "Final_Videos"
video_path = final_videos_dir / filename
if not video_path.exists():
video_path = None
for base_dir in get_podcast_media_read_dirs("video", user_id):
candidate = (base_dir / "Final_Videos" / filename).resolve()
if str(candidate).startswith(str(base_dir.resolve())) and candidate.exists():
video_path = candidate
break
if not video_path:
raise HTTPException(status_code=404, detail="Video not found")
# Basic security: ensure filename doesn't contain path traversal

View File

@@ -9,11 +9,35 @@ from urllib.parse import urlparse
from fastapi import HTTPException
from loguru import logger
from .constants import PODCAST_AUDIO_DIR, PODCAST_IMAGES_DIR
from .constants import get_podcast_media_read_dirs
from utils.media_utils import load_media_bytes
def load_podcast_audio_bytes(audio_url: str) -> bytes:
def _resolve_podcast_media_file(
filename: str,
media_type: str,
user_id: str | None = None,
*,
subdir: Path | None = None,
) -> Path:
"""Resolve podcast media file path from tenant workspace first, then legacy global dir."""
clean_filename = filename.split("?", 1)[0].strip()
if not clean_filename:
raise HTTPException(status_code=400, detail="Invalid filename")
for base_dir in get_podcast_media_read_dirs(media_type, user_id):
target_dir = (base_dir / subdir).resolve() if subdir else base_dir.resolve()
candidate = (target_dir / clean_filename).resolve()
if not str(candidate).startswith(str(target_dir)):
logger.error(f"[Podcast] Attempted path traversal for {media_type}: {filename}")
raise HTTPException(status_code=403, detail="Invalid media path")
if candidate.exists():
return candidate
raise HTTPException(status_code=404, detail=f"{media_type.capitalize()} file not found: {clean_filename}")
def load_podcast_audio_bytes(audio_url: str, user_id: str | None = None) -> bytes:
"""Load podcast audio bytes from URL. Only handles /api/podcast/audio/ URLs."""
if not audio_url:
raise HTTPException(status_code=400, detail="Audio URL is required")
@@ -33,19 +57,8 @@ def load_podcast_audio_bytes(audio_url: str) -> bytes:
filename = path.split(prefix, 1)[1].split("?", 1)[0].strip()
if not filename:
raise HTTPException(status_code=400, detail=f"Could not extract filename from URL: {audio_url}")
# 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}")
raise HTTPException(status_code=403, detail="Invalid audio path")
if not audio_path.exists():
logger.warning(f"[Podcast] Audio file not found: {audio_path}")
raise HTTPException(status_code=404, detail=f"Audio file not found: {filename}")
audio_path = _resolve_podcast_media_file(filename, "audio", user_id)
return audio_path.read_bytes()
except HTTPException:
raise
@@ -77,4 +90,3 @@ def load_podcast_image_bytes(image_url: str) -> bytes:
except Exception as exc:
logger.error(f"[Podcast] Failed to load image: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to load image: {str(exc)}")