Files
alembic/app/routers/connect.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

101 lines
4.1 KiB
Python

"""Spotify OAuth "Connect Spotify" flow.
Not on the /auth prefix or the /settings/credentials prefix: Spotify's app
dashboard needs one fixed redirect URI to register, and this is it in full:
https://<host>/connect/spotify/callback.
Unlike Pocket ID (app/security/oidc.py), Spotify's client id/secret live in
the encrypted credential store, not app.settings -- so this builds the
authorize/token requests directly with httpx instead of going through an
authlib client registered once at import time with fixed credentials.
"""
import base64
import secrets
import urllib.parse
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from app.db import get_db
from app.security.deps import require_auth
from app.services import credential_service
router = APIRouter(tags=["spotify-connect"])
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
TOKEN_URL = "https://accounts.spotify.com/api/token"
SCOPES = "playlist-read-private playlist-read-collaborative"
def _callback_redirect_uri(request: Request) -> str:
"""request.url_for() reflects the scheme uvicorn saw on the actual TCP
connection, not what the reverse proxy served over -- alembic sits behind
a TLS-terminating proxy (per every deployment example in the README) and
uvicorn isn't told to trust its X-Forwarded-Proto, so this comes back
`http://`. Spotify requires an exact scheme match against the redirect
URI registered in its app dashboard (always https for a real hostname),
so force it here rather than trust the raw connection's scheme."""
return str(request.url_for("spotify_callback").replace(scheme="https"))
@router.get("/connect/spotify")
async def connect_spotify(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
client_id = credential_service.get_scope(db, "spotify").get("client_id")
if not client_id:
# Nothing to connect to yet -- send them to save client_id/secret first.
return RedirectResponse(url="/settings/credentials", status_code=303)
# CSRF guard on the callback: the state we get back must match what we
# handed out for this session, not just be present.
state = secrets.token_urlsafe(24)
request.session["spotify_oauth_state"] = state
params = {
"client_id": client_id,
"response_type": "code",
"redirect_uri": _callback_redirect_uri(request),
"scope": SCOPES,
"state": state,
}
return RedirectResponse(url=f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}")
@router.get("/connect/spotify/callback", name="spotify_callback")
async def spotify_callback(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
if error := request.query_params.get("error"):
raise HTTPException(400, f"Spotify authorization failed: {error}")
state = request.query_params.get("state")
if not state or state != request.session.pop("spotify_oauth_state", None):
raise HTTPException(400, "Spotify authorization state mismatch")
code = request.query_params.get("code")
if not code:
raise HTTPException(400, "Spotify did not return an authorization code")
creds = credential_service.get_scope(db, "spotify")
client_id = creds.get("client_id")
client_secret = creds.get("client_secret")
if not client_id or not client_secret:
raise HTTPException(400, "Spotify client id/secret not configured")
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
resp = httpx.post(
TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _callback_redirect_uri(request),
},
headers={"Authorization": f"Basic {basic}"},
timeout=15,
)
resp.raise_for_status()
refresh_token = resp.json().get("refresh_token")
if not refresh_token:
raise HTTPException(400, "Spotify did not return a refresh token")
credential_service.set_credentials(db, "spotify", {"refresh_token": refresh_token})
return RedirectResponse(url="/settings/credentials", status_code=303)