591a938e71
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>
84 lines
2.6 KiB
Python
84 lines
2.6 KiB
Python
"""Radio / continuous-mix endpoints (§6.5).
|
|
|
|
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(
|
|
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(
|
|
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)
|