feat: implement 1B domain entities/repos + 1H library API routes

1B — domain layer:
- New entities: Album, Playlist, Like, PlayHistoryEntry
- Track entity extended with album_id, genre, year fields
- New protocols: AlbumRepository, PlaylistRepository, LikeRepository, HistoryRepository
- ArtistRepository / TrackRepository protocols extended (list, count, update, get_many, etc.)
- New repos: SqlAlchemyAlbum/Playlist/Like/HistoryRepository
- Artist and track repos updated to match extended protocols

1H — library API:
- Pagination: PagedResponse[T] generic, offset-based, limit default 50 max 200
- Schemas: TrackOut, AlbumOut, ArtistOut, PlaylistOut/Create/Update,
  LikeEvent/State, HistoryIn/Out, LibrarySearchResponse
- GET/PATCH/DELETE /tracks with filters, sort, pagination
- GET /albums, /albums/{id}, /albums/{id}/tracks
- GET /artists, /artists/{id}, /artists/{id}/albums, /artists/{id}/tracks
- GET /search/library (ILIKE across tracks/albums/artists)
- Full /playlists CRUD + track add/remove (append-only version bump)
- POST /likes (append-only event log), GET /likes, GET /likes/state
- POST /history (scrobble), GET /history
- deps.py: TrackRepoDep, ArtistRepoDep, AlbumRepoDep, PlaylistRepoDep,
  LikeRepoDep, HistoryRepoDep

ruff   mypy   pytest 45/45 

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-07 16:43:51 +03:00
parent 81ea93c371
commit 7c920f38f6
30 changed files with 1641 additions and 59 deletions
+44 -7
View File
@@ -1,15 +1,52 @@
"""Playback history endpoints."""
from typing import Any
from fastapi import APIRouter, Query, Response
from fastapi import APIRouter
from app.api.deps import CurrentUser, HistoryRepoDep, TrackRepoDep
from app.api.schemas.history import HistoryIn, HistoryOut
from app.api.schemas.pagination import PagedResponse
from app.domain.errors import NotFoundError
router = APIRouter(prefix="/history", tags=["history"])
@router.post("", status_code=204)
async def record_history(
body: HistoryIn,
history_repo: HistoryRepoDep,
track_repo: TrackRepoDep,
user: CurrentUser,
) -> Response:
track = await track_repo.get_by_id(body.track_id)
if track is None:
raise NotFoundError(f"Track {body.track_id} not found.")
await history_repo.add(
user_id=user.id,
track_id=body.track_id,
played_at=body.played_at,
play_duration_seconds=body.play_duration_seconds,
completed=body.completed,
)
return Response(status_code=204)
@router.get("")
async def get_history() -> Any: ...
@router.post("")
async def record_history() -> Any: ...
async def get_history(
history_repo: HistoryRepoDep,
user: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[HistoryOut]:
entries = await history_repo.list(user_id=user.id, limit=limit, offset=offset)
total = await history_repo.count(user_id=user.id)
items = [
HistoryOut(
id=e.id,
track_id=e.track_id,
played_at=e.played_at,
play_duration_seconds=e.play_duration_seconds,
completed=e.completed,
)
for e in entries
]
return PagedResponse(items=items, total=total, limit=limit, offset=offset)