591a938e71
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>
154 lines
4.6 KiB
Python
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)
|