Files
alembic/app/main.py
T
andrew e5d9c7f043 0.6: Spotify OAuth connect flow, AzuraCast base URL, fingerprint/buy-url fixes
- Add "Connect Spotify" OAuth flow (/connect/spotify) so newly created Spotify
  apps can read playlists: Spotify now requires a user token for playlist
  reads, which client-credentials alone can no longer provide. The stored
  refresh token is rendered as spotify-refresh into each playlist's sldl
  .conf -- sldl's own docs confirm supplying it skips its interactive login
  flow, which is what was causing multi-hour hangs on a stuck sync.
  Grandfathered apps keep working unchanged if no account is connected.
- Fix the connect flow's redirect_uri: request.url_for() reflected the raw
  connection scheme (http) rather than what the reverse proxy actually
  served over, which Spotify's exact-match redirect_uri check rejected.
  Force https rather than trust the unproxied scheme.
- Add an AzuraCast base URL credential field alongside the API key, with a
  keyfile fallback so the scheduled enrich-buy-url.py job can actually reach
  AzuraCast (previously it had no way to receive a base URL at all when run
  from the scheduler, so the reprocess call was silently always skipped).
- Fix build-fingerprint-index.py exiting non-zero on any single fingerprint
  failure instead of only when nothing was written.
- Guard enrich-buy-url.py's AzuraCast reprocess against an empty
  --azuracast-base (raised ValueError since PD2 removed the hardcoded base).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 11:16:55 -06:00

93 lines
3.3 KiB
Python

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 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,
)
@asynccontextmanager
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()
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()