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:
Цвылев Александр Вадимович
2026-07-28 21:59:15 +03:00
parent 313af3a070
commit 591a938e71
11 changed files with 586 additions and 10 deletions
+20 -2
View File
@@ -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)