This track didn't survive distillation.
+Nothing's left in the flask at this address — it may have evaporated, moved, or never existed.
+ + +diff --git a/app/db.py b/app/db.py index 237a7c6..b630a5d 100644 --- a/app/db.py +++ b/app/db.py @@ -1,4 +1,5 @@ import sqlite3 +import time from pathlib import Path from sqlalchemy import create_engine, event, text @@ -25,32 +26,64 @@ def _set_sqlite_pragmas(dbapi_connection, _record): SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) -def _current_schema_version(conn) -> int: +def _raw_schema_version(conn: sqlite3.Connection) -> int: try: - row = conn.execute(text("SELECT version FROM schema_version")).fetchone() + row = conn.execute("SELECT version FROM schema_version").fetchone() return row[0] if row else 0 - except Exception: - return 0 + except sqlite3.OperationalError: + return 0 # schema_version table doesn't exist yet (fresh database) + + +def _set_schema_version(conn: sqlite3.Connection, version: int) -> None: + """schema_version holds a single row (created by migration 0001). Set it + to the migration we just applied, so the version bump never depends on the + .sql file remembering to do it itself.""" + cur = conn.execute("UPDATE schema_version SET version = ?", (version,)) + if cur.rowcount == 0: + conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,)) def init_db() -> None: """Apply schema/*.sql migrations in order, tracked by schema_version. Plain SQL files, not the `alembic` Python migration tool — see - docs/ARCHITECTURE.md for why.""" + docs/ARCHITECTURE.md for why. + + Each file is applied with executescript (so triggers and semicolons inside + string literals survive, unlike a naive split on ';'), and this function + sets schema_version after each file rather than trusting the file to. That + makes re-running safe: a file whose version is already recorded is skipped, + so a forgotten in-file version bump can't cause an ALTER to run twice and + crash startup.""" settings.app_db_path.parent.mkdir(parents=True, exist_ok=True) migrations = sorted(SCHEMA_DIR.glob("*.sql")) - with engine.begin() as conn: - current = _current_schema_version(conn) + conn = sqlite3.connect(settings.app_db_path) + try: + current = _raw_schema_version(conn) for path in migrations: version = int(path.stem.split("_", 1)[0]) if version <= current: continue - sql = path.read_text() - for statement in sql.split(";"): - statement = statement.strip() - if statement: - conn.execute(text(statement)) + conn.executescript(path.read_text()) + _set_schema_version(conn, version) + conn.commit() + finally: + conn.close() + + +def mark_interrupted_runs() -> None: + """A job_run still marked 'running' at startup was killed mid-flight: the + subprocess does not survive a container restart. Mark those so the UI and + history don't show a run stuck 'running' forever. Runs once at startup, + before the scheduler can create any new runs.""" + with engine.begin() as conn: + conn.execute( + text( + "UPDATE job_runs SET status = 'interrupted', " + "finished_at = COALESCE(finished_at, :now) WHERE status = 'running'" + ), + {"now": time.time()}, + ) def enable_beets_db_wal() -> None: diff --git a/app/main.py b/app/main.py index efff896..f6e58e3 100644 --- a/app/main.py +++ b/app/main.py @@ -1,17 +1,30 @@ import logging from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.exception_handlers import http_exception_handler from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware -from app.db import enable_beets_db_wal, init_db +from app.db import enable_beets_db_wal, init_db, mark_interrupted_runs from app.routers import artist_casing, auth, credentials, dashboard, dedup, genres, health, import_, jobs, library, playlists from app.security.session import resolve_session_secret from app.services import scheduler_service from app.settings import settings log = logging.getLogger("alembic") +templates = Jinja2Templates(directory="app/templates") + + +async def not_found_or_default(request: Request, exc: StarletteHTTPException): + """Render the branded 404 page for missing pages; leave every other + HTTP exception (including the 303 login redirect raised by require_auth) + to FastAPI's default handler so their status and headers are preserved.""" + if exc.status_code == 404: + return templates.TemplateResponse(request, "404.html", {}, status_code=404) + return await http_exception_handler(request, exc) def _warn_if_key_colocated_with_config() -> None: @@ -39,6 +52,7 @@ def _warn_if_key_colocated_with_config() -> None: async def lifespan(_app: FastAPI): _warn_if_key_colocated_with_config() init_db() + mark_interrupted_runs() enable_beets_db_wal() scheduler = scheduler_service.create_scheduler() @@ -53,6 +67,8 @@ async def lifespan(_app: FastAPI): def create_app() -> FastAPI: app = FastAPI(title="alembic", lifespan=lifespan) + app.add_exception_handler(StarletteHTTPException, not_found_or_default) + app.add_middleware(SessionMiddleware, secret_key=resolve_session_secret()) app.mount("/static", StaticFiles(directory="app/static"), name="static") diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 0000000..bf75496 --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,85 @@ + + +
+ + +