From b108bb1cb183df0581daf72b7f11b41e09728bfd Mon Sep 17 00:00:00 2001 From: James Pine Date: Fri, 20 Mar 2026 15:06:07 -0700 Subject: [PATCH] fix: store media paths relative to data dir --- backend/config.py | 47 +++++++++++++++++++++++++++++++ backend/database/migrations.py | 38 ++++++++----------------- backend/database/seed.py | 6 ++-- backend/routes/audio.py | 16 +++++------ backend/routes/effects.py | 13 +++++---- backend/routes/history.py | 7 ++--- backend/routes/profiles.py | 6 ++-- backend/services/export_import.py | 20 +++++++------ backend/services/generation.py | 14 +++++---- backend/services/history.py | 8 +++--- backend/services/profiles.py | 28 +++++++++++------- backend/services/stories.py | 5 ++-- backend/services/versions.py | 8 +++--- 13 files changed, 130 insertions(+), 86 deletions(-) diff --git a/backend/config.py b/backend/config.py index ecfc3920..959731c6 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,6 +22,21 @@ if _custom_models_dir: _data_dir = Path("data").resolve() +def _path_relative_to_any_data_dir(path: Path) -> Path | None: + """Extract the path within a data dir from an absolute or relative path.""" + parts = path.parts + for idx, part in enumerate(parts): + if part != "data": + continue + + tail = parts[idx + 1 :] + if tail: + return Path(*tail) + return Path() + + return None + + def set_data_dir(path: str | Path): """ Set the data directory path. @@ -45,6 +60,38 @@ def get_data_dir() -> Path: return _data_dir +def to_storage_path(path: str | Path) -> str: + """Convert a filesystem path to a DB-safe path relative to the data dir.""" + resolved_path = Path(path).resolve() + + relative_to_any_data_dir = _path_relative_to_any_data_dir(resolved_path) + if relative_to_any_data_dir is not None: + return str(relative_to_any_data_dir) + + try: + return str(resolved_path.relative_to(_data_dir)) + except ValueError: + return str(resolved_path) + + +def resolve_storage_path(path: str | Path | None) -> Path | None: + """Resolve a DB-stored path against the configured data dir.""" + if path is None: + return None + + stored_path = Path(path) + if stored_path.is_absolute(): + rebased_path = _path_relative_to_any_data_dir(stored_path) + if rebased_path is not None: + candidate = (_data_dir / rebased_path).resolve() + if candidate.exists() or not stored_path.exists(): + return candidate + + return stored_path + + return (_data_dir / stored_path).resolve() + + def get_db_path() -> Path: """Get database file path.""" return _data_dir / "voicebox.db" diff --git a/backend/database/migrations.py b/backend/database/migrations.py index 6256d92b..2bdd9282 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -34,7 +34,7 @@ def run_migrations(engine) -> None: _migrate_generations(engine, inspector, tables) _migrate_effect_presets(engine, inspector, tables) _migrate_generation_versions(engine, inspector, tables) - _resolve_relative_paths(engine, tables) + _normalize_storage_paths(engine, tables) # -- helpers --------------------------------------------------------------- @@ -182,24 +182,11 @@ def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None: _add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id") -def _resolve_relative_paths(engine, tables: set[str]) -> None: - """Resolve any relative file paths in the database to absolute paths. - - Earlier versions stored paths relative to CWD (e.g. "data/generations/abc.wav"). - These break when the production binary's CWD differs from the data directory. - This migration converts them to absolute paths using the configured data dir. - Idempotent: absolute paths are left untouched. - - Strategy: paths like "data/generations/abc.wav" are rebased onto the - configured data directory. If the path starts with "data/", strip that - prefix and prepend get_data_dir(). Otherwise, join the relative path - directly under get_data_dir(). - directly under get_data_dir(). If the rebased path still does not exist, - fall back to resolving relative to CWD. - """ +def _normalize_storage_paths(engine, tables: set[str]) -> None: + """Normalize stored file paths to be relative to the configured data dir.""" from pathlib import Path - from ..config import get_data_dir + from ..config import get_data_dir, to_storage_path, resolve_storage_path data_dir = get_data_dir() @@ -222,21 +209,18 @@ def _resolve_relative_paths(engine, tables: set[str]) -> None: if not path_val: continue p = Path(path_val) - if p.is_absolute(): + resolved = resolve_storage_path(p) + if resolved is None: continue - parts = p.parts - if parts and parts[0] == "data": - rebased = (data_dir / Path(*parts[1:])).resolve() - else: - rebased = (data_dir / p).resolve() - resolved = rebased if rebased.exists() else p.resolve() - if resolved.exists(): + normalized = to_storage_path(resolved) + + if normalized != path_val: conn.execute( text(f"UPDATE {table} SET {column} = :path WHERE id = :id"), - {"path": str(resolved), "id": row_id}, + {"path": normalized, "id": row_id}, ) total_fixed += 1 if total_fixed > 0: conn.commit() - logger.info("Resolved %d relative file paths to absolute", total_fixed) + logger.info("Normalized %d stored file paths", total_fixed) diff --git a/backend/database/seed.py b/backend/database/seed.py index b62edc22..b09e0b8f 100644 --- a/backend/database/seed.py +++ b/backend/database/seed.py @@ -3,7 +3,8 @@ import json import logging import uuid -from pathlib import Path + +from .. import config logger = logging.getLogger(__name__) @@ -25,7 +26,8 @@ def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) -> for gen in generations: if gen.id in existing_version_gen_ids: continue - if not Path(gen.audio_path).exists(): + resolved_audio_path = config.resolve_storage_path(gen.audio_path) + if resolved_audio_path is None or not resolved_audio_path.exists(): continue version = GenerationVersion( id=str(uuid.uuid4()), diff --git a/backend/routes/audio.py b/backend/routes/audio.py index 682d7aae..f80a44d5 100644 --- a/backend/routes/audio.py +++ b/backend/routes/audio.py @@ -1,12 +1,10 @@ """Audio file serving endpoints.""" -from pathlib import Path - from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from .. import models +from .. import config, models from ..services import history from ..database import get_db @@ -22,8 +20,8 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)): if not version: raise HTTPException(status_code=404, detail="Version not found") - audio_path = Path(version.audio_path) - if not audio_path.exists(): + audio_path = config.resolve_storage_path(version.audio_path) + if audio_path is None or not audio_path.exists(): raise HTTPException(status_code=404, detail="Audio file not found") return FileResponse( @@ -40,8 +38,8 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)): if not generation: raise HTTPException(status_code=404, detail="Generation not found") - audio_path = Path(generation.audio_path) - if not audio_path.exists(): + audio_path = config.resolve_storage_path(generation.audio_path) + if audio_path is None or not audio_path.exists(): raise HTTPException(status_code=404, detail="Audio file not found") return FileResponse( @@ -60,8 +58,8 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)): if not sample: raise HTTPException(status_code=404, detail="Sample not found") - audio_path = Path(sample.audio_path) - if not audio_path.exists(): + audio_path = config.resolve_storage_path(sample.audio_path) + if audio_path is None or not audio_path.exists(): raise HTTPException(status_code=404, detail="Audio file not found") return FileResponse( diff --git a/backend/routes/effects.py b/backend/routes/effects.py index 8139176d..52bbc8fd 100644 --- a/backend/routes/effects.py +++ b/backend/routes/effects.py @@ -3,7 +3,6 @@ import asyncio import io import uuid -from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse @@ -41,10 +40,11 @@ async def preview_effects( all_versions = versions_mod.list_versions(generation_id, db) clean_version = next((v for v in all_versions if v.effects_chain is None), None) source_path = clean_version.audio_path if clean_version else gen.audio_path - if not source_path or not Path(source_path).exists(): + resolved_source_path = config.resolve_storage_path(source_path) + if resolved_source_path is None or not resolved_source_path.exists(): raise HTTPException(status_code=404, detail="Source audio file not found") - audio, sample_rate = await asyncio.to_thread(load_audio, source_path) + audio, sample_rate = await asyncio.to_thread(load_audio, str(resolved_source_path)) processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts) import soundfile as sf @@ -193,10 +193,11 @@ async def apply_effects_to_generation( source_path = clean_version.audio_path source_version_id = clean_version.id - if not source_path or not Path(source_path).exists(): + resolved_source_path = config.resolve_storage_path(source_path) + if resolved_source_path is None or not resolved_source_path.exists(): raise HTTPException(status_code=404, detail="Source audio file not found") - audio, sample_rate = await asyncio.to_thread(load_audio, source_path) + audio, sample_rate = await asyncio.to_thread(load_audio, str(resolved_source_path)) processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts) version_id = str(uuid.uuid4()) @@ -208,7 +209,7 @@ async def apply_effects_to_generation( version = versions_mod.create_version( generation_id=generation_id, label=label, - audio_path=str(processed_path), + audio_path=config.to_storage_path(processed_path), db=db, effects_chain=chain_dicts, is_default=data.set_as_default, diff --git a/backend/routes/history.py b/backend/routes/history.py index 5435e299..f8233e3c 100644 --- a/backend/routes/history.py +++ b/backend/routes/history.py @@ -1,13 +1,12 @@ """Generation history endpoints.""" import io -from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session -from .. import models +from .. import config, models from ..services import export_import, history from ..app import safe_content_disposition from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db @@ -162,8 +161,8 @@ async def export_generation_audio( if not generation.audio_path: raise HTTPException(status_code=404, detail="Generation has no audio file") - audio_path = Path(generation.audio_path) - if not audio_path.is_file(): + audio_path = config.resolve_storage_path(generation.audio_path) + if audio_path is None or not audio_path.is_file(): raise HTTPException(status_code=404, detail="Audio file not found") safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip() diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index d65a1138..7bc075c5 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session -from .. import models +from .. import config, models from ..app import safe_content_disposition from ..database import VoiceProfile as DBVoiceProfile, get_db from ..services import channels, export_import, profiles @@ -258,8 +258,8 @@ async def get_profile_avatar( if not profile.avatar_path: raise HTTPException(status_code=404, detail="No avatar found for this profile") - avatar_path = Path(profile.avatar_path) - if not avatar_path.exists(): + avatar_path = config.resolve_storage_path(profile.avatar_path) + if avatar_path is None or not avatar_path.exists(): raise HTTPException(status_code=404, detail="Avatar file not found") return FileResponse(avatar_path) diff --git a/backend/services/export_import.py b/backend/services/export_import.py index 93252f50..514eaacd 100644 --- a/backend/services/export_import.py +++ b/backend/services/export_import.py @@ -73,8 +73,8 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes: # Check if profile has avatar has_avatar = False if profile.avatar_path: - avatar_path = Path(profile.avatar_path) - if avatar_path.exists(): + avatar_path = config.resolve_storage_path(profile.avatar_path) + if avatar_path is not None and avatar_path.exists(): has_avatar = True # Add avatar to ZIP root with original extension avatar_ext = avatar_path.suffix @@ -98,7 +98,9 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes: for sample in samples: # Get filename from audio_path (should be {sample_id}.wav) - audio_path = Path(sample.audio_path) + audio_path = config.resolve_storage_path(sample.audio_path) + if audio_path is None: + raise ValueError(f"Audio file not found: {sample.audio_path}") filename = audio_path.name # Read audio file @@ -279,7 +281,7 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes: # Build version manifest entries version_entries = [] for v in versions: - v_path = Path(v.audio_path) + v_path = config.resolve_storage_path(v.audio_path) effects_chain = None if v.effects_chain: effects_chain = json.loads(v.effects_chain) @@ -314,14 +316,14 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes: # Add all version audio files for v in versions: - v_path = Path(v.audio_path) - if v_path.exists(): + v_path = config.resolve_storage_path(v.audio_path) + if v_path is not None and v_path.exists(): zip_file.write(v_path, f"audio/{v_path.name}") # Fallback: if no versions exist, include the generation's main audio if not versions: - audio_path = Path(generation.audio_path) - if audio_path.exists(): + audio_path = config.resolve_storage_path(generation.audio_path) + if audio_path is not None and audio_path.exists(): zip_file.write(audio_path, f"audio/{audio_path.name}") zip_buffer.seek(0) @@ -426,7 +428,7 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict: profile_id=profile_id, text=generation_data["text"], language=generation_data["language"], - audio_path=str(audio_dest), + audio_path=config.to_storage_path(audio_dest), duration=generation_data["duration"], seed=generation_data.get("seed"), instruct=generation_data.get("instruct"), diff --git a/backend/services/generation.py b/backend/services/generation.py index d8d5214d..a70e633e 100644 --- a/backend/services/generation.py +++ b/backend/services/generation.py @@ -163,7 +163,7 @@ def _save_generate( versions_mod.create_version( generation_id=generation_id, label="original", - audio_path=str(clean_audio_path), + audio_path=config.to_storage_path(clean_audio_path), db=db, effects_chain=None, is_default=not has_effects, @@ -174,6 +174,8 @@ def _save_generate( if has_effects: from ..utils.effects import apply_effects, validate_effects_chain + assert effects_chain is not None + error_msg = validate_effects_chain(effects_chain) if error_msg: import logging @@ -189,13 +191,13 @@ def _save_generate( versions_mod.create_version( generation_id=generation_id, label="version-2", - audio_path=str(processed_path), + audio_path=config.to_storage_path(processed_path), db=db, effects_chain=effects_chain, is_default=True, ) - return final_audio_path + return config.to_storage_path(final_audio_path) def _save_retry( @@ -211,7 +213,7 @@ def _save_retry( """ audio_path = config.get_generations_dir() / f"{generation_id}.wav" save_audio(audio, str(audio_path), sample_rate) - return str(audio_path) + return config.to_storage_path(audio_path) def _save_regenerate( @@ -244,10 +246,10 @@ def _save_regenerate( versions_mod.create_version( generation_id=generation_id, label=label, - audio_path=str(audio_path), + audio_path=config.to_storage_path(audio_path), db=db, effects_chain=None, is_default=True, ) - return str(audio_path) + return config.to_storage_path(audio_path) diff --git a/backend/services/history.py b/backend/services/history.py index 8f45d48f..473c4b37 100644 --- a/backend/services/history.py +++ b/backend/services/history.py @@ -253,8 +253,8 @@ async def delete_generation( # Delete main audio file (if not already removed by version cleanup) if generation.audio_path: - audio_path = Path(generation.audio_path) - if audio_path.exists(): + audio_path = config.resolve_storage_path(generation.audio_path) + if audio_path is not None and audio_path.exists(): audio_path.unlink() # Delete from database @@ -283,8 +283,8 @@ async def delete_generations_by_profile( count = 0 for generation in generations: # Delete audio file - audio_path = Path(generation.audio_path) - if audio_path.exists(): + audio_path = config.resolve_storage_path(generation.audio_path) + if audio_path is not None and audio_path.exists(): audio_path.unlink() # Delete from database diff --git a/backend/services/profiles.py b/backend/services/profiles.py index cd418aab..78839504 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -236,7 +236,7 @@ async def add_profile_sample( db_sample = DBProfileSample( id=sample_id, profile_id=profile_id, - audio_path=str(dest_path), + audio_path=config.to_storage_path(dest_path), reference_text=reference_text, ) @@ -441,8 +441,8 @@ async def delete_profile_sample( # Store profile_id before deleting profile_id = sample.profile_id - audio_path = Path(sample.audio_path) - if audio_path.exists(): + audio_path = config.resolve_storage_path(sample.audio_path) + if audio_path is not None and audio_path.exists(): audio_path.unlink() db.delete(sample) @@ -556,14 +556,22 @@ async def create_voice_prompt_for_profile( if len(samples) == 1: sample = samples[0] + sample_audio_path = config.resolve_storage_path(sample.audio_path) + if sample_audio_path is None: + raise ValueError(f"Sample audio not found for profile {profile_id}") voice_prompt, _ = await tts_model.create_voice_prompt( - sample.audio_path, + str(sample_audio_path), sample.reference_text, use_cache=use_cache, ) return voice_prompt - audio_paths = [s.audio_path for s in samples] + audio_paths = [] + for sample in samples: + sample_audio_path = config.resolve_storage_path(sample.audio_path) + if sample_audio_path is None: + raise ValueError(f"Sample audio not found for profile {profile_id}") + audio_paths.append(str(sample_audio_path)) reference_texts = [s.reference_text for s in samples] combined_audio, combined_text = await tts_model.combine_voice_prompts( @@ -617,8 +625,8 @@ async def upload_avatar( raise ValueError(error_msg) if profile.avatar_path: - old_avatar = Path(profile.avatar_path) - if old_avatar.exists(): + old_avatar = config.resolve_storage_path(profile.avatar_path) + if old_avatar is not None and old_avatar.exists(): old_avatar.unlink() # Determine file extension from uploaded file @@ -639,7 +647,7 @@ async def upload_avatar( process_avatar(image_path, str(output_path)) - profile.avatar_path = str(output_path) + profile.avatar_path = config.to_storage_path(output_path) profile.updated_at = datetime.utcnow() db.commit() @@ -666,8 +674,8 @@ async def delete_avatar( if not profile or not profile.avatar_path: return False - avatar_path = Path(profile.avatar_path) - if avatar_path.exists(): + avatar_path = config.resolve_storage_path(profile.avatar_path) + if avatar_path is not None and avatar_path.exists(): avatar_path.unlink() profile.avatar_path = None diff --git a/backend/services/stories.py b/backend/services/stories.py index ac8e22bd..611ab29d 100644 --- a/backend/services/stories.py +++ b/backend/services/stories.py @@ -10,6 +10,7 @@ from pathlib import Path from sqlalchemy.orm import Session from sqlalchemy import func +from .. import config from ..models import ( StoryCreate, StoryResponse, @@ -826,8 +827,8 @@ async def export_story_audio( if version: resolved_audio_path = version.audio_path - audio_path = Path(resolved_audio_path) - if not audio_path.exists(): + audio_path = config.resolve_storage_path(resolved_audio_path) + if audio_path is None or not audio_path.exists(): continue try: diff --git a/backend/services/versions.py b/backend/services/versions.py index 1743a25c..cbeb4b8b 100644 --- a/backend/services/versions.py +++ b/backend/services/versions.py @@ -158,8 +158,8 @@ def delete_version(version_id: str, db: Session) -> bool: gen_id = version.generation_id # Delete audio file - audio_path = Path(version.audio_path) - if audio_path.exists(): + audio_path = config.resolve_storage_path(version.audio_path) + if audio_path is not None and audio_path.exists(): audio_path.unlink() db.delete(version) @@ -193,8 +193,8 @@ def delete_versions_for_generation(generation_id: str, db: Session) -> int: ) count = 0 for v in versions: - audio_path = Path(v.audio_path) - if audio_path.exists(): + audio_path = config.resolve_storage_path(v.audio_path) + if audio_path is not None and audio_path.exists(): audio_path.unlink() db.delete(v) count += 1