Files
mcma-backend/app/api/v1/artists.py
T
Цвылев Александр Вадимович 591a938e71 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>
2026-07-28 21:59:15 +03:00

154 lines
4.6 KiB
Python

"""Artist endpoints."""
import uuid
from fastapi import APIRouter, Query
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
from app.domain.errors import NotFoundError
router = APIRouter(prefix="/artists", tags=["artists"])
@router.get("")
async def list_artists(
artist_repo: ArtistRepoDep,
_: CurrentUser,
q: str | None = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[ArtistOut]:
artists = await artist_repo.list(q=q, limit=limit, offset=offset)
total = await artist_repo.count(q=q)
items = []
for a in artists:
album_cnt = await artist_repo.album_count(a.id)
track_cnt = await artist_repo.track_count(a.id)
items.append(
ArtistOut(
id=a.id,
name=a.name,
album_count=album_cnt,
track_count=track_cnt,
created_at=a.created_at,
)
)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{artist_id}")
async def get_artist(
artist_id: uuid.UUID,
artist_repo: ArtistRepoDep,
_: CurrentUser,
) -> ArtistOut:
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError(f"Artist {artist_id} not found.")
album_cnt = await artist_repo.album_count(artist_id)
track_cnt = await artist_repo.track_count(artist_id)
return ArtistOut(
id=artist.id,
name=artist.name,
album_count=album_cnt,
track_count=track_cnt,
created_at=artist.created_at,
)
@router.get("/{artist_id}/albums")
async def get_artist_albums(
artist_id: uuid.UUID,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[AlbumOut]:
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError(f"Artist {artist_id} not found.")
albums = await album_repo.list(artist_id=artist_id, q=None, limit=limit, offset=offset)
total = await album_repo.count(artist_id=artist_id, q=None)
artists_map = {artist.id: artist}
track_counts = await album_repo.track_count_many([a.id for a in albums])
items = await _build_album_out(albums, artists_map, track_counts)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{artist_id}/tracks")
async def get_artist_tracks(
artist_id: uuid.UUID,
artist_repo: ArtistRepoDep,
track_repo: TrackRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[TrackOut]:
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError(f"Artist {artist_id} not found.")
tracks = await track_repo.list(
artist_id=artist_id,
album_id=None,
q=None,
sort_by="title",
order="asc",
limit=limit,
offset=offset,
)
total = await track_repo.count(artist_id=artist_id, album_id=None, q=None)
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
artists_map = {artist.id: artist}
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out(tracks, artists_map, albums_map)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{artist_id}/similar")
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)