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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user