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
+49
View File
@@ -128,6 +128,12 @@ class ArtistRepository(Protocol):
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: ...
async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]: ...
async def list_similar(self, *, artist_id: uuid.UUID, limit: int) -> list[Artist]:
"""Artists sharing the seed artist's genres, ranked by overlap. Metadata
fallback for ``GET /artists/{id}/similar``. Defined before ``list`` so the
``list[Artist]`` annotation isn't shadowed by the method named ``list``."""
...
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ...
async def count(self, *, q: str | None) -> int: ...
async def album_count(self, artist_id: uuid.UUID) -> int: ...
@@ -171,6 +177,24 @@ class TrackRepository(Protocol):
# AlbumRepository below).
async def genres(self) -> list[tuple[str, int]]: ...
async def library_stats(self) -> LibraryStats: ...
async def list_similar(
self,
*,
genre: str | None,
artist_id: uuid.UUID,
exclude_ids: list[uuid.UUID],
limit: int,
) -> list[Track]:
"""Playable tracks resembling a seed (same genre and/or artist), ranked
by match strength then shuffled. The metadata fallback for §6.5 radio /
similar when no ML service is configured."""
...
async def sample_playable(
self, *, exclude_ids: list[uuid.UUID], limit: int
) -> list[Track]:
"""Random playable tracks — the exploration filler for radio."""
...
async def find_duplicate_groups(self) -> list[tuple[str, list[Track]]]: ...
async def list_by_metadata_status(
self, status: str, *, limit: int, offset: int
@@ -497,6 +521,31 @@ class CoverArtProvider(Protocol):
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: ...
class Recommender(Protocol):
"""External ML recommender (plan §6.5, ``ML_SERVICE_URL``). Returns ordered
track/artist ids, or ``None`` when unavailable/erroring so the service falls
back to metadata heuristics — ML is never a hard dependency (invariant)."""
def is_available(self) -> bool: ...
async def similar_track_ids(
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
) -> list[uuid.UUID] | None: ...
async def similar_artist_ids(
self, artist_id: uuid.UUID, *, limit: int
) -> list[uuid.UUID] | None: ...
async def radio_track_ids(
self,
*,
seed_track_id: uuid.UUID | None,
exploration: float,
limit: int,
exclude_ids: list[uuid.UUID],
) -> list[uuid.UUID] | None: ...
class Transcoder(Protocol):
"""Transcodes an audio file with ffmpeg (plan §6.6 / Group B). ``to_opus``
writes a single Opus rendition; ``to_hls`` writes an HLS playlist + segments