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