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,6 +1,10 @@
"""SQLAlchemy repository adapters implementing the domain ports."""
from app.infrastructure.db.repositories.album_repository import SqlAlchemyAlbumRepository
from app.infrastructure.db.repositories.artist_repository import SqlAlchemyArtistRepository
from app.infrastructure.db.repositories.history_repository import SqlAlchemyHistoryRepository
from app.infrastructure.db.repositories.like_repository import SqlAlchemyLikeRepository
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
from app.infrastructure.db.repositories.refresh_token_repository import (
SqlAlchemyRefreshTokenRepository,
)
@@ -8,7 +12,11 @@ from app.infrastructure.db.repositories.track_repository import SqlAlchemyTrackR
from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRepository
__all__ = [
"SqlAlchemyAlbumRepository",
"SqlAlchemyArtistRepository",
"SqlAlchemyHistoryRepository",
"SqlAlchemyLikeRepository",
"SqlAlchemyPlaylistRepository",
"SqlAlchemyRefreshTokenRepository",
"SqlAlchemyTrackRepository",
"SqlAlchemyUserRepository",
@@ -0,0 +1,87 @@
"""Album repository — adapter over ``AsyncSession``."""
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.album import Album
from app.infrastructure.db.models.album import AlbumModel
from app.infrastructure.db.models.track import TrackModel
def _to_entity(row: AlbumModel) -> Album:
return Album(
id=row.id,
title=row.title,
artist_id=row.artist_id,
year=row.year,
cover_path=row.cover_path,
musicbrainz_id=row.musicbrainz_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
class SqlAlchemyAlbumRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, album_id: uuid.UUID) -> Album | None:
row = await self._session.get(AlbumModel, album_id)
return _to_entity(row) if row is not None else None
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]:
if not ids:
return []
rows = (
(await self._session.execute(select(AlbumModel).where(AlbumModel.id.in_(ids))))
.scalars()
.all()
)
return [_to_entity(r) for r in rows]
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int:
stmt = select(func.count()).select_from(AlbumModel)
if artist_id is not None:
stmt = stmt.where(AlbumModel.artist_id == artist_id)
if q:
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
return (await self._session.execute(stmt)).scalar_one()
async def track_count(self, album_id: uuid.UUID) -> int:
return (
await self._session.execute(
select(func.count()).select_from(TrackModel).where(TrackModel.album_id == album_id)
)
).scalar_one()
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not album_ids:
return {}
rows = (
await self._session.execute(
select(TrackModel.album_id, func.count(TrackModel.id).label("cnt"))
.where(TrackModel.album_id.in_(album_ids))
.group_by(TrackModel.album_id)
)
).all()
return {row.album_id: row.cnt for row in rows}
# list must come after methods using list[...] in signatures (builtin name shadowing)
async def list(
self,
*,
artist_id: uuid.UUID | None,
q: str | None,
limit: int,
offset: int,
) -> list[Album]:
stmt = select(AlbumModel)
if artist_id is not None:
stmt = stmt.where(AlbumModel.artist_id == artist_id)
if q:
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
stmt = stmt.order_by(AlbumModel.title).limit(limit).offset(offset)
rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows]
@@ -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()
@@ -0,0 +1,72 @@
"""Play history repository — adapter over ``AsyncSession``."""
import datetime as dt
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.history import PlayHistoryEntry
from app.infrastructure.db.models.play_history import PlayHistoryModel
def _to_entity(row: PlayHistoryModel) -> PlayHistoryEntry:
return PlayHistoryEntry(
id=row.id,
user_id=row.user_id,
track_id=row.track_id,
played_at=row.played_at,
play_duration_seconds=row.play_duration_seconds,
completed=row.completed,
)
class SqlAlchemyHistoryRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(
self,
*,
user_id: uuid.UUID,
track_id: uuid.UUID,
played_at: dt.datetime,
play_duration_seconds: int | None,
completed: bool,
) -> PlayHistoryEntry:
row = PlayHistoryModel(
user_id=user_id,
track_id=track_id,
played_at=played_at,
play_duration_seconds=play_duration_seconds,
completed=completed,
)
self._session.add(row)
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def list(self, *, user_id: uuid.UUID, limit: int, offset: int) -> list[PlayHistoryEntry]:
rows = (
(
await self._session.execute(
select(PlayHistoryModel)
.where(PlayHistoryModel.user_id == user_id)
.order_by(PlayHistoryModel.played_at.desc())
.limit(limit)
.offset(offset)
)
)
.scalars()
.all()
)
return [_to_entity(r) for r in rows]
async def count(self, *, user_id: uuid.UUID) -> int:
return (
await self._session.execute(
select(func.count())
.select_from(PlayHistoryModel)
.where(PlayHistoryModel.user_id == user_id)
)
).scalar_one()
@@ -0,0 +1,150 @@
"""Like repository — adapter over ``AsyncSession``.
Likes are an append-only event log. Current state = latest event per (user, track).
"""
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.like import Like
from app.domain.entities.track import Track
from app.infrastructure.db.models.like import LikeModel
from app.infrastructure.db.models.track import TrackModel
def _to_entity(row: LikeModel) -> Like:
return Like(
id=row.id,
user_id=row.user_id,
track_id=row.track_id,
value=row.value,
created_at=row.created_at,
)
def _track_to_entity(row: TrackModel) -> Track:
return 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,
)
class SqlAlchemyLikeRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(self, *, user_id: uuid.UUID, track_id: uuid.UUID, value: str) -> Like:
row = LikeModel(user_id=user_id, track_id=track_id, value=value)
self._session.add(row)
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def get_latest_state(
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
) -> list[Like]:
if not track_ids:
return []
# Subquery: max(created_at) per track for this user
max_sq = (
select(
LikeModel.track_id,
func.max(LikeModel.created_at).label("latest"),
)
.where(LikeModel.user_id == user_id, LikeModel.track_id.in_(track_ids))
.group_by(LikeModel.track_id)
.subquery()
)
rows = (
(
await self._session.execute(
select(LikeModel)
.join(
max_sq,
(LikeModel.track_id == max_sq.c.track_id)
& (LikeModel.created_at == max_sq.c.latest),
)
.where(LikeModel.user_id == user_id)
)
)
.scalars()
.all()
)
return [_to_entity(r) for r in rows]
async def list_liked_tracks(
self, *, user_id: uuid.UUID, limit: int, offset: int
) -> list[Track]:
# Tracks where the latest like event has value='like', ordered by like time desc
max_sq = (
select(
LikeModel.track_id,
func.max(LikeModel.created_at).label("latest"),
)
.where(LikeModel.user_id == user_id)
.group_by(LikeModel.track_id)
.subquery()
)
liked_sq = (
select(LikeModel.track_id, LikeModel.created_at)
.join(
max_sq,
(LikeModel.track_id == max_sq.c.track_id)
& (LikeModel.created_at == max_sq.c.latest),
)
.where(LikeModel.user_id == user_id, LikeModel.value == "like")
.subquery()
)
rows = (
(
await self._session.execute(
select(TrackModel)
.join(liked_sq, TrackModel.id == liked_sq.c.track_id)
.order_by(liked_sq.c.created_at.desc())
.limit(limit)
.offset(offset)
)
)
.scalars()
.all()
)
return [_track_to_entity(r) for r in rows]
async def count_liked_tracks(self, *, user_id: uuid.UUID) -> int:
max_sq = (
select(
LikeModel.track_id,
func.max(LikeModel.created_at).label("latest"),
)
.where(LikeModel.user_id == user_id)
.group_by(LikeModel.track_id)
.subquery()
)
liked_sq = (
select(LikeModel.track_id)
.join(
max_sq,
(LikeModel.track_id == max_sq.c.track_id)
& (LikeModel.created_at == max_sq.c.latest),
)
.where(LikeModel.user_id == user_id, LikeModel.value == "like")
.subquery()
)
return (
await self._session.execute(select(func.count()).select_from(liked_sq))
).scalar_one()
@@ -0,0 +1,188 @@
"""Playlist repository — adapter over ``AsyncSession``."""
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.playlist import Playlist
from app.domain.entities.track import Track
from app.infrastructure.db.models.playlist import PlaylistModel, PlaylistTrackModel
from app.infrastructure.db.models.track import TrackModel
def _to_entity(row: PlaylistModel) -> Playlist:
return Playlist(
id=row.id,
name=row.name,
description=row.description,
owner_id=row.owner_id,
version=row.version,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _track_to_entity(row: TrackModel) -> Track:
return 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,
)
class SqlAlchemyPlaylistRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, playlist_id: uuid.UUID) -> Playlist | None:
row = await self._session.get(PlaylistModel, playlist_id)
return _to_entity(row) if row is not None else None
async def count(self, *, owner_id: uuid.UUID) -> int:
return (
await self._session.execute(
select(func.count())
.select_from(PlaylistModel)
.where(PlaylistModel.owner_id == owner_id)
)
).scalar_one()
async def add(self, *, name: str, description: str | None, owner_id: uuid.UUID) -> Playlist:
row = PlaylistModel(name=name, description=description, owner_id=owner_id)
self._session.add(row)
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def update(
self, playlist_id: uuid.UUID, *, name: str | None, description: str | None
) -> Playlist:
row = await self._session.get(PlaylistModel, playlist_id)
if row is None:
from app.domain.errors import NotFoundError
raise NotFoundError(f"Playlist {playlist_id} not found.")
if name is not None:
row.name = name
if description is not None:
row.description = description
row.version = row.version + 1
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def delete(self, playlist_id: uuid.UUID) -> None:
row = await self._session.get(PlaylistModel, playlist_id)
if row is not None:
await self._session.delete(row)
await self._session.flush()
async def track_count(self, playlist_id: uuid.UUID) -> int:
return (
await self._session.execute(
select(func.count())
.select_from(PlaylistTrackModel)
.where(PlaylistTrackModel.playlist_id == playlist_id)
)
).scalar_one()
async def track_count_many(self, playlist_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not playlist_ids:
return {}
rows = (
await self._session.execute(
select(
PlaylistTrackModel.playlist_id,
func.count(PlaylistTrackModel.id).label("cnt"),
)
.where(PlaylistTrackModel.playlist_id.in_(playlist_ids))
.group_by(PlaylistTrackModel.playlist_id)
)
).all()
return {row.playlist_id: row.cnt for row in rows}
async def get_tracks(self, playlist_id: uuid.UUID, *, limit: int, offset: int) -> list[Track]:
rows = (
(
await self._session.execute(
select(TrackModel)
.join(PlaylistTrackModel, TrackModel.id == PlaylistTrackModel.track_id)
.where(PlaylistTrackModel.playlist_id == playlist_id)
.order_by(PlaylistTrackModel.position)
.limit(limit)
.offset(offset)
)
)
.scalars()
.all()
)
return [_track_to_entity(r) for r in rows]
async def get_track_total(self, playlist_id: uuid.UUID) -> int:
return await self.track_count(playlist_id)
async def add_track(
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
) -> None:
row = PlaylistTrackModel(playlist_id=playlist_id, track_id=track_id, position=position)
self._session.add(row)
playlist = await self._session.get(PlaylistModel, playlist_id)
if playlist is not None:
playlist.version = playlist.version + 1
await self._session.flush()
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None:
row = (
await self._session.execute(
select(PlaylistTrackModel).where(
PlaylistTrackModel.playlist_id == playlist_id,
PlaylistTrackModel.track_id == track_id,
)
)
).scalar_one_or_none()
if row is not None:
await self._session.delete(row)
playlist = await self._session.get(PlaylistModel, playlist_id)
if playlist is not None:
playlist.version = playlist.version + 1
await self._session.flush()
async def max_position(self, playlist_id: uuid.UUID) -> float:
result = (
await self._session.execute(
select(func.max(PlaylistTrackModel.position)).where(
PlaylistTrackModel.playlist_id == playlist_id
)
)
).scalar_one_or_none()
return float(result) if result is not None else 0.0
# list must come after methods using list[...] in signatures (builtin name shadowing)
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]:
rows = (
(
await self._session.execute(
select(PlaylistModel)
.where(PlaylistModel.owner_id == owner_id)
.order_by(PlaylistModel.updated_at.desc())
.limit(limit)
.offset(offset)
)
)
.scalars()
.all()
)
return [_to_entity(r) for r in rows]
@@ -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)