diff --git a/app/api/deps.py b/app/api/deps.py index 872bf83..28f7c47 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -20,6 +20,7 @@ from app.application.auth_service import AuthService from app.application.download_service import DownloadService from app.application.lyrics_service import LyricsService from app.application.metadata_service import MetadataEnrichmentService +from app.application.recommendation_service import RecommendationService from app.application.remote_library_service import RemoteLibraryService from app.application.streaming_service import StreamingService from app.application.subsonic_auth_service import SubsonicAuthService @@ -51,6 +52,7 @@ from app.infrastructure.metadata.acoustid import AcoustIdHttpClient from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter from app.infrastructure.metadata.lrclib import LrclibHttpClient from app.infrastructure.metadata.tags import MutagenTagReader +from app.infrastructure.ml.recommender import NullRecommender from app.infrastructure.sources.registry import SourceRegistry, build_source_registry from app.infrastructure.storage.provider import get_file_storage from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize @@ -188,6 +190,18 @@ def get_transcode_service(session: SessionDep) -> TranscodeService: ) +def get_recommendation_service(session: SessionDep) -> RecommendationService: + """Radio + similarity (§6.5). ML is optional and no service/contract exists + yet, so we wire ``NullRecommender`` — the service then uses its metadata + fallback. Swap in ``RemoteRecommender(ml_service_url)`` once ML lands.""" + return RecommendationService( + recommender=NullRecommender(), + tracks=SqlAlchemyTrackRepository(session), + artists=SqlAlchemyArtistRepository(session), + likes=SqlAlchemyLikeRepository(session), + ) + + def get_lyrics_service(session: SessionDep) -> LyricsService: """Wires the LRCLIB lyrics provider + cache repo (plan §6.7). LRCLIB is keyless, so this is always available; failures degrade to ``not_found``.""" @@ -226,6 +240,9 @@ StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service) MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)] LyricsServiceDep = Annotated[LyricsService, Depends(get_lyrics_service)] TranscodeServiceDep = Annotated[TranscodeService, Depends(get_transcode_service)] +RecommendationServiceDep = Annotated[ + RecommendationService, Depends(get_recommendation_service) +] DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)] RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)] diff --git a/app/api/schemas/radio.py b/app/api/schemas/radio.py new file mode 100644 index 0000000..1149cb0 --- /dev/null +++ b/app/api/schemas/radio.py @@ -0,0 +1,43 @@ +"""Radio + similarity response schemas (§6.5).""" + +import uuid + +from pydantic import BaseModel, Field + +from app.api.schemas.artist import ArtistOut +from app.api.schemas.track import TrackOut + + +class RadioRequest(BaseModel): + """Start or continue a radio. ``seed_track_id`` seeds from a track; + ``from_likes`` seeds from the caller's likes. ``exclude_ids`` are already- + queued tracks to skip (the client drives the infinite feed). ``exploration`` + biases familiar↔new.""" + + seed_track_id: uuid.UUID | None = None + from_likes: bool = False + exploration: float = Field(default=0.25, ge=0.0, le=1.0) + count: int = Field(default=20, ge=1, le=50) + exclude_ids: list[uuid.UUID] = Field(default_factory=list) + + +class RadioTrackOut(BaseModel): + track: TrackOut + # Short code the client localizes: ml | similar | from_likes | discover. + reason: str + + +class RadioResponse(BaseModel): + # Where the picks came from: "ml" or "metadata" (fallback). + source: str + tracks: list[RadioTrackOut] + + +class SimilarTracksOut(BaseModel): + source: str + tracks: list[TrackOut] + + +class SimilarArtistsOut(BaseModel): + source: str + artists: list[ArtistOut] diff --git a/app/api/v1/artists.py b/app/api/v1/artists.py index 5639a3d..4ccca2b 100644 --- a/app/api/v1/artists.py +++ b/app/api/v1/artists.py @@ -1,14 +1,20 @@ """Artist endpoints.""" import uuid -from typing import Any from fastapi import APIRouter, Query -from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep +from app.api.deps import ( + AlbumRepoDep, + ArtistRepoDep, + CurrentUser, + RecommendationServiceDep, + TrackRepoDep, +) from app.api.schemas.album import AlbumOut from app.api.schemas.artist import ArtistOut from app.api.schemas.pagination import PagedResponse +from app.api.schemas.radio import SimilarArtistsOut from app.api.schemas.track import TrackOut from app.api.v1.albums import _build_album_out from app.api.v1.tracks import _build_track_out @@ -124,4 +130,24 @@ async def get_artist_tracks( @router.get("/{artist_id}/similar") -async def get_similar_artists(artist_id: uuid.UUID, _: CurrentUser) -> Any: ... +async def get_similar_artists( + artist_id: uuid.UUID, + service: RecommendationServiceDep, + artist_repo: ArtistRepoDep, + _: CurrentUser, + limit: int = Query(20, ge=1, le=100), +) -> SimilarArtistsOut: + """Artists similar to this one (§6.5). ML when configured, else a shared- + genre metadata heuristic.""" + source, artists = await service.similar_artists(artist_id, limit=limit) + items = [ + ArtistOut( + id=a.id, + name=a.name, + album_count=await artist_repo.album_count(a.id), + track_count=await artist_repo.track_count(a.id), + created_at=a.created_at, + ) + for a in artists + ] + return SimilarArtistsOut(source=source, artists=items) diff --git a/app/api/v1/radio.py b/app/api/v1/radio.py index cd5a591..9c58540 100644 --- a/app/api/v1/radio.py +++ b/app/api/v1/radio.py @@ -1,15 +1,83 @@ -"""Radio / continuous-mix endpoints. Degrades gracefully when ML service is down.""" +"""Radio / continuous-mix endpoints (§6.5). -from typing import Any +Stateless: the client passes the seed + already-queued ids and pulls more as the +queue drains (offline-first infinite feed). Degrades gracefully when no ML +service is configured — the recommendation service falls back to metadata. +""" from fastapi import APIRouter +from app.api.deps import ( + AlbumRepoDep, + ArtistRepoDep, + CurrentUser, + RecommendationServiceDep, +) +from app.api.schemas.radio import RadioRequest, RadioResponse, RadioTrackOut +from app.api.v1.tracks import _build_track_out +from app.application.recommendation_service import RadioPick + router = APIRouter(prefix="/radio", tags=["radio"]) +async def _to_response( + source: str, + picks: list[RadioPick], + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, +) -> RadioResponse: + tracks = [p.track for p in picks] + artist_ids = list({t.artist_id for t in tracks}) + album_ids = list({t.album_id for t in tracks if t.album_id is not None}) + artists = {a.id: a for a in await artist_repo.get_many(artist_ids)} + albums = {a.id: a for a in await album_repo.get_many(album_ids)} + outs = await _build_track_out(tracks, artists, albums) + return RadioResponse( + source=source, + tracks=[ + RadioTrackOut(track=out, reason=pick.reason) + for out, pick in zip(outs, picks, strict=True) + ], + ) + + +async def _run_radio( + body: RadioRequest, + user: CurrentUser, + service: RecommendationServiceDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, +) -> RadioResponse: + source, picks = await service.radio( + user_id=user.id, + seed_track_id=body.seed_track_id, + from_likes=body.from_likes, + exploration=body.exploration, + limit=body.count, + exclude_ids=body.exclude_ids, + ) + return await _to_response(source, picks, artist_repo, album_repo) + + @router.post("") -async def start_radio() -> Any: ... +async def start_radio( + body: RadioRequest, + user: CurrentUser, + service: RecommendationServiceDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, +) -> RadioResponse: + """Start a radio from a seed track or the caller's likes.""" + return await _run_radio(body, user, service, artist_repo, album_repo) @router.post("/next") -async def next_radio_track() -> Any: ... +async def next_radio_track( + body: RadioRequest, + user: CurrentUser, + service: RecommendationServiceDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, +) -> RadioResponse: + """Fetch more tracks as the radio queue drains (pass ``exclude_ids``).""" + return await _run_radio(body, user, service, artist_repo, album_repo) diff --git a/app/api/v1/tracks.py b/app/api/v1/tracks.py index c57ae93..7f71530 100644 --- a/app/api/v1/tracks.py +++ b/app/api/v1/tracks.py @@ -1,7 +1,7 @@ """Track endpoints.""" import uuid -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Query, Response from fastapi.responses import StreamingResponse @@ -14,6 +14,7 @@ from app.api.deps import ( FileStorageDep, LyricsServiceDep, MetadataServiceDep, + RecommendationServiceDep, RemoteLibraryServiceDep, StreamUser, TrackRepoDep, @@ -21,6 +22,7 @@ from app.api.deps import ( from app.api.schemas.download import DownloadJobOut from app.api.schemas.lyrics import LyricsOut from app.api.schemas.pagination import PagedResponse +from app.api.schemas.radio import SimilarTracksOut from app.api.schemas.track import ( MaterializeResponse, MetadataApply, @@ -221,7 +223,23 @@ async def delete_track( @router.get("/{track_id}/similar") -async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ... +async def get_similar_tracks( + track_id: uuid.UUID, + service: RecommendationServiceDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, + _: CurrentUser, + limit: Annotated[int, Query(ge=1, le=100)] = 20, +) -> SimilarTracksOut: + """Tracks similar to this one (§6.5). Uses ML when configured, else a + genre/artist metadata heuristic.""" + source, tracks = await service.similar_tracks(track_id, limit=limit) + artist_ids = list({t.artist_id for t in tracks}) + album_ids = list({t.album_id for t in tracks if t.album_id is not None}) + artists = {a.id: a for a in await artist_repo.get_many(artist_ids)} + albums = {a.id: a for a in await album_repo.get_many(album_ids)} + outs = await _build_track_out(tracks, artists, albums) + return SimilarTracksOut(source=source, tracks=outs) @router.post("/{track_id}/optimize", status_code=202) diff --git a/app/application/recommendation_service.py b/app/application/recommendation_service.py new file mode 100644 index 0000000..256de39 --- /dev/null +++ b/app/application/recommendation_service.py @@ -0,0 +1,187 @@ +"""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))] diff --git a/app/domain/ports.py b/app/domain/ports.py index 956c266..786de76 100644 --- a/app/domain/ports.py +++ b/app/domain/ports.py @@ -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 diff --git a/app/infrastructure/db/repositories/artist_repository.py b/app/infrastructure/db/repositories/artist_repository.py index 0f810e6..d2ad805 100644 --- a/app/infrastructure/db/repositories/artist_repository.py +++ b/app/infrastructure/db/repositories/artist_repository.py @@ -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() + diff --git a/app/infrastructure/db/repositories/track_repository.py b/app/infrastructure/db/repositories/track_repository.py index 521628b..9b5cd59 100644 --- a/app/infrastructure/db/repositories/track_repository.py +++ b/app/infrastructure/db/repositories/track_repository.py @@ -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``.""" diff --git a/app/infrastructure/ml/__init__.py b/app/infrastructure/ml/__init__.py new file mode 100644 index 0000000..be4158a --- /dev/null +++ b/app/infrastructure/ml/__init__.py @@ -0,0 +1 @@ +"""ML/recommender adapters (plan §6.5). ML is optional — see Recommender port.""" diff --git a/app/infrastructure/ml/recommender.py b/app/infrastructure/ml/recommender.py new file mode 100644 index 0000000..5159ba7 --- /dev/null +++ b/app/infrastructure/ml/recommender.py @@ -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], + }, + )