fix: store media paths relative to data dir

This commit is contained in:
James Pine
2026-03-20 15:06:07 -07:00
parent e6f419cd70
commit b108bb1cb1
13 changed files with 130 additions and 86 deletions
+47
View File
@@ -22,6 +22,21 @@ if _custom_models_dir:
_data_dir = Path("data").resolve() _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): def set_data_dir(path: str | Path):
""" """
Set the data directory path. Set the data directory path.
@@ -45,6 +60,38 @@ def get_data_dir() -> Path:
return _data_dir 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: def get_db_path() -> Path:
"""Get database file path.""" """Get database file path."""
return _data_dir / "voicebox.db" return _data_dir / "voicebox.db"
+11 -27
View File
@@ -34,7 +34,7 @@ def run_migrations(engine) -> None:
_migrate_generations(engine, inspector, tables) _migrate_generations(engine, inspector, tables)
_migrate_effect_presets(engine, inspector, tables) _migrate_effect_presets(engine, inspector, tables)
_migrate_generation_versions(engine, inspector, tables) _migrate_generation_versions(engine, inspector, tables)
_resolve_relative_paths(engine, tables) _normalize_storage_paths(engine, tables)
# -- helpers --------------------------------------------------------------- # -- 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") _add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
def _resolve_relative_paths(engine, tables: set[str]) -> None: def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Resolve any relative file paths in the database to absolute paths. """Normalize stored file paths to be relative to the configured data dir."""
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.
"""
from pathlib import Path 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() data_dir = get_data_dir()
@@ -222,21 +209,18 @@ def _resolve_relative_paths(engine, tables: set[str]) -> None:
if not path_val: if not path_val:
continue continue
p = Path(path_val) p = Path(path_val)
if p.is_absolute(): resolved = resolve_storage_path(p)
if resolved is None:
continue 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() normalized = to_storage_path(resolved)
if resolved.exists():
if normalized != path_val:
conn.execute( conn.execute(
text(f"UPDATE {table} SET {column} = :path WHERE id = :id"), text(f"UPDATE {table} SET {column} = :path WHERE id = :id"),
{"path": str(resolved), "id": row_id}, {"path": normalized, "id": row_id},
) )
total_fixed += 1 total_fixed += 1
if total_fixed > 0: if total_fixed > 0:
conn.commit() conn.commit()
logger.info("Resolved %d relative file paths to absolute", total_fixed) logger.info("Normalized %d stored file paths", total_fixed)
+4 -2
View File
@@ -3,7 +3,8 @@
import json import json
import logging import logging
import uuid import uuid
from pathlib import Path
from .. import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,7 +26,8 @@ def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) ->
for gen in generations: for gen in generations:
if gen.id in existing_version_gen_ids: if gen.id in existing_version_gen_ids:
continue 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 continue
version = GenerationVersion( version = GenerationVersion(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
+7 -9
View File
@@ -1,12 +1,10 @@
"""Audio file serving endpoints.""" """Audio file serving endpoints."""
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models from .. import config, models
from ..services import history from ..services import history
from ..database import get_db 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: if not version:
raise HTTPException(status_code=404, detail="Version not found") raise HTTPException(status_code=404, detail="Version not found")
audio_path = Path(version.audio_path) audio_path = config.resolve_storage_path(version.audio_path)
if not audio_path.exists(): if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found") raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse( return FileResponse(
@@ -40,8 +38,8 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)):
if not generation: if not generation:
raise HTTPException(status_code=404, detail="Generation not found") raise HTTPException(status_code=404, detail="Generation not found")
audio_path = Path(generation.audio_path) audio_path = config.resolve_storage_path(generation.audio_path)
if not audio_path.exists(): if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found") raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse( return FileResponse(
@@ -60,8 +58,8 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
if not sample: if not sample:
raise HTTPException(status_code=404, detail="Sample not found") raise HTTPException(status_code=404, detail="Sample not found")
audio_path = Path(sample.audio_path) audio_path = config.resolve_storage_path(sample.audio_path)
if not audio_path.exists(): if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found") raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse( return FileResponse(
+7 -6
View File
@@ -3,7 +3,6 @@
import asyncio import asyncio
import io import io
import uuid import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -41,10 +40,11 @@ async def preview_effects(
all_versions = versions_mod.list_versions(generation_id, db) 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) 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 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") 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) processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
import soundfile as sf import soundfile as sf
@@ -193,10 +193,11 @@ async def apply_effects_to_generation(
source_path = clean_version.audio_path source_path = clean_version.audio_path
source_version_id = clean_version.id 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") 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) processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
version_id = str(uuid.uuid4()) version_id = str(uuid.uuid4())
@@ -208,7 +209,7 @@ async def apply_effects_to_generation(
version = versions_mod.create_version( version = versions_mod.create_version(
generation_id=generation_id, generation_id=generation_id,
label=label, label=label,
audio_path=str(processed_path), audio_path=config.to_storage_path(processed_path),
db=db, db=db,
effects_chain=chain_dicts, effects_chain=chain_dicts,
is_default=data.set_as_default, is_default=data.set_as_default,
+3 -4
View File
@@ -1,13 +1,12 @@
"""Generation history endpoints.""" """Generation history endpoints."""
import io import io
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models from .. import config, models
from ..services import export_import, history from ..services import export_import, history
from ..app import safe_content_disposition from ..app import safe_content_disposition
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db 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: if not generation.audio_path:
raise HTTPException(status_code=404, detail="Generation has no audio file") raise HTTPException(status_code=404, detail="Generation has no audio file")
audio_path = Path(generation.audio_path) audio_path = config.resolve_storage_path(generation.audio_path)
if not audio_path.is_file(): if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found") 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() safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
+3 -3
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models from .. import config, models
from ..app import safe_content_disposition from ..app import safe_content_disposition
from ..database import VoiceProfile as DBVoiceProfile, get_db from ..database import VoiceProfile as DBVoiceProfile, get_db
from ..services import channels, export_import, profiles from ..services import channels, export_import, profiles
@@ -258,8 +258,8 @@ async def get_profile_avatar(
if not profile.avatar_path: if not profile.avatar_path:
raise HTTPException(status_code=404, detail="No avatar found for this profile") raise HTTPException(status_code=404, detail="No avatar found for this profile")
avatar_path = Path(profile.avatar_path) avatar_path = config.resolve_storage_path(profile.avatar_path)
if not avatar_path.exists(): if avatar_path is None or not avatar_path.exists():
raise HTTPException(status_code=404, detail="Avatar file not found") raise HTTPException(status_code=404, detail="Avatar file not found")
return FileResponse(avatar_path) return FileResponse(avatar_path)
+11 -9
View File
@@ -73,8 +73,8 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
# Check if profile has avatar # Check if profile has avatar
has_avatar = False has_avatar = False
if profile.avatar_path: if profile.avatar_path:
avatar_path = Path(profile.avatar_path) avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path.exists(): if avatar_path is not None and avatar_path.exists():
has_avatar = True has_avatar = True
# Add avatar to ZIP root with original extension # Add avatar to ZIP root with original extension
avatar_ext = avatar_path.suffix avatar_ext = avatar_path.suffix
@@ -98,7 +98,9 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
for sample in samples: for sample in samples:
# Get filename from audio_path (should be {sample_id}.wav) # 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 filename = audio_path.name
# Read audio file # Read audio file
@@ -279,7 +281,7 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
# Build version manifest entries # Build version manifest entries
version_entries = [] version_entries = []
for v in versions: for v in versions:
v_path = Path(v.audio_path) v_path = config.resolve_storage_path(v.audio_path)
effects_chain = None effects_chain = None
if v.effects_chain: if v.effects_chain:
effects_chain = json.loads(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 # Add all version audio files
for v in versions: for v in versions:
v_path = Path(v.audio_path) v_path = config.resolve_storage_path(v.audio_path)
if v_path.exists(): if v_path is not None and v_path.exists():
zip_file.write(v_path, f"audio/{v_path.name}") zip_file.write(v_path, f"audio/{v_path.name}")
# Fallback: if no versions exist, include the generation's main audio # Fallback: if no versions exist, include the generation's main audio
if not versions: if not versions:
audio_path = Path(generation.audio_path) audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path.exists(): if audio_path is not None and audio_path.exists():
zip_file.write(audio_path, f"audio/{audio_path.name}") zip_file.write(audio_path, f"audio/{audio_path.name}")
zip_buffer.seek(0) zip_buffer.seek(0)
@@ -426,7 +428,7 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
profile_id=profile_id, profile_id=profile_id,
text=generation_data["text"], text=generation_data["text"],
language=generation_data["language"], language=generation_data["language"],
audio_path=str(audio_dest), audio_path=config.to_storage_path(audio_dest),
duration=generation_data["duration"], duration=generation_data["duration"],
seed=generation_data.get("seed"), seed=generation_data.get("seed"),
instruct=generation_data.get("instruct"), instruct=generation_data.get("instruct"),
+8 -6
View File
@@ -163,7 +163,7 @@ def _save_generate(
versions_mod.create_version( versions_mod.create_version(
generation_id=generation_id, generation_id=generation_id,
label="original", label="original",
audio_path=str(clean_audio_path), audio_path=config.to_storage_path(clean_audio_path),
db=db, db=db,
effects_chain=None, effects_chain=None,
is_default=not has_effects, is_default=not has_effects,
@@ -174,6 +174,8 @@ def _save_generate(
if has_effects: if has_effects:
from ..utils.effects import apply_effects, validate_effects_chain from ..utils.effects import apply_effects, validate_effects_chain
assert effects_chain is not None
error_msg = validate_effects_chain(effects_chain) error_msg = validate_effects_chain(effects_chain)
if error_msg: if error_msg:
import logging import logging
@@ -189,13 +191,13 @@ def _save_generate(
versions_mod.create_version( versions_mod.create_version(
generation_id=generation_id, generation_id=generation_id,
label="version-2", label="version-2",
audio_path=str(processed_path), audio_path=config.to_storage_path(processed_path),
db=db, db=db,
effects_chain=effects_chain, effects_chain=effects_chain,
is_default=True, is_default=True,
) )
return final_audio_path return config.to_storage_path(final_audio_path)
def _save_retry( def _save_retry(
@@ -211,7 +213,7 @@ def _save_retry(
""" """
audio_path = config.get_generations_dir() / f"{generation_id}.wav" audio_path = config.get_generations_dir() / f"{generation_id}.wav"
save_audio(audio, str(audio_path), sample_rate) save_audio(audio, str(audio_path), sample_rate)
return str(audio_path) return config.to_storage_path(audio_path)
def _save_regenerate( def _save_regenerate(
@@ -244,10 +246,10 @@ def _save_regenerate(
versions_mod.create_version( versions_mod.create_version(
generation_id=generation_id, generation_id=generation_id,
label=label, label=label,
audio_path=str(audio_path), audio_path=config.to_storage_path(audio_path),
db=db, db=db,
effects_chain=None, effects_chain=None,
is_default=True, is_default=True,
) )
return str(audio_path) return config.to_storage_path(audio_path)
+4 -4
View File
@@ -253,8 +253,8 @@ async def delete_generation(
# Delete main audio file (if not already removed by version cleanup) # Delete main audio file (if not already removed by version cleanup)
if generation.audio_path: if generation.audio_path:
audio_path = Path(generation.audio_path) audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path.exists(): if audio_path is not None and audio_path.exists():
audio_path.unlink() audio_path.unlink()
# Delete from database # Delete from database
@@ -283,8 +283,8 @@ async def delete_generations_by_profile(
count = 0 count = 0
for generation in generations: for generation in generations:
# Delete audio file # Delete audio file
audio_path = Path(generation.audio_path) audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path.exists(): if audio_path is not None and audio_path.exists():
audio_path.unlink() audio_path.unlink()
# Delete from database # Delete from database
+18 -10
View File
@@ -236,7 +236,7 @@ async def add_profile_sample(
db_sample = DBProfileSample( db_sample = DBProfileSample(
id=sample_id, id=sample_id,
profile_id=profile_id, profile_id=profile_id,
audio_path=str(dest_path), audio_path=config.to_storage_path(dest_path),
reference_text=reference_text, reference_text=reference_text,
) )
@@ -441,8 +441,8 @@ async def delete_profile_sample(
# Store profile_id before deleting # Store profile_id before deleting
profile_id = sample.profile_id profile_id = sample.profile_id
audio_path = Path(sample.audio_path) audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path.exists(): if audio_path is not None and audio_path.exists():
audio_path.unlink() audio_path.unlink()
db.delete(sample) db.delete(sample)
@@ -556,14 +556,22 @@ async def create_voice_prompt_for_profile(
if len(samples) == 1: if len(samples) == 1:
sample = samples[0] 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( voice_prompt, _ = await tts_model.create_voice_prompt(
sample.audio_path, str(sample_audio_path),
sample.reference_text, sample.reference_text,
use_cache=use_cache, use_cache=use_cache,
) )
return voice_prompt 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] reference_texts = [s.reference_text for s in samples]
combined_audio, combined_text = await tts_model.combine_voice_prompts( combined_audio, combined_text = await tts_model.combine_voice_prompts(
@@ -617,8 +625,8 @@ async def upload_avatar(
raise ValueError(error_msg) raise ValueError(error_msg)
if profile.avatar_path: if profile.avatar_path:
old_avatar = Path(profile.avatar_path) old_avatar = config.resolve_storage_path(profile.avatar_path)
if old_avatar.exists(): if old_avatar is not None and old_avatar.exists():
old_avatar.unlink() old_avatar.unlink()
# Determine file extension from uploaded file # Determine file extension from uploaded file
@@ -639,7 +647,7 @@ async def upload_avatar(
process_avatar(image_path, str(output_path)) 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() profile.updated_at = datetime.utcnow()
db.commit() db.commit()
@@ -666,8 +674,8 @@ async def delete_avatar(
if not profile or not profile.avatar_path: if not profile or not profile.avatar_path:
return False return False
avatar_path = Path(profile.avatar_path) avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path.exists(): if avatar_path is not None and avatar_path.exists():
avatar_path.unlink() avatar_path.unlink()
profile.avatar_path = None profile.avatar_path = None
+3 -2
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from .. import config
from ..models import ( from ..models import (
StoryCreate, StoryCreate,
StoryResponse, StoryResponse,
@@ -826,8 +827,8 @@ async def export_story_audio(
if version: if version:
resolved_audio_path = version.audio_path resolved_audio_path = version.audio_path
audio_path = Path(resolved_audio_path) audio_path = config.resolve_storage_path(resolved_audio_path)
if not audio_path.exists(): if audio_path is None or not audio_path.exists():
continue continue
try: try:
+4 -4
View File
@@ -158,8 +158,8 @@ def delete_version(version_id: str, db: Session) -> bool:
gen_id = version.generation_id gen_id = version.generation_id
# Delete audio file # Delete audio file
audio_path = Path(version.audio_path) audio_path = config.resolve_storage_path(version.audio_path)
if audio_path.exists(): if audio_path is not None and audio_path.exists():
audio_path.unlink() audio_path.unlink()
db.delete(version) db.delete(version)
@@ -193,8 +193,8 @@ def delete_versions_for_generation(generation_id: str, db: Session) -> int:
) )
count = 0 count = 0
for v in versions: for v in versions:
audio_path = Path(v.audio_path) audio_path = config.resolve_storage_path(v.audio_path)
if audio_path.exists(): if audio_path is not None and audio_path.exists():
audio_path.unlink() audio_path.unlink()
db.delete(v) db.delete(v)
count += 1 count += 1