import logging from contextlib import asynccontextmanager 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 SessionLocal, enable_beets_db_wal, init_db, mark_interrupted_runs from app.routers import artist_casing, auth, connect, 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: """The Fernet master key decrypts every stored credential. If it lives inside the config volume, one leaked volume or backup exposes both the key and the encrypted database together, which defeats the encryption. Warn loudly rather than fail, since some users deliberately accept this.""" try: key_path = settings.encryption_key_file.resolve() config_dir = settings.alembic_config_dir.resolve() except OSError: return if config_dir == key_path or config_dir in key_path.parents: log.warning( "Master key %s is inside the config directory %s. Anyone with a " "copy of that volume or backup gets both the key and the encrypted " "credentials. Mount the key from a separate location kept out of " "the config backup. See docker-compose.snippet.yml.", key_path, config_dir, ) def _render_confs_on_startup() -> None: """Re-render every playlist .conf from the current template once per boot, so confs rendered by an older version converge on upgrade without waiting for a playlist or credential change. Concretely: 0.6.2 dropped the Spotify credential lines from confs (sldl no longer talks to Spotify), and this is what scrubs those secrets from already-deployed confs. Best-effort -- a render failure shouldn't stop the app from starting.""" from app.services import credential_service db = SessionLocal() try: credential_service.render_playlist_confs(db) except Exception: log.exception("Startup playlist .conf render failed; continuing") finally: db.close() @asynccontextmanager async def lifespan(_app: FastAPI): _warn_if_key_colocated_with_config() init_db() mark_interrupted_runs() enable_beets_db_wal() _render_confs_on_startup() scheduler = scheduler_service.create_scheduler() scheduler_service.register_all_jobs(scheduler) scheduler.start() yield scheduler.shutdown(wait=False) 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") app.include_router(health.router) app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(library.router) app.include_router(import_.router) app.include_router(dedup.router) app.include_router(genres.router) app.include_router(playlists.router) app.include_router(credentials.router) app.include_router(connect.router) app.include_router(jobs.router) app.include_router(artist_casing.router) return app app = create_app()