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 @@ + + + + + +Not found — alembic + + + + + + + +
+ +
+
+
+ +
+
+ + + + + + + + + + + + + Alembic + + +
+
+ +
+
+ + + + + + + + + + + +
Error 404 · No signal
+ +
404
+ +

This track didn't survive distillation.

+

Nothing's left in the flask at this address — it may have evaporated, moved, or never existed.

+ +
+ Back to dashboard + View playlists +
+
+
+
+ + diff --git a/pipeline/lib/pipeline-status.sh b/pipeline/lib/pipeline-status.sh index cd8ad33..01c7331 100755 --- a/pipeline/lib/pipeline-status.sh +++ b/pipeline/lib/pipeline-status.sh @@ -43,6 +43,14 @@ mark_info() { printf " %s\n" "$*"; } section() { printf "\n▎ %s\n" "$*"; } +# syslog / `logger` is not present in the container image. Only call it if it +# exists, so these lines don't spew "logger: command not found" into the job +# log on every run. Always returns success. +slog() { + command -v logger >/dev/null 2>&1 || return 0 + logger -t sldl-pipeline "$@" +} + # Format an age in seconds as "Nh", "Nd", "Nw", "Nmo". human_age() { local s=$1 @@ -375,16 +383,16 @@ ${BANNER_TALLY}" "$OUT" # Always mirror the one-line summary to syslog so journalctl shows last-run # state even if Telegram is broken. Warnings get a separate WARN tag. -logger -t sldl-pipeline "status: $OK ok, $WARN warn, $SKIP skip (see $OUT)" +slog "status: $OK ok, $WARN warn, $SKIP skip (see $OUT)" if (( WARN > 0 )); then - logger -t sldl-pipeline "WARN: pipeline-status flagged $WARN issue(s) — see $OUT" + slog "WARN: pipeline-status flagged $WARN issue(s) — see $OUT" fi # Send to Telegram. If it fails, log the failure to syslog so the absence of # a message in the chat has a corresponding journal entry to grep for. if [[ -x ${PIPELINE_DIR:-/app/pipeline}/lib/notify-telegram.sh ]]; then if ! ${PIPELINE_DIR:-/app/pipeline}/lib/notify-telegram.sh < "$OUT" 2>/tmp/tg-err; then - logger -t sldl-pipeline "WARN: Telegram send failed: $(cat /tmp/tg-err 2>/dev/null | head -c 200)" + slog "WARN: Telegram send failed: $(cat /tmp/tg-err 2>/dev/null | head -c 200)" rm -f /tmp/tg-err fi fi diff --git a/pipeline/lib/prep-audio.sh b/pipeline/lib/prep-audio.sh index adb8df4..b6b5ad4 100755 --- a/pipeline/lib/prep-audio.sh +++ b/pipeline/lib/prep-audio.sh @@ -26,25 +26,37 @@ prep_audio() { return 0 fi - echo "[prep-audio] loudgain (track-mode RG, clip-safe) on ${#files[@]} file(s)" >>"$log" - # -q quiet, -k lower track gain to avoid clipping >-1 dBFS, - # -s e write extended ReplayGain2 + R128 tags, -I 4 ID3v2.4 for MP3. - # No -a, so each file gets its own track gain (track-mode). - if ! loudgain -q -k -s e -I 4 "${files[@]}" >>"$log" 2>&1; then - echo "[prep-audio] loudgain returned non-zero (see log)" >>"$log" + # loudgain (ReplayGain) and cue_file (Liquidsoap autocue) are optional. They + # are not in the base image, so only run them if present -- otherwise skip + # cleanly with a note instead of failing on a missing binary. Install them in + # the image if you want ReplayGain/autocue tags written at prep time. + if command -v loudgain >/dev/null 2>&1; then + echo "[prep-audio] loudgain (track-mode RG, clip-safe) on ${#files[@]} file(s)" >>"$log" + # -q quiet, -k lower track gain to avoid clipping >-1 dBFS, + # -s e write extended ReplayGain2 + R128 tags, -I 4 ID3v2.4 for MP3. + # No -a, so each file gets its own track gain (track-mode). + if ! loudgain -q -k -s e -I 4 "${files[@]}" >>"$log" 2>&1; then + echo "[prep-audio] loudgain returned non-zero (see log)" >>"$log" + fi + else + echo "[prep-audio] loudgain not installed, skipping ReplayGain" >>"$log" fi - echo "[prep-audio] cue_file (autocue liq_* tags) on ${#files[@]} file(s)" >>"$log" - # -w write tags into file, -k true-peak clipping prevention. - # Skips files that already have liq_cue_file=true (use -f to force). - local failed=0 - for f in "${files[@]}"; do - if ! cue_file -w -k "$f" >>"$log" 2>&1; then - echo "[prep-audio] cue_file failed on $f" >>"$log" - failed=$((failed + 1)) - fi - done - [[ $failed -gt 0 ]] && echo "[prep-audio] cue_file errors: $failed" >>"$log" + if command -v cue_file >/dev/null 2>&1; then + echo "[prep-audio] cue_file (autocue liq_* tags) on ${#files[@]} file(s)" >>"$log" + # -w write tags into file, -k true-peak clipping prevention. + # Skips files that already have liq_cue_file=true (use -f to force). + local failed=0 + for f in "${files[@]}"; do + if ! cue_file -w -k "$f" >>"$log" 2>&1; then + echo "[prep-audio] cue_file failed on $f" >>"$log" + failed=$((failed + 1)) + fi + done + [[ $failed -gt 0 ]] && echo "[prep-audio] cue_file errors: $failed" >>"$log" + else + echo "[prep-audio] cue_file not installed, skipping autocue tags" >>"$log" + fi echo "${#files[@]}" }