591a938e71
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>
188 lines
6.1 KiB
Python
188 lines
6.1 KiB
Python
"""Recommendation / radio service (plan §6.5).
|
|
|
|
Tries the external ML recommender first; when it's unavailable or declines
|
|
(returns ``None``), falls back to metadata heuristics over the catalogue — so
|
|
similar/radio always work, worse, without ML (graceful-degradation invariant).
|
|
|
|
``reason`` values are short codes (``ml`` / ``similar`` / ``from_likes`` /
|
|
``discover``) the client localizes for the "why is this playing?" affordance.
|
|
"""
|
|
|
|
import random
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
|
|
from app.domain.entities.track import Artist, Track
|
|
from app.domain.errors import NotFoundError
|
|
from app.domain.ports import (
|
|
ArtistRepository,
|
|
LikeRepository,
|
|
Recommender,
|
|
TrackRepository,
|
|
)
|
|
|
|
REASON_ML = "ml"
|
|
REASON_SIMILAR = "similar"
|
|
REASON_FROM_LIKES = "from_likes"
|
|
REASON_DISCOVER = "discover"
|
|
|
|
_LIKED_SEED_POOL = 50
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RadioPick:
|
|
track: Track
|
|
reason: str
|
|
|
|
|
|
class RecommendationService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
recommender: Recommender,
|
|
tracks: TrackRepository,
|
|
artists: ArtistRepository,
|
|
likes: LikeRepository,
|
|
) -> None:
|
|
self._recommender = recommender
|
|
self._tracks = tracks
|
|
self._artists = artists
|
|
self._likes = likes
|
|
|
|
# -- similar ---------------------------------------------------------------
|
|
|
|
async def similar_tracks(
|
|
self, track_id: uuid.UUID, *, limit: int
|
|
) -> tuple[str, list[Track]]:
|
|
seed = await self._tracks.get_by_id(track_id)
|
|
if seed is None:
|
|
raise NotFoundError(f"Track {track_id} not found.")
|
|
|
|
if self._recommender.is_available():
|
|
ids = await self._recommender.similar_track_ids(
|
|
track_id, limit=limit, exclude_ids=[track_id]
|
|
)
|
|
if ids is not None:
|
|
return REASON_ML, await self._hydrate_tracks(ids)
|
|
|
|
found = await self._tracks.list_similar(
|
|
genre=seed.genre,
|
|
artist_id=seed.artist_id,
|
|
exclude_ids=[track_id],
|
|
limit=limit,
|
|
)
|
|
return REASON_SIMILAR, found
|
|
|
|
async def similar_artists(
|
|
self, artist_id: uuid.UUID, *, limit: int
|
|
) -> tuple[str, list[Artist]]:
|
|
if await self._artists.get_by_id(artist_id) is None:
|
|
raise NotFoundError(f"Artist {artist_id} not found.")
|
|
|
|
if self._recommender.is_available():
|
|
ids = await self._recommender.similar_artist_ids(artist_id, limit=limit)
|
|
if ids is not None:
|
|
found = [a for i in ids if (a := await self._artists.get_by_id(i))]
|
|
return REASON_ML, found
|
|
|
|
found = await self._artists.list_similar(artist_id=artist_id, limit=limit)
|
|
return REASON_SIMILAR, found
|
|
|
|
# -- radio -----------------------------------------------------------------
|
|
|
|
async def radio(
|
|
self,
|
|
*,
|
|
user_id: uuid.UUID,
|
|
seed_track_id: uuid.UUID | None,
|
|
from_likes: bool,
|
|
exploration: float,
|
|
limit: int,
|
|
exclude_ids: list[uuid.UUID],
|
|
) -> tuple[str, list[RadioPick]]:
|
|
exploration = min(1.0, max(0.0, exploration))
|
|
|
|
if self._recommender.is_available():
|
|
ids = await self._recommender.radio_track_ids(
|
|
seed_track_id=seed_track_id,
|
|
exploration=exploration,
|
|
limit=limit,
|
|
exclude_ids=exclude_ids,
|
|
)
|
|
if ids is not None:
|
|
picks = [
|
|
RadioPick(track=t, reason=REASON_ML)
|
|
for t in await self._hydrate_tracks(ids)
|
|
]
|
|
return REASON_ML, picks
|
|
|
|
return "metadata", await self._radio_fallback(
|
|
user_id=user_id,
|
|
seed_track_id=seed_track_id,
|
|
from_likes=from_likes,
|
|
exploration=exploration,
|
|
limit=limit,
|
|
exclude_ids=exclude_ids,
|
|
)
|
|
|
|
async def _radio_fallback(
|
|
self,
|
|
*,
|
|
user_id: uuid.UUID,
|
|
seed_track_id: uuid.UUID | None,
|
|
from_likes: bool,
|
|
exploration: float,
|
|
limit: int,
|
|
exclude_ids: list[uuid.UUID],
|
|
) -> list[RadioPick]:
|
|
exclude = list(dict.fromkeys(exclude_ids)) # de-dupe, keep order
|
|
explore_n = round(limit * exploration)
|
|
similar_n = limit - explore_n
|
|
picks: list[RadioPick] = []
|
|
|
|
seed, seed_reason = await self._resolve_seed(
|
|
user_id, seed_track_id, from_likes
|
|
)
|
|
if seed is not None and similar_n > 0:
|
|
for track in await self._tracks.list_similar(
|
|
genre=seed.genre,
|
|
artist_id=seed.artist_id,
|
|
exclude_ids=exclude,
|
|
limit=similar_n,
|
|
):
|
|
picks.append(RadioPick(track=track, reason=seed_reason))
|
|
exclude.append(track.id)
|
|
|
|
# Fill the remainder (exploration + any similarity shortfall) with random
|
|
# playable tracks — this is also the total fallback when there's no seed.
|
|
remaining = limit - len(picks)
|
|
if remaining > 0:
|
|
for track in await self._tracks.sample_playable(
|
|
exclude_ids=exclude, limit=remaining
|
|
):
|
|
picks.append(RadioPick(track=track, reason=REASON_DISCOVER))
|
|
exclude.append(track.id)
|
|
|
|
random.shuffle(picks)
|
|
return picks
|
|
|
|
async def _resolve_seed(
|
|
self,
|
|
user_id: uuid.UUID,
|
|
seed_track_id: uuid.UUID | None,
|
|
from_likes: bool,
|
|
) -> tuple[Track | None, str]:
|
|
if seed_track_id is not None:
|
|
return await self._tracks.get_by_id(seed_track_id), REASON_SIMILAR
|
|
if from_likes:
|
|
liked = await self._likes.list_liked_tracks(
|
|
user_id=user_id, limit=_LIKED_SEED_POOL, offset=0
|
|
)
|
|
if liked:
|
|
return random.choice(liked), REASON_FROM_LIKES
|
|
return None, REASON_DISCOVER
|
|
|
|
async def _hydrate_tracks(self, ids: list[uuid.UUID]) -> list[Track]:
|
|
"""Resolve ids → tracks preserving order, skipping any that vanished."""
|
|
return [t for i in ids if (t := await self._tracks.get_by_id(i))]
|