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:
Цвылев Александр Вадимович
2026-07-28 21:59:15 +03:00
parent 313af3a070
commit 591a938e71
11 changed files with 586 additions and 10 deletions
+17
View File
@@ -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)]
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)