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
@@ -1,10 +1,14 @@
"""Artist repository — adapter over ``AsyncSession``."""
from sqlalchemy import select
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.track import Artist
from app.infrastructure.db.models.album import AlbumModel
from app.infrastructure.db.models.artist import ArtistModel
from app.infrastructure.db.models.track import TrackModel
def _to_entity(row: ArtistModel) -> Artist:
@@ -30,3 +34,49 @@ class SqlAlchemyArtistRepository:
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None:
row = await self._session.get(ArtistModel, artist_id)
return _to_entity(row) if row is not None else None
async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]:
if not ids:
return []
rows = (
(await self._session.execute(select(ArtistModel).where(ArtistModel.id.in_(ids))))
.scalars()
.all()
)
return [_to_entity(r) for r in rows]
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]:
stmt = select(ArtistModel)
if q:
stmt = stmt.where(ArtistModel.name.ilike(f"%{q}%"))
stmt = stmt.order_by(ArtistModel.name).limit(limit).offset(offset)
rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows]
async def count(self, *, q: str | None) -> int:
stmt = select(func.count()).select_from(ArtistModel)
if q:
stmt = stmt.where(ArtistModel.name.ilike(f"%{q}%"))
return (await self._session.execute(stmt)).scalar_one()
async def album_count(self, artist_id: uuid.UUID) -> int:
return (
await self._session.execute(
select(func.count())
.select_from(AlbumModel)
.where(AlbumModel.artist_id == artist_id)
)
).scalar_one()
async def track_count(self, artist_id: uuid.UUID) -> int:
return (
await self._session.execute(
select(func.count())
.select_from(TrackModel)
.where(TrackModel.artist_id == artist_id)
)
).scalar_one()