feat(reco): radio + similar with metadata fallback (§6.5)
POST /radio + /radio/next (stateless infinite feed: seed track / from-likes,
exploration mix, client-passed exclude_ids) and GET /tracks|artists/{id}/similar,
replacing the stubs. Recommender port abstracts the (future) ML service —
NullRecommender is wired now so RecommendationService always uses its metadata
heuristics (genre/artist similarity, random exploration filler), never a hard ML
dependency. Adds TrackRepository.list_similar/sample_playable + Artist.list_similar,
reason codes for the client, RemoteRecommender skeleton (TODO: ML contract).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+72
-4
@@ -1,15 +1,83 @@
|
||||
"""Radio / continuous-mix endpoints. Degrades gracefully when ML service is down."""
|
||||
"""Radio / continuous-mix endpoints (§6.5).
|
||||
|
||||
from typing import Any
|
||||
Stateless: the client passes the seed + already-queued ids and pulls more as the
|
||||
queue drains (offline-first infinite feed). Degrades gracefully when no ML
|
||||
service is configured — the recommendation service falls back to metadata.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
RecommendationServiceDep,
|
||||
)
|
||||
from app.api.schemas.radio import RadioRequest, RadioResponse, RadioTrackOut
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
from app.application.recommendation_service import RadioPick
|
||||
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
|
||||
async def _to_response(
|
||||
source: str,
|
||||
picks: list[RadioPick],
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
) -> RadioResponse:
|
||||
tracks = [p.track for p in picks]
|
||||
artist_ids = list({t.artist_id for t in tracks})
|
||||
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
outs = await _build_track_out(tracks, artists, albums)
|
||||
return RadioResponse(
|
||||
source=source,
|
||||
tracks=[
|
||||
RadioTrackOut(track=out, reason=pick.reason)
|
||||
for out, pick in zip(outs, picks, strict=True)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _run_radio(
|
||||
body: RadioRequest,
|
||||
user: CurrentUser,
|
||||
service: RecommendationServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
) -> RadioResponse:
|
||||
source, picks = await service.radio(
|
||||
user_id=user.id,
|
||||
seed_track_id=body.seed_track_id,
|
||||
from_likes=body.from_likes,
|
||||
exploration=body.exploration,
|
||||
limit=body.count,
|
||||
exclude_ids=body.exclude_ids,
|
||||
)
|
||||
return await _to_response(source, picks, artist_repo, album_repo)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def start_radio() -> Any: ...
|
||||
async def start_radio(
|
||||
body: RadioRequest,
|
||||
user: CurrentUser,
|
||||
service: RecommendationServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
) -> RadioResponse:
|
||||
"""Start a radio from a seed track or the caller's likes."""
|
||||
return await _run_radio(body, user, service, artist_repo, album_repo)
|
||||
|
||||
|
||||
@router.post("/next")
|
||||
async def next_radio_track() -> Any: ...
|
||||
async def next_radio_track(
|
||||
body: RadioRequest,
|
||||
user: CurrentUser,
|
||||
service: RecommendationServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
) -> RadioResponse:
|
||||
"""Fetch more tracks as the radio queue drains (pass ``exclude_ids``)."""
|
||||
return await _run_radio(body, user, service, artist_repo, album_repo)
|
||||
|
||||
Reference in New Issue
Block a user