fix: centralize ROOT_DIR resolution, fix workspace path on Render.com, cleanup legacy paths

- Upgrade utils/storage_paths.py with robust find_repo_root() (env var override + validation + fallback)
- Remove broken _find_root() from podcast/constants.py, import from storage_paths instead
- Fix ROOT_DIR resolving to backend/ instead of project root (caused avatar upload 500s on Render.com)
- Fix video_combination_service.py default output dir (was writing to data/media instead of workspace)
- Add deprecation comments to global data/media constants in media_utils.py
- Pass user_id through resolve_media_path for tenant-scoped podcast resolution
- Add ALWRITY_ROOT_DIR env var support for explicit production overrides
- Log warning when get_podcast_media_dir called without user_id
- Use OperationButton with cost display for scene action buttons
This commit is contained in:
ajaysi
2026-04-22 06:28:45 +05:30
parent 6e9c11744c
commit c5d625945f
5 changed files with 173 additions and 91 deletions

View File

@@ -2,31 +2,17 @@
Podcast API Constants Podcast API Constants
Centralized constants and directory configuration for podcast module. Centralized constants and directory configuration for podcast module.
All workspace paths use utils.storage_paths for root resolution.
""" """
import os
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from loguru import logger from loguru import logger
from services.story_writer.audio_generation_service import StoryAudioGenerationService from services.story_writer.audio_generation_service import StoryAudioGenerationService
from utils.storage_paths import get_repo_root, sanitize_user_id as _sanitize_user_id
# Directory paths ROOT_DIR = get_repo_root()
# Find root by looking for 'data' or 'backend' folder
def _find_root() -> Path:
"""Find project root by searching up for data directory."""
current = Path(__file__).resolve()
for _ in range(10): # max 10 levels up
if (current / "data").exists() and (current / "data" / "media").exists():
return current
if (current / "backend").exists():
return current / "backend"
parent = current.parent
if parent == current:
break
current = parent
# Fallback: assume backend is root
return Path(__file__).resolve().parents[1]
ROOT_DIR = _find_root()
# Video subdirectory (relative to workspace media dir) # Video subdirectory (relative to workspace media dir)
AI_VIDEO_SUBDIR = Path("AI_Videos") AI_VIDEO_SUBDIR = Path("AI_Videos")
@@ -38,10 +24,6 @@ PODCAST_AVATARS_SUBDIR = Path("avatars")
MediaType = Literal["audio", "image", "video", "chart"] MediaType = Literal["audio", "image", "video", "chart"]
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( def get_podcast_media_dir(
media_type: MediaType, media_type: MediaType,
user_id: str | None = None, user_id: str | None = None,
@@ -50,9 +32,10 @@ def get_podcast_media_dir(
) -> Path: ) -> Path:
""" """
Resolve podcast media directory (workspace-only for multi-tenant isolation). Resolve podcast media directory (workspace-only for multi-tenant isolation).
Always requires user_id for tenant isolation. Falls back to default workspace Requires user_id for tenant isolation. Falls back to default workspace
only if no user_id provided (for backward compat in development). only if no user_id provided (for backward compat in development).
Logs a warning in production when user_id is missing.
""" """
media_subdir = { media_subdir = {
"audio": "podcast_audio", "audio": "podcast_audio",
@@ -67,13 +50,11 @@ def get_podcast_media_dir(
ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir ROOT_DIR / "workspace" / f"workspace_{sanitized}" / "media" / media_subdir
).resolve() ).resolve()
else: else:
# Development fallback: use a default workspace logger.warning(f"[Podcast] get_podcast_media_dir called without user_id for {media_type} — using default workspace. This should not happen in production.")
resolved_dir = ( resolved_dir = (
ROOT_DIR / "workspace" / "workspace_alwrity" / "media" / media_subdir ROOT_DIR / "workspace" / "workspace_alwrity" / "media" / media_subdir
).resolve() ).resolve()
logger.warning(f"[Podcast] get_podcast_media_dir: type={media_type}, user_id={user_id}, resolved={resolved_dir}")
if ensure_exists: if ensure_exists:
resolved_dir.mkdir(parents=True, exist_ok=True) resolved_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -17,20 +17,26 @@ from loguru import logger
class PodcastVideoCombinationService: class PodcastVideoCombinationService:
"""Service for combining podcast scene videos into final episodes.""" """Service for combining podcast scene videos into final episodes."""
def __init__(self, output_dir: Optional[str] = None): def __init__(self, output_dir: Optional[str] = None, user_id: Optional[str] = None):
""" """
Initialize the podcast video combination service. Initialize the podcast video combination service.
Parameters: Parameters:
output_dir (str, optional): Directory to save combined videos. output_dir (str, optional): Directory to save combined videos.
Defaults to 'backend/podcast_videos/Final_Videos' if not provided. user_id (str, optional): User ID for workspace-scoped output.
Either output_dir or user_id must be provided for workspace isolation.
""" """
if output_dir: if output_dir:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
elif user_id:
from api.podcast.constants import get_podcast_media_dir
self.output_dir = get_podcast_media_dir("video", user_id, ensure_exists=True) / "Final_Videos"
else: else:
# Default to root/data/media/podcast_videos/Final_Videos directory from utils.storage_paths import get_user_workspace, sanitize_user_id
base_dir = Path(__file__).resolve().parents[3] logger.warning("[PodcastVideoCombination] No output_dir or user_id provided — using default workspace. This should not happen in production.")
self.output_dir = base_dir / "data" / "media" / "podcast_videos" / "Final_Videos" default_user = sanitize_user_id("alwrity")
self.output_dir = get_user_workspace(default_user) / "media" / "podcast_videos" / "Final_Videos"
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[PodcastVideoCombination] Initialized with output directory: {self.output_dir}") logger.info(f"[PodcastVideoCombination] Initialized with output directory: {self.output_dir}")

View File

@@ -3,6 +3,10 @@ Media Utility Functions
Centralized helper functions for loading and managing media assets across modules. Centralized helper functions for loading and managing media assets across modules.
Promotes reuse between Podcast, YouTube, and other media-heavy modules. Promotes reuse between Podcast, YouTube, and other media-heavy modules.
DEPRECATED: The global DATA_MEDIA_DIR paths below are legacy and will be removed.
New code should use workspace-scoped paths via utils.storage_paths or module-specific
resolvers (e.g., api.podcast.constants.get_podcast_media_dir).
""" """
import logging import logging
@@ -12,16 +16,19 @@ from typing import Optional, List
from urllib.parse import urlparse from urllib.parse import urlparse
from services.database import WORKSPACE_DIR from services.database import WORKSPACE_DIR
from utils.storage_paths import get_repo_root
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Base Directories # Base Directories — use get_repo_root() for consistent resolution
# backend/utils/media_utils.py -> parents[2] = backend/.. = root ROOT_DIR = get_repo_root()
ROOT_DIR = Path(__file__).resolve().parents[2]
# DEPRECATED: Global data/media paths — kept for backward-compat read fallback only.
# New writes must go to workspace-scoped paths. Do NOT add new consumers.
DATA_MEDIA_DIR = ROOT_DIR / "data" / "media" DATA_MEDIA_DIR = ROOT_DIR / "data" / "media"
# Module-specific directories # Module-specific directories (DEPRECATED — use workspace-scoped resolvers instead)
YOUTUBE_AVATARS_DIR = DATA_MEDIA_DIR / "youtube_avatars" YOUTUBE_AVATARS_DIR = DATA_MEDIA_DIR / "youtube_avatars"
YOUTUBE_IMAGES_DIR = DATA_MEDIA_DIR / "youtube_images" YOUTUBE_IMAGES_DIR = DATA_MEDIA_DIR / "youtube_images"
PODCAST_IMAGES_DIR = DATA_MEDIA_DIR / "podcast_images" PODCAST_IMAGES_DIR = DATA_MEDIA_DIR / "podcast_images"
@@ -33,7 +40,7 @@ def ensure_media_dirs() -> None:
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
def resolve_media_path(media_url_or_path: str) -> Optional[Path]: def resolve_media_path(media_url_or_path: str, user_id: Optional[str] = None) -> Optional[Path]:
""" """
Resolve a media URL or filename to a concrete file path on disk. Resolve a media URL or filename to a concrete file path on disk.
@@ -41,6 +48,7 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
Args: Args:
media_url_or_path: URL path (e.g. /api/youtube/avatars/foo.png) or filename media_url_or_path: URL path (e.g. /api/youtube/avatars/foo.png) or filename
user_id: Optional user ID for tenant-scoped resolution (recommended)
Returns: Returns:
Path object if found, None otherwise Path object if found, None otherwise
@@ -70,9 +78,9 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
parsed_path = urlparse(media_url_or_path).path parsed_path = urlparse(media_url_or_path).path
parts = parsed_path.split("/") parts = parsed_path.split("/")
if len(parts) >= 6: if len(parts) >= 6:
user_id = parts[3] asset_user_id = parts[3]
safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ("-", "_")) safe_user_id = "".join(c for c in asset_user_id if c.isalnum() or c in ("-", "_"))
if safe_user_id == user_id: if safe_user_id == asset_user_id:
safe_filename = os.path.basename(filename) safe_filename = os.path.basename(filename)
assets_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "avatars" / safe_filename assets_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "avatars" / safe_filename
if assets_path.exists() and assets_path.is_file(): if assets_path.exists() and assets_path.is_file():
@@ -82,7 +90,7 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
logger.error(f"[MediaUtils] Error resolving assets avatar path: {exc}") logger.error(f"[MediaUtils] Error resolving assets avatar path: {exc}")
# Define search paths in order of likelihood # Define search paths in order of likelihood
# We search all avatar/image directories # We search all avatar/image directories (DEPRECATED: global paths — kept for backward-compat reads)
search_paths: List[Path] = [ search_paths: List[Path] = [
YOUTUBE_AVATARS_DIR / filename, YOUTUBE_AVATARS_DIR / filename,
PODCAST_AVATARS_DIR / filename, PODCAST_AVATARS_DIR / filename,
@@ -101,7 +109,7 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
try: try:
# Import the centralized function that checks tenant workspace first # Import the centralized function that checks tenant workspace first
from api.podcast.constants import get_podcast_media_read_dirs from api.podcast.constants import get_podcast_media_read_dirs
podcast_dirs = get_podcast_media_read_dirs("image") podcast_dirs = get_podcast_media_read_dirs("image", user_id=user_id)
search_paths = [] search_paths = []
for pod_dir in podcast_dirs: for pod_dir in podcast_dirs:
# Add both avatar and image subdirectories # Add both avatar and image subdirectories

View File

@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
from loguru import logger
_SAFE_CHARS = {"-", "_"} _SAFE_CHARS = {"-", "_"}
@@ -16,9 +19,46 @@ def _sanitize_segment(value: str, fallback: str) -> str:
return cleaned or fallback return cleaned or fallback
def find_repo_root() -> Path:
"""Find the project repository root directory.
Resolution order:
1. ALWRITY_ROOT_DIR environment variable (explicit override for production)
2. Deterministic path from this file (storage_paths.py is at utils/)
3. Walk-up fallback looking for a 'backend/' directory at project root
Returns an absolute, resolved Path.
"""
env_root = os.environ.get("ALWRITY_ROOT_DIR")
if env_root:
root = Path(env_root).resolve()
if root.is_dir():
return root
# storage_paths.py is at backend/utils/storage_paths.py
# project root is parents[2] (utils -> backend -> root)
this_file = Path(__file__).resolve()
candidate = this_file.parents[2]
if (candidate / "backend").is_dir():
return candidate
# Walk-up fallback for unusual deployments
current = this_file.parent
for _ in range(10):
if (current / "backend").is_dir():
return current
parent = current.parent
if parent == current:
break
current = parent
return this_file.parents[2]
def get_repo_root() -> Path: def get_repo_root() -> Path:
"""Return repository root as an absolute canonical path.""" """Return repository root as an absolute canonical path."""
return Path(__file__).resolve().parents[2] return find_repo_root()
def get_workspace_root() -> Path: def get_workspace_root() -> Path:
@@ -67,3 +107,7 @@ def get_legacy_video_studio_upload_dirs() -> list[Path]:
(repo_root / "backend" / "data" / "video_studio" / "uploads").resolve(), (repo_root / "backend" / "data" / "video_studio" / "uploads").resolve(),
(repo_root / "backend" / "backend" / "data" / "video_studio" / "uploads").resolve(), (repo_root / "backend" / "backend" / "data" / "video_studio" / "uploads").resolve(),
] ]
# Log resolved root at import time for production debugging
logger.info(f"[StoragePaths] Repository root resolved to: {get_repo_root()}")

View File

@@ -12,7 +12,8 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Scene, Job } from "../types"; import { Scene, Job } from "../types";
import { PrimaryButton, SecondaryButton } from "../ui"; import { PrimaryButton, SecondaryButton } from "../ui";
import { Typography } from "@mui/material"; // Import Typography import { Typography } from "@mui/material";
import { OperationButton } from "../../shared/OperationButton";
interface SceneActionButtonsProps { interface SceneActionButtonsProps {
scene: Scene; scene: Scene;
@@ -94,14 +95,37 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
> >
Preview Sample Preview Sample
</SecondaryButton> </SecondaryButton>
<PrimaryButton <OperationButton
operation={{
provider: "audio",
model: "minimax/speech-02-hd",
tokens_requested: scene.lines.reduce((sum, l) => sum + l.text.length, 0),
operation_type: "tts_full_render",
actual_provider_name: "wavespeed",
}}
label="Generate Audio"
variant="contained"
size="medium"
startIcon={<PlayArrowIcon />}
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={() => onRender(scene.id, "full")} onClick={() => onRender(scene.id, "full")}
disabled={isBusy} disabled={isBusy}
startIcon={<PlayArrowIcon />} sx={{
tooltip="Generate the complete, production-ready audio for this scene" background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
> color: "white",
Generate Audio fontWeight: 600,
</PrimaryButton> textTransform: "none",
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
"&:disabled": {
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
}}
/>
</Stack> </Stack>
); );
} }
@@ -221,58 +245,77 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
</Tooltip> </Tooltip>
{/* Generate/Regenerate Image - ALWAYS visible if we have audio */} {/* Generate/Regenerate Image - ALWAYS visible if we have audio */}
<PrimaryButton <OperationButton
operation={{
provider: "stability",
operation_type: "image_generation",
actual_provider_name: "wavespeed",
}}
label={isGeneratingImage ? "Generating..." : hasImage ? "Regenerate Image" : "Generate Image"}
variant="contained"
size="medium"
startIcon={<ImageIcon />}
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={() => onImageGenerate(scene.id)} onClick={() => onImageGenerate(scene.id)}
disabled={isGeneratingImage} disabled={isGeneratingImage}
loading={isGeneratingImage} loading={isGeneratingImage}
startIcon={<ImageIcon />} sx={{
tooltip={
isGeneratingImage
? "Generating image..."
: hasImage
? "Regenerate image for this scene"
: "Generate image for video (optional)"
}
sx={{
minWidth: 160, minWidth: 160,
// Use secondary style if image exists (to de-emphasize), primary if needed background: hasImage ? alpha("#667eea", 0.1) : "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
background: hasImage ? alpha("#667eea", 0.1) : undefined, color: hasImage ? "#667eea" : "white",
color: hasImage ? "#667eea" : undefined,
border: hasImage ? "1px solid rgba(102,126,234,0.3)" : undefined, border: hasImage ? "1px solid rgba(102,126,234,0.3)" : undefined,
fontWeight: 600,
textTransform: "none",
"&:hover": { "&:hover": {
background: hasImage ? alpha("#667eea", 0.2) : undefined, background: hasImage ? alpha("#667eea", 0.2) : "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
} },
"&:disabled": {
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
}} }}
> />
{isGeneratingImage ? "Generating..." : hasImage ? "Regenerate Image" : "Generate Image"}
</PrimaryButton>
{/* Generate Video - ALWAYS visible if we have audio */} {/* Generate Video - ALWAYS visible if we have audio */}
<PrimaryButton <OperationButton
onClick={() => { operation={{
onVideoRender(scene.id); provider: "video",
model: "kling-v2.5-turbo-5s",
operation_type: "video_generation",
actual_provider_name: "wavespeed",
}} }}
disabled={isBusy || videoInProgress || !hasImage} label={
startIcon={<VideocamIcon />} videoInProgress && isCurrentVideo
tooltip={ ? "Generating Video..."
!hasImage : hasVideo
? "Generate an image first to create video" ? "Regenerate Video"
: videoInProgress : "Generate Video"
? "A video generation is already running. Please wait..."
: isBusy
? "Another operation in progress"
: hasVideo
? "Regenerate video"
: "Generate video for this scene"
} }
sx={{ minWidth: 180 }} variant="contained"
> size="medium"
{videoInProgress && isCurrentVideo startIcon={<VideocamIcon />}
? "Generating Video..." showCost={true}
: hasVideo checkOnHover={true}
? "Regenerate Video" checkOnMount={false}
: "Generate Video"} onClick={() => onVideoRender(scene.id)}
</PrimaryButton> disabled={isBusy || videoInProgress || !hasImage}
sx={{
minWidth: 180,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontWeight: 600,
textTransform: "none",
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
"&:disabled": {
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
}}
/>
{/* Download Video */} {/* Download Video */}
{hasVideo && job?.videoUrl && ( {hasVideo && job?.videoUrl && (