"""Subsonic annotation endpoints: star/unstar, rating, scrobble. * ``star``/``unstar`` map to the **append-only** like event-log (a new event per call — never a mutated boolean; CLAUDE.md invariant). Album/artist stars are accepted but not persisted (no album/artist likes yet). * ``scrobble`` appends to play history. * ``setRating`` has no backing store yet — it's accepted as a clean no-op. """ import datetime as dt from typing import Annotated from fastapi import APIRouter, Query, Response from app.api.deps import HistoryRepoDep, LikeRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep from app.api.rest.envelope import subsonic_response from app.api.rest.ids import decode_track from app.domain.errors import NotFoundError router = APIRouter() @router.api_route("/star", methods=["GET", "POST"]) @router.api_route("/star.view", methods=["GET", "POST"]) async def star( user: SubsonicUser, fmt: SubsonicFormat, like_repo: LikeRepoDep, track_repo: TrackRepoDep, id: Annotated[list[str] | None, Query()] = None, albumId: Annotated[list[str] | None, Query()] = None, artistId: Annotated[list[str] | None, Query()] = None, ) -> Response: # albumId/artistId are accepted for client compatibility but not persisted. for raw in id or []: track_id = decode_track(raw) if await track_repo.get_by_id(track_id) is None: raise NotFoundError("Song not found.") await like_repo.add(user_id=user.id, track_id=track_id, value="like") return subsonic_response(fmt=fmt) @router.api_route("/unstar", methods=["GET", "POST"]) @router.api_route("/unstar.view", methods=["GET", "POST"]) async def unstar( user: SubsonicUser, fmt: SubsonicFormat, like_repo: LikeRepoDep, track_repo: TrackRepoDep, id: Annotated[list[str] | None, Query()] = None, albumId: Annotated[list[str] | None, Query()] = None, artistId: Annotated[list[str] | None, Query()] = None, ) -> Response: for raw in id or []: track_id = decode_track(raw) if await track_repo.get_by_id(track_id) is None: raise NotFoundError("Song not found.") await like_repo.add(user_id=user.id, track_id=track_id, value="neutral") return subsonic_response(fmt=fmt) @router.api_route("/setRating", methods=["GET", "POST"]) @router.api_route("/setRating.view", methods=["GET", "POST"]) async def set_rating( _user: SubsonicUser, fmt: SubsonicFormat, id: Annotated[str, Query()], rating: Annotated[int, Query(ge=0, le=5)], ) -> Response: # No rating store yet — accept cleanly so clients don't error. return subsonic_response(fmt=fmt) @router.api_route("/scrobble", methods=["GET", "POST"]) @router.api_route("/scrobble.view", methods=["GET", "POST"]) async def scrobble( user: SubsonicUser, fmt: SubsonicFormat, history_repo: HistoryRepoDep, track_repo: TrackRepoDep, id: Annotated[list[str] | None, Query()] = None, time: Annotated[list[int] | None, Query()] = None, submission: Annotated[bool, Query()] = True, ) -> Response: times = time or [] for index, raw in enumerate(id or []): track_id = decode_track(raw) if await track_repo.get_by_id(track_id) is None: raise NotFoundError("Song not found.") if index < len(times): played_at = dt.datetime.fromtimestamp(times[index] / 1000, tz=dt.UTC) else: played_at = dt.datetime.now(dt.UTC) await history_repo.add( user_id=user.id, track_id=track_id, played_at=played_at, play_duration_seconds=None, completed=submission, ) return subsonic_response(fmt=fmt)