178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
"""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")
|