2641a1b874
- R11: pin requirements.txt to the versions running in the image, so a rebuild can't pull a breaking upstream release. Documented how to upgrade. - R12: log rotation now also prunes job_runs rows older than 90 days (nulling the manual_imports FK reference first), so the table doesn't grow without bound. dedup/genre candidates are review state and left alone. - U5: credential form shows non-secret fields (URLs, usernames, prefs) as plain text so they can be verified while typing; only real secrets stay masked. Dashboard IP card reworded from gluetun-specific to generic "Outbound IP". - S9: enforce ALLOWED_EMAIL at the OIDC callback, before any user row or session is created, instead of only on later requests. Verified: pinned build resolves; prune deletes only old rows and keeps the manual_imports record; credentials page renders text+password inputs; a disallowed email gets 403 with no user row, an allowed one succeeds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import create_engine, event, text
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.settings import settings
|
|
|
|
SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema"
|
|
|
|
engine = create_engine(
|
|
f"sqlite:///{settings.app_db_path}",
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _set_sqlite_pragmas(dbapi_connection, _record):
|
|
cursor = dbapi_connection.cursor()
|
|
cursor.execute("PRAGMA journal_mode=WAL")
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.close()
|
|
|
|
|
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
|
|
|
|
def _raw_schema_version(conn: sqlite3.Connection) -> int:
|
|
try:
|
|
row = conn.execute("SELECT version FROM schema_version").fetchone()
|
|
return row[0] if row else 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.
|
|
|
|
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"))
|
|
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
|
|
conn.executescript(path.read_text())
|
|
_set_schema_version(conn, version)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def prune_old_job_runs(max_age_days: int = 90) -> int:
|
|
"""Delete job_runs rows older than max_age_days so the table doesn't grow
|
|
without bound (roughly 30 scheduled runs/day). Rows are tiny and useful for
|
|
history, so the retention is longer than the 30-day log-file rotation.
|
|
|
|
manual_imports.job_run_id references job_runs, so null those references
|
|
first to respect the foreign key (the import record itself is kept). dedup
|
|
and genre candidates are review state, not run history, so they are left
|
|
alone here. Returns the number of job_runs rows deleted."""
|
|
cutoff = time.time() - max_age_days * 86400
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"UPDATE manual_imports SET job_run_id = NULL "
|
|
"WHERE job_run_id IN (SELECT id FROM job_runs WHERE started_at < :c)"
|
|
),
|
|
{"c": cutoff},
|
|
)
|
|
result = conn.execute(text("DELETE FROM job_runs WHERE started_at < :c"), {"c": cutoff})
|
|
return result.rowcount or 0
|
|
|
|
|
|
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:
|
|
"""One-time WAL enable on the beets DB so alembic's read-only status
|
|
queries can run concurrently with beets writes."""
|
|
if not settings.beets_db_path.exists():
|
|
return
|
|
conn = sqlite3.connect(settings.beets_db_path)
|
|
try:
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_db():
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|