Add FastAPI app skeleton: settings, db, models, security, auth

- settings.py: pydantic-settings config for the two mount points
  (MUSIC_DATA_DIR, ALEMBIC_CONFIG_DIR), OIDC client config, encryption key path
- db.py: SQLite engine with WAL mode, plain-SQL schema migration runner
  (schema/*.sql, tracked by schema_version -- not the alembic migration tool)
- models.py: SQLAlchemy ORM models matching schema/0001_init.sql
- security/crypto.py: Fernet encrypt/decrypt for the secrets table
- security/oidc.py + routers/auth.py: Pocket ID OIDC login/callback/logout
- security/deps.py: require_auth dependency (redirect-to-login on no session)
- main.py: app factory, lifespan (init_db + beets WAL enable), session
  middleware, minimal dashboard route

Verified boots end-to-end via TestClient: unauthenticated GET / redirects to
/auth/login, static files serve, and all 13 schema tables get created on
first run.
This commit is contained in:
andrew
2026-07-08 13:24:18 -06:00
parent 68cb007e4c
commit bd56c8153f
18 changed files with 595 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.db
*.db-journal
*.db-wal
*.db-shm
.env
View File
+73
View File
@@ -0,0 +1,73 @@
import sqlite3
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 _current_schema_version(conn) -> int:
try:
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
return row[0] if row else 0
except Exception:
return 0
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."""
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)
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))
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()
+34
View File
@@ -0,0 +1,34 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.db import enable_beets_db_wal, init_db
from app.routers import auth, dashboard
from app.settings import settings
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_db()
enable_beets_db_wal()
# Task 5 (scheduler_service) hooks APScheduler start/stop in here once
# the job registry and pipeline_runner exist.
yield
def create_app() -> FastAPI:
app = FastAPI(title="alembic", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
app.include_router(dashboard.router)
return app
app = create_app()
+159
View File
@@ -0,0 +1,159 @@
from sqlalchemy import (
Boolean,
Column,
Float,
ForeignKey,
Integer,
LargeBinary,
String,
UniqueConstraint,
)
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Playlist(Base):
__tablename__ = "playlists"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
spotify_url = Column(String, nullable=False)
active = Column(Boolean, nullable=False, default=True)
no_m3u = Column(Boolean, nullable=False, default=False)
cron_expr = Column(String, nullable=True)
notes = Column(String, nullable=True)
created_at = Column(Float, nullable=False)
updated_at = Column(Float, nullable=False)
class Secret(Base):
__tablename__ = "secrets"
__table_args__ = (UniqueConstraint("scope", "key"),)
id = Column(Integer, primary_key=True)
scope = Column(String, nullable=False)
key = Column(String, nullable=False)
value_encrypted = Column(LargeBinary, nullable=False)
updated_at = Column(Float, nullable=False)
class ScheduledJob(Base):
__tablename__ = "scheduled_jobs"
id = Column(Integer, primary_key=True)
job_key = Column(String, unique=True, nullable=False)
job_type = Column(String, nullable=False)
playlist_id = Column(Integer, ForeignKey("playlists.id"), nullable=True)
cron_expr = Column(String, nullable=False)
enabled = Column(Boolean, nullable=False, default=True)
class JobRun(Base):
__tablename__ = "job_runs"
id = Column(Integer, primary_key=True)
job_key = Column(String, nullable=False)
started_at = Column(Float, nullable=False)
finished_at = Column(Float, nullable=True)
status = Column(String, nullable=False) # running|success|failed|skipped_lock
exit_code = Column(Integer, nullable=True)
summary = Column(String, nullable=True)
log_path = Column(String, nullable=True)
triggered_by = Column(String, nullable=False) # schedule|manual|api
class ManualFixAudit(Base):
__tablename__ = "manual_fix_audit"
id = Column(Integer, primary_key=True)
beets_item_id = Column(Integer, nullable=False)
file_path = Column(String, nullable=False)
field = Column(String, nullable=False)
old_value = Column(String, nullable=True)
new_value = Column(String, nullable=True)
changed_by = Column(String, nullable=False)
changed_at = Column(Float, nullable=False)
source = Column(String, nullable=False) # tag_edit|genre_fix|retag_from_url
class ManualImport(Base):
__tablename__ = "manual_imports"
id = Column(Integer, primary_key=True)
source_path = Column(String, nullable=False)
playlist_name = Column(String, nullable=True)
imported_by = Column(String, nullable=False)
imported_at = Column(Float, nullable=False)
status = Column(String, nullable=False)
job_run_id = Column(Integer, ForeignKey("job_runs.id"), nullable=True)
class DedupRun(Base):
__tablename__ = "dedup_runs"
id = Column(Integer, primary_key=True)
started_at = Column(Float, nullable=False)
finished_at = Column(Float, nullable=True)
mode = Column(String, nullable=False) # dry_run|apply
groups_found = Column(Integer, nullable=True)
kept = Column(Integer, nullable=True)
deleted = Column(Integer, nullable=True)
log_path = Column(String, nullable=True)
class DedupCandidate(Base):
__tablename__ = "dedup_candidates"
id = Column(Integer, primary_key=True)
dedup_run_id = Column(Integer, ForeignKey("dedup_runs.id"), nullable=False)
pass_name = Column(String, nullable=False)
keep_path = Column(String, nullable=False)
delete_path = Column(String, nullable=False)
delete_size_bytes = Column(Integer, nullable=True)
confirmed = Column(Boolean, nullable=False, default=False)
confirmed_by = Column(String, nullable=True)
confirmed_at = Column(Float, nullable=True)
applied = Column(Boolean, nullable=False, default=False)
class GenreRun(Base):
__tablename__ = "genre_runs"
id = Column(Integer, primary_key=True)
started_at = Column(Float, nullable=True)
finished_at = Column(Float, nullable=True)
mode = Column(String, nullable=True)
written = Column(Integer, nullable=True)
unchanged = Column(Integer, nullable=True)
no_match = Column(Integer, nullable=True)
no_meta = Column(Integer, nullable=True)
log_path = Column(String, nullable=True)
class GenreCandidate(Base):
__tablename__ = "genre_candidates"
id = Column(Integer, primary_key=True)
genre_run_id = Column(Integer, ForeignKey("genre_runs.id"), nullable=False)
artist = Column(String, nullable=True)
title = Column(String, nullable=True)
old_genre = Column(String, nullable=True)
new_genre = Column(String, nullable=True)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
oidc_sub = Column(String, unique=True, nullable=False)
email = Column(String, nullable=True)
name = Column(String, nullable=True)
last_login_at = Column(Float, nullable=True)
class AppSetting(Base):
__tablename__ = "app_settings"
key = Column(String, primary_key=True)
value = Column(String, nullable=True)
View File
+53
View File
@@ -0,0 +1,53 @@
import time
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.db import SessionLocal
from app.models import User
from app.security.oidc import oauth
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/login")
async def login(request: Request):
redirect_uri = request.url_for("auth_callback")
return await oauth.pocketid.authorize_redirect(request, redirect_uri)
@router.get("/callback", name="auth_callback")
async def auth_callback(request: Request):
token = await oauth.pocketid.authorize_access_token(request)
userinfo = token.get("userinfo") or await oauth.pocketid.userinfo(token=token)
sub = userinfo["sub"]
email = userinfo.get("email")
name = userinfo.get("name")
now = time.time()
with SessionLocal() as db:
user = db.execute(select(User).where(User.oidc_sub == sub)).scalar_one_or_none()
if user is None:
user = User(oidc_sub=sub, email=email, name=name, last_login_at=now)
db.add(user)
else:
user.email = email
user.name = name
user.last_login_at = now
db.commit()
request.session["user"] = {"sub": sub, "email": email, "name": name}
return RedirectResponse(url="/")
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
end_session_endpoint = None
metadata = await oauth.pocketid.load_server_metadata()
end_session_endpoint = metadata.get("end_session_endpoint")
if end_session_endpoint:
return RedirectResponse(url=end_session_endpoint)
return RedirectResponse(url="/")
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates
from app.security.deps import require_auth
router = APIRouter(tags=["dashboard"])
templates = Jinja2Templates(directory="app/templates")
@router.get("/")
async def dashboard(request: Request, user: dict = Depends(require_auth)):
return templates.TemplateResponse(
request,
"dashboard.html",
{"user": user},
)
View File
+25
View File
@@ -0,0 +1,25 @@
from functools import lru_cache
from cryptography.fernet import Fernet
from app.settings import settings
@lru_cache(maxsize=1)
def _fernet() -> Fernet:
key_path = settings.encryption_key_file
if not key_path.exists():
raise RuntimeError(
f"Encryption key not found at {key_path}. Generate one with "
f"`openssl rand -base64 32` and mount it as the alembic_master_key "
f"Docker secret — see docker-compose.snippet.yml."
)
return Fernet(key_path.read_bytes().strip())
def encrypt(value: str) -> bytes:
return _fernet().encrypt(value.encode())
def decrypt(value: bytes) -> str:
return _fernet().decrypt(value).decode()
+22
View File
@@ -0,0 +1,22 @@
from fastapi import HTTPException, Request
from app.settings import settings
def get_session_user(request: Request) -> dict | None:
return request.session.get("user")
def require_auth(request: Request) -> dict:
"""FastAPI dependency: redirect to /auth/login if no session user.
Raising a 3xx HTTPException with a Location header works as a browser
redirect regardless of the response body FastAPI's default exception
handler produces — the redirect is purely a status-code+header behavior.
"""
user = get_session_user(request)
if user is None:
raise HTTPException(status_code=303, headers={"Location": "/auth/login"})
if settings.allowed_email and user.get("email") != settings.allowed_email:
raise HTTPException(status_code=403, detail="not authorized")
return user
+14
View File
@@ -0,0 +1,14 @@
from authlib.integrations.starlette_client import OAuth
from app.settings import settings
oauth = OAuth()
if settings.pocketid_issuer:
oauth.register(
name="pocketid",
server_metadata_url=f"{settings.pocketid_issuer}/.well-known/openid-configuration",
client_id=settings.pocketid_client_id,
client_secret=settings.pocketid_client_secret,
client_kwargs={"scope": "openid profile email"},
)
View File
+60
View File
@@ -0,0 +1,60 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="", extra="ignore")
# Mount points — see docker-compose.snippet.yml. Two volumes total:
# everything audio-related under MUSIC_DATA_DIR, everything else
# (app DB, beets config+DB, rendered pipeline credentials, job logs)
# under ALEMBIC_CONFIG_DIR.
music_data_dir: Path = Path("/data/music")
alembic_config_dir: Path = Path("/config")
pipeline_dir: Path = Path("/app/pipeline")
alembic_port: int = 8420
# Fernet key for the `secrets` table, read from a Docker secret file
# (not a plain env var) — see docker-compose.snippet.yml's `secrets:` block.
encryption_key_file: Path = Path("/run/secrets/alembic_master_key")
# Session cookie signing secret for Starlette's SessionMiddleware.
session_secret: str = "changeme-dev-only-override-in-production"
# Pocket ID OIDC client, registered once in the Pocket ID admin UI
# (redirect URI: https://<alembic-host>/auth/callback).
pocketid_issuer: str = ""
pocketid_client_id: str = ""
pocketid_client_secret: str = ""
# Defense-in-depth: this is a single-user app. If set, only this email
# may complete login even if Pocket ID ever grows a second account.
allowed_email: str = ""
@property
def beets_dir(self) -> Path:
return self.alembic_config_dir / "beets"
@property
def beets_db_path(self) -> Path:
return self.beets_dir / "data" / "beets_library.db"
@property
def pipeline_config_dir(self) -> Path:
"""Rendered/instance pipeline config: per-playlist .conf files,
credential env files, editable lists — NOT the static code in
pipeline_dir, which is baked into the image."""
return self.alembic_config_dir / "pipeline"
@property
def app_db_path(self) -> Path:
return self.alembic_config_dir / "alembic.db"
@property
def logs_dir(self) -> Path:
return self.alembic_config_dir / "logs"
settings = Settings()
+90
View File
@@ -0,0 +1,90 @@
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1a1a1a;
--muted: #666;
--border: #ddd;
--accent: #2a6f97;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14161a;
--fg: #e8e8e8;
--muted: #999;
--border: #333;
--accent: #6fb3d8;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
}
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.topbar .brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--accent);
text-decoration: none;
}
.topbar nav {
display: flex;
gap: 1rem;
flex: 1;
}
.topbar a {
color: var(--fg);
text-decoration: none;
}
.topbar nav a:hover {
color: var(--accent);
}
.logout {
font-size: 0.9rem;
color: var(--muted);
}
main {
max-width: 960px;
margin: 2rem auto;
padding: 0 1.25rem;
}
.muted { color: var(--muted); }
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
}
button, .btn {
background: var(--accent);
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}alembic{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">alembic</a>
<nav>
<a href="/">Dashboard</a>
<a href="/playlists">Playlists</a>
<a href="/library">Library</a>
<a href="/jobs">Jobs</a>
<a href="/dedup">Dedup</a>
<a href="/genres">Genres</a>
<a href="/import">Import</a>
<a href="/settings/credentials">Credentials</a>
</nav>
{% if user %}
<a class="logout" href="/auth/logout">Log out ({{ user.email or user.name }})</a>
{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Dashboard — alembic{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<p class="muted">
Logged in as {{ user.name or user.email }}. Playlist status, job history,
and pipeline health will render here once the corresponding services are
built (status_service, scheduler_service).
</p>
{% endblock %}
+2
View File
@@ -2,6 +2,8 @@
fastapi
uvicorn[standard]
jinja2
python-multipart
pydantic-settings
sqlalchemy
apscheduler
authlib