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:
@@ -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()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.storage import FormatBreakdown, LibraryStats
|
||||
@@ -136,6 +136,42 @@ class SqlAlchemyTrackRepository:
|
||||
).all()
|
||||
return [(row.genre, row.cnt) for row in rows]
|
||||
|
||||
async def list_similar(
|
||||
self,
|
||||
*,
|
||||
genre: str | None,
|
||||
artist_id: uuid.UUID,
|
||||
exclude_ids: list[uuid.UUID],
|
||||
limit: int,
|
||||
) -> list[Track]:
|
||||
# Rank a same-genre hit above a same-artist hit; shuffle within a tier so
|
||||
# the mix varies. Only playable (locally-stored) tracks are candidates.
|
||||
if genre is not None:
|
||||
match = or_(TrackModel.genre == genre, TrackModel.artist_id == artist_id)
|
||||
score = case((TrackModel.genre == genre, 2), else_=0) + case(
|
||||
(TrackModel.artist_id == artist_id, 1), else_=0
|
||||
)
|
||||
else:
|
||||
match = TrackModel.artist_id == artist_id
|
||||
score = case((TrackModel.artist_id == artist_id, 1), else_=0)
|
||||
|
||||
stmt = select(TrackModel).where(TrackModel.storage_uri.is_not(None), match)
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(TrackModel.id.not_in(exclude_ids))
|
||||
stmt = stmt.order_by(score.desc(), func.random()).limit(limit)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def sample_playable(
|
||||
self, *, exclude_ids: list[uuid.UUID], limit: int
|
||||
) -> list[Track]:
|
||||
stmt = select(TrackModel).where(TrackModel.storage_uri.is_not(None))
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(TrackModel.id.not_in(exclude_ids))
|
||||
stmt = stmt.order_by(func.random()).limit(limit)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def library_stats(self) -> LibraryStats:
|
||||
"""One-shot aggregate over the whole catalogue (no pagination). Defined
|
||||
before ``list`` for the same shadowing reason as ``genres``."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""ML/recommender adapters (plan §6.5). ML is optional — see Recommender port."""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Recommender adapters (plan §6.5).
|
||||
|
||||
``NullRecommender`` is the default: no ML service, so every method reports
|
||||
unavailable and the ``RecommendationService`` uses its metadata fallback. When
|
||||
an embedding service exists, wire ``RemoteRecommender`` (skeleton below) to
|
||||
``ML_SERVICE_URL`` — its exact request/response contract is TODO pending that
|
||||
service. Both keep the invariant: ML is optional, never a hard dependency.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class NullRecommender:
|
||||
"""No ML configured — always unavailable, always ``None`` (→ fallback)."""
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return False
|
||||
|
||||
async def similar_track_ids(
|
||||
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
|
||||
) -> list[uuid.UUID] | None:
|
||||
return None
|
||||
|
||||
async def similar_artist_ids(
|
||||
self, artist_id: uuid.UUID, *, limit: int
|
||||
) -> list[uuid.UUID] | None:
|
||||
return 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:
|
||||
return None
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
class RemoteRecommender:
|
||||
"""HTTP client for an external embedding/recommender service.
|
||||
|
||||
TODO: the request/response schema below is a placeholder — align it with the
|
||||
real ML service once its contract is known. Until then this stays unused
|
||||
(``deps`` wires ``NullRecommender``). Every call is defensive: any error or a
|
||||
malformed body returns ``None`` so the service degrades to metadata, matching
|
||||
the graceful-degradation invariant.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _post_ids(self, path: str, payload: dict[str, object]) -> list[uuid.UUID] | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(f"{self._base_url}{path}", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
ids = data.get("track_ids") if isinstance(data, dict) else None
|
||||
if not isinstance(ids, list):
|
||||
return None
|
||||
return [uuid.UUID(str(i)) for i in ids]
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
log.warning("recommender.remote_failed", path=path, error=str(exc))
|
||||
return None
|
||||
|
||||
async def similar_track_ids(
|
||||
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
|
||||
) -> list[uuid.UUID] | None:
|
||||
return await self._post_ids(
|
||||
"/similar/tracks",
|
||||
{"track_id": str(track_id), "limit": limit,
|
||||
"exclude": [str(i) for i in exclude_ids]},
|
||||
)
|
||||
|
||||
async def similar_artist_ids(
|
||||
self, artist_id: uuid.UUID, *, limit: int
|
||||
) -> list[uuid.UUID] | None:
|
||||
# Artist recommendations aren't part of the placeholder track contract.
|
||||
return 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:
|
||||
return await self._post_ids(
|
||||
"/radio",
|
||||
{
|
||||
"seed_track_id": str(seed_track_id) if seed_track_id else None,
|
||||
"exploration": exploration,
|
||||
"limit": limit,
|
||||
"exclude": [str(i) for i in exclude_ids],
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user