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
@@ -77,6 +77,26 @@ class SqlAlchemyArtistRepository:
)
return [_to_entity(r) for r in rows]
async def list_similar(self, *, artist_id: uuid.UUID, limit: int) -> list[Artist]:
# Artists whose tracks fall in the seed artist's genres, ranked by how
# many such tracks they have. Defined before ``list`` so the ``list[Artist]``
# return annotation isn't shadowed by the method named ``list``.
seed_genres = (
select(TrackModel.genre)
.where(TrackModel.artist_id == artist_id, TrackModel.genre.is_not(None))
.distinct()
)
stmt = (
select(ArtistModel)
.join(TrackModel, TrackModel.artist_id == ArtistModel.id)
.where(TrackModel.genre.in_(seed_genres), ArtistModel.id != artist_id)
.group_by(ArtistModel.id)
.order_by(func.count(TrackModel.id).desc())
.limit(limit)
)
rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows]
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]:
stmt = select(ArtistModel)
if q:
@@ -108,3 +128,4 @@ class SqlAlchemyArtistRepository:
.where(TrackModel.artist_id == artist_id)
)
).scalar_one()