feat: expose recommendation streaming api

This commit is contained in:
Justin Visser 2026-08-10 13:41:43 +02:00
parent e8d20158e3
commit 41aa93c3c7
7 changed files with 473 additions and 12 deletions

View file

@ -0,0 +1,178 @@
"""Authenticated recommendation streaming and playlist creation routes."""
import asyncio
from collections.abc import AsyncIterator, Callable
from typing import cast
import structlog
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.adapters.spotify.errors import SpotifyError
from app.adapters.spotify.session import SpotifySession
from app.api.routes import resolve_session
from app.api.schemas import (
NDJSON_CONTENT_TYPE,
DoneEvent,
ErrorEvent,
MetadataEvent,
PlaylistCreateRequest,
PlaylistCreateResponse,
RecommendationRequest,
StreamEvent,
TrackCard,
TrackEvent,
WarningEvent,
)
from app.config import Settings
from app.domain.models import ConversationTurn, PreviousRecommendation
from app.pipeline.event import (
PipelineDoneEvent,
PipelineErrorEvent,
PipelineEvent,
PipelineMetadataEvent,
PipelineTrackEvent,
PipelineWarningEvent,
)
from app.pipeline.orchestrator import RecommendationPipeline
from app.ports.protocols import MusicCatalog, PlaylistWriter
SpotifyClientFactory = Callable[[SpotifySession], MusicCatalog | PlaylistWriter]
router = APIRouter()
@router.post("/api/recommendations")
async def recommendations(
request: Request,
payload: RecommendationRequest,
) -> StreamingResponse:
"""Stream one authenticated 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(
PreviousRecommendation(
item.rank,
item.track_id,
item.title,
tuple(item.artists),
)
for item in payload.prior_recommendations
)
return StreamingResponse(
_stream_lines(
request,
pipeline,
resolved.session_id,
request_id,
catalog,
payload.query,
history,
previous,
),
media_type=NDJSON_CONTENT_TYPE,
)
@router.post("/api/playlists", response_model=PlaylistCreateResponse)
async def create_playlist(
request: Request,
payload: PlaylistCreateRequest,
) -> PlaylistCreateResponse:
"""Create and fill one authenticated Spotify playlist."""
resolved = resolve_session(request)
if resolved is None:
raise HTTPException(status_code=401, detail="Not authenticated")
settings = cast(Settings, request.app.state.settings)
factory = cast(SpotifyClientFactory, request.app.state.spotify_client_factory)
writer = cast(PlaylistWriter, factory(resolved.session))
playlist_name = f"{settings.playlist_name_prefix} {payload.name}"
try:
playlist = await writer.create_playlist(
playlist_name,
"Music discovery selected by the listener.",
)
await writer.add_tracks_to_playlist(playlist.id, payload.track_uris)
except (SpotifyError, ValueError) as error:
structlog.get_logger().warning("playlist_write_failed", error_type=type(error).__name__)
raise HTTPException(status_code=502, detail="Spotify playlist creation failed") from error
return PlaylistCreateResponse(url=playlist.url)
async def _stream_lines(
request: Request,
pipeline: RecommendationPipeline,
session_id: str,
request_id: str,
catalog: MusicCatalog,
query: str,
history: tuple[ConversationTurn, ...],
previous: tuple[PreviousRecommendation, ...],
) -> AsyncIterator[str]:
event_stream = pipeline.stream(
session_id,
request_id,
catalog,
query,
history,
previous,
)
try:
async for event in event_stream:
if await request.is_disconnected():
return
yield f"{_to_wire_event(event).model_dump_json()}\n"
except asyncio.CancelledError:
raise
except Exception as error:
structlog.get_logger().exception(
"recommendation_stream_failed",
error_type=type(error).__name__,
)
if not await request.is_disconnected():
failure = ErrorEvent(
code="recommendation_failed",
message="Recommendation could not be completed.",
)
yield f"{failure.model_dump_json()}\n"
finally:
await event_stream.aclose()
def _to_wire_event(event: PipelineEvent) -> StreamEvent:
if isinstance(event, PipelineMetadataEvent):
return MetadataEvent(
request_id=event.request_id,
intent_summary=event.intent_summary,
candidate_count=event.candidate_count,
)
if isinstance(event, PipelineTrackEvent):
track = event.track
return TrackEvent(
rank=event.rank,
track=TrackCard(
id=track.id,
uri=track.uri,
title=track.title,
artists=list(track.artists),
album_name=track.album_name,
album_art_url=track.album_art_url,
external_url=track.external_url,
),
justification=event.justification,
)
if isinstance(event, PipelineWarningEvent):
return WarningEvent(code=event.code, message=event.message)
if isinstance(event, PipelineErrorEvent):
return ErrorEvent(code=event.code, message=event.message)
if isinstance(event, PipelineDoneEvent):
return DoneEvent(track_count=event.track_count, total_ms=event.total_ms)
raise AssertionError("Unhandled pipeline event")

View file

@ -1,5 +1,6 @@
"""HTTP routes for Spotify login and session management."""
from dataclasses import dataclass
from typing import cast
import httpx2
@ -7,7 +8,7 @@ from fastapi import APIRouter, Request
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
from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession
from app.config import Settings
SESSION_COOKIE_NAME = "discovery_session"
@ -15,6 +16,14 @@ SESSION_COOKIE_NAME = "discovery_session"
router = APIRouter()
@dataclass(frozen=True)
class ResolvedSession:
"""A session and stable cache key selected for one request."""
session_id: str
session: SpotifySession
@router.get("/api/auth/login")
def login(request: Request) -> RedirectResponse:
"""Start Spotify Authorization Code with PKCE login."""
@ -65,12 +74,10 @@ async def callback(
@router.get("/api/auth/me")
def current_session(request: Request) -> JSONResponse:
"""Return the display name for a valid application session."""
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session_store = cast(SessionStore, request.app.state.session_store)
session = session_store.get(session_id) if session_id is not None else None
if session is None:
resolved = resolve_session(request)
if resolved is None:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return JSONResponse({"display_name": session.display_name})
return JSONResponse({"display_name": resolved.session.display_name})
@router.post("/api/auth/logout", status_code=204)
@ -95,3 +102,19 @@ def logout(request: Request) -> Response:
def _login_error_redirect() -> RedirectResponse:
return RedirectResponse("/?login=error", status_code=307)
def resolve_session(request: Request) -> ResolvedSession | None:
"""Resolve the cookie session or the installed live seed 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:
cookie_session = session_store.get(cookie_session_id)
if cookie_session is not None:
return ResolvedSession(cookie_session_id, cookie_session)
seed_session_id = cast(str | None, getattr(request.app.state, "seed_session_id", None))
seed_session = session_store.get(seed_session_id) if seed_session_id is not None else None
if seed_session is None or seed_session_id is None:
return None
return ResolvedSession(seed_session_id, seed_session)