"""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))]