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.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)]
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
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)
|
||||
|
||||
+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 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)
|
||||
|
||||
+20
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user