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:
@@ -20,6 +20,7 @@ from app.application.auth_service import AuthService
|
|||||||
from app.application.download_service import DownloadService
|
from app.application.download_service import DownloadService
|
||||||
from app.application.lyrics_service import LyricsService
|
from app.application.lyrics_service import LyricsService
|
||||||
from app.application.metadata_service import MetadataEnrichmentService
|
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.remote_library_service import RemoteLibraryService
|
||||||
from app.application.streaming_service import StreamingService
|
from app.application.streaming_service import StreamingService
|
||||||
from app.application.subsonic_auth_service import SubsonicAuthService
|
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.fingerprint import FpcalcFingerprinter
|
||||||
from app.infrastructure.metadata.lrclib import LrclibHttpClient
|
from app.infrastructure.metadata.lrclib import LrclibHttpClient
|
||||||
from app.infrastructure.metadata.tags import MutagenTagReader
|
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.sources.registry import SourceRegistry, build_source_registry
|
||||||
from app.infrastructure.storage.provider import get_file_storage
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize
|
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:
|
def get_lyrics_service(session: SessionDep) -> LyricsService:
|
||||||
"""Wires the LRCLIB lyrics provider + cache repo (plan §6.7). LRCLIB is
|
"""Wires the LRCLIB lyrics provider + cache repo (plan §6.7). LRCLIB is
|
||||||
keyless, so this is always available; failures degrade to ``not_found``."""
|
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)]
|
MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)]
|
||||||
LyricsServiceDep = Annotated[LyricsService, Depends(get_lyrics_service)]
|
LyricsServiceDep = Annotated[LyricsService, Depends(get_lyrics_service)]
|
||||||
TranscodeServiceDep = Annotated[TranscodeService, Depends(get_transcode_service)]
|
TranscodeServiceDep = Annotated[TranscodeService, Depends(get_transcode_service)]
|
||||||
|
RecommendationServiceDep = Annotated[
|
||||||
|
RecommendationService, Depends(get_recommendation_service)
|
||||||
|
]
|
||||||
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
|
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
|
||||||
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
|
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
+29
-3
@@ -1,14 +1,20 @@
|
|||||||
"""Artist endpoints."""
|
"""Artist endpoints."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
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.album import AlbumOut
|
||||||
from app.api.schemas.artist import ArtistOut
|
from app.api.schemas.artist import ArtistOut
|
||||||
from app.api.schemas.pagination import PagedResponse
|
from app.api.schemas.pagination import PagedResponse
|
||||||
|
from app.api.schemas.radio import SimilarArtistsOut
|
||||||
from app.api.schemas.track import TrackOut
|
from app.api.schemas.track import TrackOut
|
||||||
from app.api.v1.albums import _build_album_out
|
from app.api.v1.albums import _build_album_out
|
||||||
from app.api.v1.tracks import _build_track_out
|
from app.api.v1.tracks import _build_track_out
|
||||||
@@ -124,4 +130,24 @@ async def get_artist_tracks(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{artist_id}/similar")
|
@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)
|
||||||
|
|||||||
+72
-4
@@ -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 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"])
|
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("")
|
@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")
|
@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)
|
||||||
|
|||||||
+20
-2
@@ -1,7 +1,7 @@
|
|||||||
"""Track endpoints."""
|
"""Track endpoints."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated, Any
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Response
|
from fastapi import APIRouter, Query, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -14,6 +14,7 @@ from app.api.deps import (
|
|||||||
FileStorageDep,
|
FileStorageDep,
|
||||||
LyricsServiceDep,
|
LyricsServiceDep,
|
||||||
MetadataServiceDep,
|
MetadataServiceDep,
|
||||||
|
RecommendationServiceDep,
|
||||||
RemoteLibraryServiceDep,
|
RemoteLibraryServiceDep,
|
||||||
StreamUser,
|
StreamUser,
|
||||||
TrackRepoDep,
|
TrackRepoDep,
|
||||||
@@ -21,6 +22,7 @@ from app.api.deps import (
|
|||||||
from app.api.schemas.download import DownloadJobOut
|
from app.api.schemas.download import DownloadJobOut
|
||||||
from app.api.schemas.lyrics import LyricsOut
|
from app.api.schemas.lyrics import LyricsOut
|
||||||
from app.api.schemas.pagination import PagedResponse
|
from app.api.schemas.pagination import PagedResponse
|
||||||
|
from app.api.schemas.radio import SimilarTracksOut
|
||||||
from app.api.schemas.track import (
|
from app.api.schemas.track import (
|
||||||
MaterializeResponse,
|
MaterializeResponse,
|
||||||
MetadataApply,
|
MetadataApply,
|
||||||
@@ -221,7 +223,23 @@ async def delete_track(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{track_id}/similar")
|
@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)
|
@router.post("/{track_id}/optimize", status_code=202)
|
||||||
|
|||||||
@@ -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))]
|
||||||
@@ -128,6 +128,12 @@ class ArtistRepository(Protocol):
|
|||||||
|
|
||||||
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: ...
|
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 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 list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ...
|
||||||
async def count(self, *, q: str | None) -> int: ...
|
async def count(self, *, q: str | None) -> int: ...
|
||||||
async def album_count(self, artist_id: uuid.UUID) -> int: ...
|
async def album_count(self, artist_id: uuid.UUID) -> int: ...
|
||||||
@@ -171,6 +177,24 @@ class TrackRepository(Protocol):
|
|||||||
# AlbumRepository below).
|
# AlbumRepository below).
|
||||||
async def genres(self) -> list[tuple[str, int]]: ...
|
async def genres(self) -> list[tuple[str, int]]: ...
|
||||||
async def library_stats(self) -> LibraryStats: ...
|
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 find_duplicate_groups(self) -> list[tuple[str, list[Track]]]: ...
|
||||||
async def list_by_metadata_status(
|
async def list_by_metadata_status(
|
||||||
self, status: str, *, limit: int, offset: int
|
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: ...
|
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):
|
class Transcoder(Protocol):
|
||||||
"""Transcodes an audio file with ffmpeg (plan §6.6 / Group B). ``to_opus``
|
"""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
|
writes a single Opus rendition; ``to_hls`` writes an HLS playlist + segments
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ class SqlAlchemyArtistRepository:
|
|||||||
)
|
)
|
||||||
return [_to_entity(r) for r in rows]
|
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]:
|
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]:
|
||||||
stmt = select(ArtistModel)
|
stmt = select(ArtistModel)
|
||||||
if q:
|
if q:
|
||||||
@@ -108,3 +128,4 @@ class SqlAlchemyArtistRepository:
|
|||||||
.where(TrackModel.artist_id == artist_id)
|
.where(TrackModel.artist_id == artist_id)
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import case, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.domain.entities.storage import FormatBreakdown, LibraryStats
|
from app.domain.entities.storage import FormatBreakdown, LibraryStats
|
||||||
@@ -136,6 +136,42 @@ class SqlAlchemyTrackRepository:
|
|||||||
).all()
|
).all()
|
||||||
return [(row.genre, row.cnt) for row in rows]
|
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:
|
async def library_stats(self) -> LibraryStats:
|
||||||
"""One-shot aggregate over the whole catalogue (no pagination). Defined
|
"""One-shot aggregate over the whole catalogue (no pagination). Defined
|
||||||
before ``list`` for the same shadowing reason as ``genres``."""
|
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