"""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")