mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
Split the 2,578-line main.py (90 routes) into 12 domain-specific router modules under routes/. main.py is now a 45-line entry point. New structure: - app.py: FastAPI instance, CORS, startup/shutdown, safe_content_disposition - routes/: health, profiles, channels, generations, history, transcription, stories, effects, audio, models, tasks, cuda - services/cuda.py: moved from cuda_download.py Also includes Phase 5 database/ package (from parallel agent): - database/__init__.py re-exports all symbols for backward compat - database/models.py, session.py, migrations.py, seed.py All 90 routes verified registered and app imports cleanly.
165 lines
7.1 KiB
Python
165 lines
7.1 KiB
Python
"""Column-level migrations for the voicebox SQLite database.
|
|
|
|
Why not Alembic? voicebox is a single-user desktop app shipping as a
|
|
PyInstaller binary. Every user has exactly one SQLite file. Alembic's
|
|
strengths -- migration tracking across environments, rollback, team
|
|
coordination -- don't apply here and would add bundling complexity
|
|
(alembic.ini, env.py, versions/ directory all need to survive
|
|
PyInstaller). The column-existence checks below are idempotent, run in
|
|
<50 ms on startup, and have worked reliably across 12 schema changes.
|
|
If the project ever moves to a server-based deployment or Postgres, this
|
|
decision should be revisited.
|
|
|
|
Adding a new migration:
|
|
1. Append a new ``_migrate_*`` helper at the bottom of this file.
|
|
2. Call it from ``run_migrations()`` in the appropriate spot.
|
|
3. The helper should check column/table existence before acting
|
|
(idempotent) and print a short message when it does real work.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from sqlalchemy import inspect, text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def run_migrations(engine) -> None:
|
|
"""Run all schema migrations. Safe to call on every startup."""
|
|
inspector = inspect(engine)
|
|
tables = set(inspector.get_table_names())
|
|
|
|
_migrate_story_items(engine, inspector, tables)
|
|
_migrate_profiles(engine, inspector, tables)
|
|
_migrate_generations(engine, inspector, tables)
|
|
_migrate_effect_presets(engine, inspector, tables)
|
|
_migrate_generation_versions(engine, inspector, tables)
|
|
|
|
|
|
# -- helpers ---------------------------------------------------------------
|
|
|
|
def _get_columns(inspector, table: str) -> set[str]:
|
|
return {col["name"] for col in inspector.get_columns(table)}
|
|
|
|
|
|
def _add_column(engine, table: str, column_sql: str, label: str) -> None:
|
|
"""Add a column if it doesn't already exist."""
|
|
with engine.connect() as conn:
|
|
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column_sql}"))
|
|
conn.commit()
|
|
logger.info("Added %s column to %s", label, table)
|
|
|
|
|
|
# -- per-table migrations --------------------------------------------------
|
|
|
|
def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
|
|
if "story_items" not in tables:
|
|
return
|
|
|
|
columns = _get_columns(inspector, "story_items")
|
|
|
|
# Replace position-based ordering with absolute timecodes
|
|
if "position" in columns:
|
|
logger.info("Migrating story_items: removing position column, using start_time_ms")
|
|
with engine.connect() as conn:
|
|
if "start_time_ms" not in columns:
|
|
conn.execute(text(
|
|
"ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"
|
|
))
|
|
result = conn.execute(text("""
|
|
SELECT si.id, si.story_id, si.position, g.duration
|
|
FROM story_items si
|
|
JOIN generations g ON si.generation_id = g.id
|
|
ORDER BY si.story_id, si.position
|
|
"""))
|
|
current_story_id = None
|
|
current_time_ms = 0
|
|
for item_id, story_id, _position, duration in result.fetchall():
|
|
if story_id != current_story_id:
|
|
current_story_id = story_id
|
|
current_time_ms = 0
|
|
conn.execute(
|
|
text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"),
|
|
{"time": current_time_ms, "id": item_id},
|
|
)
|
|
current_time_ms += int(duration * 1000) + 200
|
|
conn.commit()
|
|
|
|
# Recreate table without the position column (SQLite lacks DROP COLUMN)
|
|
conn.execute(text("""
|
|
CREATE TABLE story_items_new (
|
|
id VARCHAR PRIMARY KEY,
|
|
story_id VARCHAR NOT NULL,
|
|
generation_id VARCHAR NOT NULL,
|
|
start_time_ms INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME,
|
|
FOREIGN KEY (story_id) REFERENCES stories(id),
|
|
FOREIGN KEY (generation_id) REFERENCES generations(id)
|
|
)
|
|
"""))
|
|
conn.execute(text("""
|
|
INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, created_at)
|
|
SELECT id, story_id, generation_id, start_time_ms, created_at FROM story_items
|
|
"""))
|
|
conn.execute(text("DROP TABLE story_items"))
|
|
conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
|
|
conn.commit()
|
|
|
|
# Re-read after table recreation
|
|
columns = _get_columns(inspector, "story_items")
|
|
|
|
if "track" not in columns:
|
|
_add_column(engine, "story_items", "track INTEGER NOT NULL DEFAULT 0", "track")
|
|
# Re-read so subsequent checks see new columns
|
|
columns = _get_columns(inspector, "story_items")
|
|
if "trim_start_ms" not in columns:
|
|
_add_column(engine, "story_items", "trim_start_ms INTEGER NOT NULL DEFAULT 0", "trim_start_ms")
|
|
if "trim_end_ms" not in columns:
|
|
_add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms")
|
|
if "version_id" not in columns:
|
|
_add_column(engine, "story_items", "version_id VARCHAR", "version_id")
|
|
|
|
|
|
def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
|
|
if "profiles" not in tables:
|
|
return
|
|
columns = _get_columns(inspector, "profiles")
|
|
if "avatar_path" not in columns:
|
|
_add_column(engine, "profiles", "avatar_path VARCHAR", "avatar_path")
|
|
if "effects_chain" not in columns:
|
|
_add_column(engine, "profiles", "effects_chain TEXT", "effects_chain")
|
|
|
|
|
|
def _migrate_generations(engine, inspector, tables: set[str]) -> None:
|
|
if "generations" not in tables:
|
|
return
|
|
columns = _get_columns(inspector, "generations")
|
|
if "status" not in columns:
|
|
_add_column(engine, "generations", "status VARCHAR DEFAULT 'completed'", "status")
|
|
if "error" not in columns:
|
|
_add_column(engine, "generations", "error TEXT", "error")
|
|
if "engine" not in columns:
|
|
_add_column(engine, "generations", "engine VARCHAR DEFAULT 'qwen'", "engine")
|
|
# Re-read after engine column (variable name shadows outer scope in old code)
|
|
columns = _get_columns(inspector, "generations")
|
|
if "model_size" not in columns:
|
|
_add_column(engine, "generations", "model_size VARCHAR", "model_size")
|
|
if "is_favorited" not in columns:
|
|
_add_column(engine, "generations", "is_favorited BOOLEAN DEFAULT 0", "is_favorited")
|
|
|
|
|
|
def _migrate_effect_presets(engine, inspector, tables: set[str]) -> None:
|
|
if "effect_presets" not in tables:
|
|
return
|
|
columns = _get_columns(inspector, "effect_presets")
|
|
if "sort_order" not in columns:
|
|
_add_column(engine, "effect_presets", "sort_order INTEGER DEFAULT 100", "sort_order")
|
|
|
|
|
|
def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
|
|
if "generation_versions" not in tables:
|
|
return
|
|
columns = _get_columns(inspector, "generation_versions")
|
|
if "source_version_id" not in columns:
|
|
_add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
|