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:
+29
-3
@@ -1,14 +1,20 @@
|
||||
"""Artist endpoints."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
RecommendationServiceDep,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.album import AlbumOut
|
||||
from app.api.schemas.artist import ArtistOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.radio import SimilarArtistsOut
|
||||
from app.api.schemas.track import TrackOut
|
||||
from app.api.v1.albums import _build_album_out
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
@@ -124,4 +130,24 @@ async def get_artist_tracks(
|
||||
|
||||
|
||||
@router.get("/{artist_id}/similar")
|
||||
async def get_similar_artists(artist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
async def get_similar_artists(
|
||||
artist_id: uuid.UUID,
|
||||
service: RecommendationServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
_: CurrentUser,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> SimilarArtistsOut:
|
||||
"""Artists similar to this one (§6.5). ML when configured, else a shared-
|
||||
genre metadata heuristic."""
|
||||
source, artists = await service.similar_artists(artist_id, limit=limit)
|
||||
items = [
|
||||
ArtistOut(
|
||||
id=a.id,
|
||||
name=a.name,
|
||||
album_count=await artist_repo.album_count(a.id),
|
||||
track_count=await artist_repo.track_count(a.id),
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in artists
|
||||
]
|
||||
return SimilarArtistsOut(source=source, artists=items)
|
||||
|
||||
+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)
|
||||
|
||||
+20
-2
@@ -1,7 +1,7 @@
|
||||
"""Track endpoints."""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -14,6 +14,7 @@ from app.api.deps import (
|
||||
FileStorageDep,
|
||||
LyricsServiceDep,
|
||||
MetadataServiceDep,
|
||||
RecommendationServiceDep,
|
||||
RemoteLibraryServiceDep,
|
||||
StreamUser,
|
||||
TrackRepoDep,
|
||||
@@ -21,6 +22,7 @@ from app.api.deps import (
|
||||
from app.api.schemas.download import DownloadJobOut
|
||||
from app.api.schemas.lyrics import LyricsOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.radio import SimilarTracksOut
|
||||
from app.api.schemas.track import (
|
||||
MaterializeResponse,
|
||||
MetadataApply,
|
||||
@@ -221,7 +223,23 @@ async def delete_track(
|
||||
|
||||
|
||||
@router.get("/{track_id}/similar")
|
||||
async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
async def get_similar_tracks(
|
||||
track_id: uuid.UUID,
|
||||
service: RecommendationServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
) -> SimilarTracksOut:
|
||||
"""Tracks similar to this one (§6.5). Uses ML when configured, else a
|
||||
genre/artist metadata heuristic."""
|
||||
source, tracks = await service.similar_tracks(track_id, limit=limit)
|
||||
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 SimilarTracksOut(source=source, tracks=outs)
|
||||
|
||||
|
||||
@router.post("/{track_id}/optimize", status_code=202)
|
||||
|
||||
Reference in New Issue
Block a user