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
@@ -2,10 +2,12 @@
import uuid
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.track import Track
from app.domain.errors import NotFoundError
from app.infrastructure.db.models.artist import ArtistModel
from app.infrastructure.db.models.track import TrackModel
@@ -14,12 +16,15 @@ def _to_entity(row: TrackModel) -> Track:
id=row.id,
title=row.title,
artist_id=row.artist_id,
album_id=row.album_id,
file_path=row.file_path,
file_format=row.file_format,
file_size=row.file_size,
source=row.source,
source_id=row.source_id,
duration_seconds=row.duration_seconds,
genre=row.genre,
year=row.year,
metadata_status=row.metadata_status,
created_at=row.created_at,
updated_at=row.updated_at,
@@ -81,3 +86,75 @@ class SqlAlchemyTrackRepository:
if row is not None:
await self._session.delete(row)
await self._session.flush()
async def list(
self,
*,
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
sort_by: str = "created_at",
order: str = "desc",
limit: int = 50,
offset: int = 0,
) -> list[Track]:
stmt = select(TrackModel)
if artist_id is not None:
stmt = stmt.where(TrackModel.artist_id == artist_id)
if album_id is not None:
stmt = stmt.where(TrackModel.album_id == album_id)
if q:
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
if sort_by == "artist":
stmt = stmt.join(ArtistModel, TrackModel.artist_id == ArtistModel.id)
col_artist = ArtistModel.name
stmt = stmt.order_by(col_artist.asc() if order == "asc" else col_artist.desc())
elif sort_by == "title":
col_title = TrackModel.title
stmt = stmt.order_by(col_title.asc() if order == "asc" else col_title.desc())
else:
stmt = stmt.order_by(
TrackModel.created_at.asc() if order == "asc" else TrackModel.created_at.desc()
)
stmt = stmt.limit(limit).offset(offset)
rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows]
async def count(
self,
*,
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
) -> int:
stmt = select(func.count()).select_from(TrackModel)
if artist_id is not None:
stmt = stmt.where(TrackModel.artist_id == artist_id)
if album_id is not None:
stmt = stmt.where(TrackModel.album_id == album_id)
if q:
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
return (await self._session.execute(stmt)).scalar_one()
async def update(
self,
track_id: uuid.UUID,
*,
title: str | None,
genre: str | None,
year: int | None,
) -> Track:
row = await self._session.get(TrackModel, track_id)
if row is None:
raise NotFoundError(f"Track {track_id} not found.")
if title is not None:
row.title = title
if genre is not None:
row.genre = genre
if year is not None:
row.year = year
row.metadata_status = "manual"
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)