"""Track endpoints.""" import uuid from typing import Any from fastapi import APIRouter, Query, Response from fastapi.responses import StreamingResponse from app.api.covers import resolve_album_for_track, stream_cover from app.api.deps import ( AlbumRepoDep, ArtistRepoDep, CurrentUser, FileStorageDep, MetadataServiceDep, StreamUser, TrackRepoDep, ) from app.api.schemas.pagination import PagedResponse from app.api.schemas.track import ( MetadataApply, MetadataMatch, MetadataMatchesOut, TrackOut, TrackUpdate, ) from app.domain.entities.album import Album from app.domain.entities.track import Artist, Track from app.domain.errors import NotFoundError from app.workers.queue import enqueue router = APIRouter(prefix="/tracks", tags=["tracks"]) async def _build_track_out( tracks: list[Track], artists: dict[uuid.UUID, Artist], albums: dict[uuid.UUID, Album], ) -> list[TrackOut]: return [ TrackOut( id=t.id, title=t.title, artist_id=t.artist_id, artist_name=artists[t.artist_id].name if t.artist_id in artists else "Unknown Artist", album_id=t.album_id, album_title=albums[t.album_id].title if t.album_id and t.album_id in albums else None, duration_seconds=t.duration_seconds, file_format=t.file_format, file_size=t.file_size, genre=t.genre, year=t.year, track_number=t.track_number, metadata_status=t.metadata_status, metadata_error=t.metadata_error, enriched_at=t.enriched_at, source=t.source, has_cover=bool(t.album_id and albums.get(t.album_id) and albums[t.album_id].cover_path), created_at=t.created_at, ) for t in tracks ] @router.get("") async def list_tracks( track_repo: TrackRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, _: CurrentUser, artist_id: uuid.UUID | None = None, album_id: uuid.UUID | None = None, q: str | None = None, source: str | None = Query(None, max_length=32), sort_by: str = Query("created_at", pattern="^(title|created_at|artist)$"), order: str = Query("desc", pattern="^(asc|desc)$"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> PagedResponse[TrackOut]: tracks = await track_repo.list( artist_id=artist_id, album_id=album_id, q=q, source=source, sort_by=sort_by, order=order, limit=limit, offset=offset, ) total = await track_repo.count( artist_id=artist_id, album_id=album_id, q=q, source=source ) 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)} items = await _build_track_out(tracks, artists, albums) return PagedResponse(items=items, total=total, limit=limit, offset=offset) @router.get("/{track_id}") async def get_track( track_id: uuid.UUID, track_repo: TrackRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, _: CurrentUser, ) -> TrackOut: track = await track_repo.get_by_id(track_id) if track is None: raise NotFoundError(f"Track {track_id} not found.") artist_ids = [track.artist_id] album_ids = [track.album_id] if track.album_id else [] 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)} items = await _build_track_out([track], artists, albums) return items[0] @router.patch("/{track_id}") async def update_track( track_id: uuid.UUID, body: TrackUpdate, track_repo: TrackRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, _: CurrentUser, ) -> TrackOut: track = await track_repo.update( track_id, title=body.title, genre=body.genre, year=body.year, ) artist_ids = [track.artist_id] album_ids = [track.album_id] if track.album_id else [] 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)} items = await _build_track_out([track], artists, albums) return items[0] @router.delete("/{track_id}", status_code=204) async def delete_track( track_id: uuid.UUID, track_repo: TrackRepoDep, storage: FileStorageDep, _: CurrentUser, ) -> Response: track = await track_repo.get_by_id(track_id) if track is None: raise NotFoundError(f"Track {track_id} not found.") await track_repo.delete(track_id) await storage.delete(track.storage_uri) return Response(status_code=204) @router.get("/{track_id}/similar") async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ... @router.post("/{track_id}/optimize") async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ... @router.get("/{track_id}/cover") async def get_track_cover( track_id: uuid.UUID, track_repo: TrackRepoDep, album_repo: AlbumRepoDep, storage: FileStorageDep, _: StreamUser, ) -> StreamingResponse: # A track's cover is its album's cover. ```` can't send a bearer # header → StreamUser accepts ``?token=``. album = await resolve_album_for_track(track_repo, album_repo, track_id) if album is None or not album.cover_path: raise NotFoundError("Cover not found.") return await stream_cover(storage, album.cover_path) @router.post("/{track_id}/metadata/enrich") async def enrich_metadata( track_id: uuid.UUID, track_repo: TrackRepoDep, _: CurrentUser, ) -> dict[str, str]: """Re-run metadata enrichment for a track (admin/user-triggered). The work happens in a worker; this only enqueues it. 503 if the queue is down.""" track = await track_repo.get_by_id(track_id) if track is None: raise NotFoundError(f"Track {track_id} not found.") job_id = await enqueue("enrich_track", track_id=str(track_id)) return {"track_id": str(track_id), "job_id": job_id} @router.get("/{track_id}/metadata/matches") async def get_metadata_matches( track_id: uuid.UUID, track_repo: TrackRepoDep, metadata_service: MetadataServiceDep, _: CurrentUser, ) -> MetadataMatchesOut: """AcoustID candidates for the metadata editor's match picker (§A7). Runs the fingerprint lookup inline (single track, user-triggered) and never mutates the track. Degrades to an empty list if fpcalc/AcoustID are unavailable or no match is found. """ track = await track_repo.get_by_id(track_id) if track is None: raise NotFoundError(f"Track {track_id} not found.") matches = await metadata_service.find_matches(track_id) return MetadataMatchesOut( items=[ MetadataMatch( acoustid=m.acoustid, score=m.score, recording_mbid=m.recording_mbid, release_group_mbid=m.release_group_mbid, title=m.title, artist=m.artist, album=m.album, year=m.year, ) for m in matches ] ) @router.put("/{track_id}/metadata") async def set_metadata( track_id: uuid.UUID, body: MetadataApply, track_repo: TrackRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, _: CurrentUser, ) -> TrackOut: """Apply manual edits or an accepted AcoustID match (§A7). Sets ``metadata_status = manual`` — never overwritten by auto-enrichment.""" track = await track_repo.get_by_id(track_id) if track is None: raise NotFoundError(f"Track {track_id} not found.") artist_id: uuid.UUID | None = None if body.artist_name: artist = await artist_repo.get_or_create(body.artist_name) artist_id = artist.id album_id: uuid.UUID | None = None if body.album_title: album = await album_repo.get_or_create( title=body.album_title, artist_id=artist_id or track.artist_id, year=body.year, musicbrainz_id=None, ) album_id = album.id track = await track_repo.update( track_id, title=body.title, genre=body.genre, year=body.year, artist_id=artist_id, album_id=album_id, track_number=body.track_number, ) artist_ids = [track.artist_id] album_ids = [track.album_id] if track.album_id else [] 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)} items = await _build_track_out([track], artists, albums) return items[0]