feat: add key-free demo mode replaying recorded fixtures

This commit is contained in:
Justin Visser 2026-08-11 09:14:22 +02:00
parent 0664cc2d27
commit 401a0ddea7
21 changed files with 1040 additions and 31 deletions

View file

@ -1,4 +1,4 @@
"""Authenticated recommendation streaming and playlist creation routes."""
"""Recommendation streaming and playlist creation routes."""
import asyncio
from collections.abc import AsyncIterator, Callable
@ -38,6 +38,10 @@ from app.pipeline.orchestrator import RecommendationPipeline
from app.ports.protocols import MusicCatalog, PlaylistWriter
SpotifyClientFactory = Callable[[SpotifySession], MusicCatalog | PlaylistWriter]
MusicCatalogFactory = Callable[
[SpotifySession, str, tuple[PreviousRecommendation, ...]],
MusicCatalog,
]
router = APIRouter()
@ -47,14 +51,12 @@ async def recommendations(
request: Request,
payload: RecommendationRequest,
) -> StreamingResponse:
"""Stream one authenticated discovery response as NDJSON."""
"""Stream one session-resolved discovery response as NDJSON."""
resolved = resolve_session(request)
if resolved is None:
raise HTTPException(status_code=401, detail="Not authenticated")
pipeline = cast(RecommendationPipeline, request.app.state.recommendation_pipeline)
factory = cast(SpotifyClientFactory, request.app.state.spotify_client_factory)
catalog = cast(MusicCatalog, factory(resolved.session))
request_id = cast(str, request.state.request_id)
history = tuple(ConversationTurn(turn.role, turn.content) for turn in payload.history)
previous = tuple(
@ -66,6 +68,8 @@ async def recommendations(
)
for item in payload.prior_recommendations
)
factory = cast(MusicCatalogFactory, request.app.state.music_catalog_factory)
catalog = factory(resolved.session, payload.query, previous)
return StreamingResponse(
_stream_lines(
@ -86,7 +90,7 @@ async def create_playlist(
request: Request,
payload: PlaylistCreateRequest,
) -> PlaylistCreateResponse:
"""Create and fill one authenticated Spotify playlist."""
"""Create and fill one live or simulated playlist."""
resolved = resolve_session(request)
if resolved is None:
raise HTTPException(status_code=401, detail="Not authenticated")

View file

@ -1,4 +1,4 @@
"""HTTP routes for Spotify login and session management."""
"""HTTP routes for login and session management."""
from dataclasses import dataclass
from typing import cast
@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
from app.adapters.spotify.login import begin_login, complete_login
from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession
from app.config import Settings
from app.config import AppMode, Settings
SESSION_COOKIE_NAME = "discovery_session"
@ -26,8 +26,10 @@ class ResolvedSession:
@router.get("/api/auth/login")
def login(request: Request) -> RedirectResponse:
"""Start Spotify Authorization Code with PKCE login."""
"""Start the configured live or demo login flow."""
application_settings = cast(Settings, request.app.state.settings)
if application_settings.app_mode is AppMode.DEMO:
return RedirectResponse("/?login=demo", status_code=307)
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
authorize_url = begin_login(application_settings, pending_logins)
return RedirectResponse(authorize_url, status_code=307)
@ -40,11 +42,13 @@ async def callback(
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
"""Complete Spotify login and establish an opaque cookie session."""
"""Complete live login and establish an opaque cookie session."""
application_settings = cast(Settings, request.app.state.settings)
if application_settings.app_mode is AppMode.DEMO:
return RedirectResponse("/?login=demo", status_code=307)
if error is not None or code is None or state is None:
return _login_error_redirect()
application_settings = cast(Settings, request.app.state.settings)
http = cast(httpx2.AsyncClient, request.app.state.http)
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
session_store = cast(SessionStore, request.app.state.session_store)
@ -80,6 +84,12 @@ def current_session(request: Request) -> JSONResponse:
return JSONResponse({"display_name": resolved.session.display_name})
@router.get("/api/suggestions")
def suggestions(request: Request) -> list[dict[str, str]]:
"""Return shared non-refinement suggestion chips and queries."""
return cast(list[dict[str, str]], request.app.state.suggestions)
@router.post("/api/auth/logout", status_code=204)
def logout(request: Request) -> Response:
"""Remove the current application session and clear its cookie."""
@ -105,7 +115,11 @@ def _login_error_redirect() -> RedirectResponse:
def resolve_session(request: Request) -> ResolvedSession | None:
"""Resolve the cookie session or the installed live seed session."""
"""Resolve the stable demo identity, cookie, or live seed session."""
application_settings = cast(Settings, request.app.state.settings)
if application_settings.app_mode is AppMode.DEMO:
demo_session = cast(SpotifySession, request.app.state.demo_session)
return ResolvedSession("demo", demo_session)
session_store = cast(SessionStore, request.app.state.session_store)
cookie_session_id = request.cookies.get(SESSION_COOKIE_NAME)
if cookie_session_id is not None: