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
+37
View File
@@ -25,7 +25,11 @@ from app.domain.errors import AuthenticationError, PermissionDeniedError
from app.domain.ports import FileStorage, PasswordHasher, TokenService
from app.infrastructure.db import get_sessionmaker
from app.infrastructure.db.repositories import (
SqlAlchemyAlbumRepository,
SqlAlchemyArtistRepository,
SqlAlchemyHistoryRepository,
SqlAlchemyLikeRepository,
SqlAlchemyPlaylistRepository,
SqlAlchemyRefreshTokenRepository,
SqlAlchemyTrackRepository,
SqlAlchemyUserRepository,
@@ -107,6 +111,39 @@ UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)]
StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)]
# -- library repository deps ---------------------------------------------------
def get_track_repository(session: SessionDep) -> SqlAlchemyTrackRepository:
return SqlAlchemyTrackRepository(session)
def get_artist_repository(session: SessionDep) -> SqlAlchemyArtistRepository:
return SqlAlchemyArtistRepository(session)
def get_album_repository(session: SessionDep) -> SqlAlchemyAlbumRepository:
return SqlAlchemyAlbumRepository(session)
def get_playlist_repository(session: SessionDep) -> SqlAlchemyPlaylistRepository:
return SqlAlchemyPlaylistRepository(session)
def get_like_repository(session: SessionDep) -> SqlAlchemyLikeRepository:
return SqlAlchemyLikeRepository(session)
def get_history_repository(session: SessionDep) -> SqlAlchemyHistoryRepository:
return SqlAlchemyHistoryRepository(session)
TrackRepoDep = Annotated[SqlAlchemyTrackRepository, Depends(get_track_repository)]
ArtistRepoDep = Annotated[SqlAlchemyArtistRepository, Depends(get_artist_repository)]
AlbumRepoDep = Annotated[SqlAlchemyAlbumRepository, Depends(get_album_repository)]
PlaylistRepoDep = Annotated[SqlAlchemyPlaylistRepository, Depends(get_playlist_repository)]
LikeRepoDep = Annotated[SqlAlchemyLikeRepository, Depends(get_like_repository)]
HistoryRepoDep = Annotated[SqlAlchemyHistoryRepository, Depends(get_history_repository)]
# -- current user / authorization ----------------------------------------------
# auto_error=False: we raise domain AuthenticationError (mapped to 401) so the
# error envelope stays consistent with the rest of the API.
+16
View File
@@ -0,0 +1,16 @@
"""Album request/response schemas."""
import datetime as dt
import uuid
from pydantic import BaseModel
class AlbumOut(BaseModel):
id: uuid.UUID
title: str
artist_id: uuid.UUID
artist_name: str
year: int | None
track_count: int
created_at: dt.datetime
+14
View File
@@ -0,0 +1,14 @@
"""Artist request/response schemas."""
import datetime as dt
import uuid
from pydantic import BaseModel
class ArtistOut(BaseModel):
id: uuid.UUID
name: str
album_count: int
track_count: int
created_at: dt.datetime
+21
View File
@@ -0,0 +1,21 @@
"""Play history request/response schemas."""
import datetime as dt
import uuid
from pydantic import BaseModel
class HistoryIn(BaseModel):
track_id: uuid.UUID
played_at: dt.datetime
play_duration_seconds: int | None = None
completed: bool = False
class HistoryOut(BaseModel):
id: uuid.UUID
track_id: uuid.UUID
played_at: dt.datetime
play_duration_seconds: int | None
completed: bool
+18
View File
@@ -0,0 +1,18 @@
"""Like request/response schemas."""
import datetime as dt
import uuid
from typing import Literal
from pydantic import BaseModel
class LikeEvent(BaseModel):
track_id: uuid.UUID
value: Literal["like", "dislike", "neutral"]
class LikeState(BaseModel):
track_id: uuid.UUID
value: str
updated_at: dt.datetime
+10
View File
@@ -0,0 +1,10 @@
"""Shared pagination envelope for all paged list responses."""
from pydantic import BaseModel
class PagedResponse[T](BaseModel):
items: list[T]
total: int
limit: int
offset: int
+31
View File
@@ -0,0 +1,31 @@
"""Playlist request/response schemas."""
import datetime as dt
import uuid
from pydantic import BaseModel
class PlaylistOut(BaseModel):
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
version: int
track_count: int
created_at: dt.datetime
class PlaylistCreate(BaseModel):
name: str
description: str | None = None
class PlaylistUpdate(BaseModel):
name: str | None = None
description: str | None = None
class PlaylistAddTrack(BaseModel):
track_id: uuid.UUID
position: float | None = None
+13
View File
@@ -0,0 +1,13 @@
"""Search response schemas."""
from pydantic import BaseModel
from app.api.schemas.album import AlbumOut
from app.api.schemas.artist import ArtistOut
from app.api.schemas.track import TrackOut
class LibrarySearchResponse(BaseModel):
tracks: list[TrackOut]
albums: list[AlbumOut]
artists: list[ArtistOut]
+27
View File
@@ -0,0 +1,27 @@
"""Track request/response schemas."""
import datetime as dt
import uuid
from pydantic import BaseModel
class TrackOut(BaseModel):
id: uuid.UUID
title: str
artist_id: uuid.UUID
artist_name: str
album_id: uuid.UUID | None
album_title: str | None
duration_seconds: int | None
file_format: str
file_size: int
metadata_status: str
source: str
created_at: dt.datetime
class TrackUpdate(BaseModel):
title: str | None = None
genre: str | None = None
year: int | None = None
+93 -5
View File
@@ -3,22 +3,110 @@
import uuid
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Query
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep
from app.api.schemas.album import AlbumOut
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut
from app.api.v1.tracks import _build_track_out
from app.domain.entities.album import Album
from app.domain.entities.track import Artist
from app.domain.errors import NotFoundError
router = APIRouter(prefix="/albums", tags=["albums"])
async def _build_album_out(
albums: list[Album],
artists: dict[uuid.UUID, Artist],
track_counts: dict[uuid.UUID, int],
) -> list[AlbumOut]:
return [
AlbumOut(
id=a.id,
title=a.title,
artist_id=a.artist_id,
artist_name=artists[a.artist_id].name if a.artist_id in artists else "Unknown Artist",
year=a.year,
track_count=track_counts.get(a.id, 0),
created_at=a.created_at,
)
for a in albums
]
@router.get("")
async def list_albums() -> Any: ...
async def list_albums(
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
_: CurrentUser,
artist_id: uuid.UUID | None = None,
q: str | None = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[AlbumOut]:
albums = await album_repo.list(artist_id=artist_id, q=q, limit=limit, offset=offset)
total = await album_repo.count(artist_id=artist_id, q=q)
artist_ids = list({a.artist_id for a in albums})
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
track_counts = await album_repo.track_count_many([a.id for a in albums])
items = await _build_album_out(albums, artists, track_counts)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{album_id}")
async def get_album(album_id: uuid.UUID) -> Any: ...
async def get_album(
album_id: uuid.UUID,
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
_: CurrentUser,
) -> AlbumOut:
album = await album_repo.get_by_id(album_id)
if album is None:
raise NotFoundError(f"Album {album_id} not found.")
artists = {a.id: a for a in await artist_repo.get_many([album.artist_id])}
track_counts = await album_repo.track_count_many([album.id])
items = await _build_album_out([album], artists, track_counts)
return items[0]
@router.get("/{album_id}/tracks")
async def get_album_tracks(album_id: uuid.UUID) -> Any: ...
async def get_album_tracks(
album_id: uuid.UUID,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[TrackOut]:
album = await album_repo.get_by_id(album_id)
if album is None:
raise NotFoundError(f"Album {album_id} not found.")
tracks = await track_repo.list(
artist_id=None,
album_id=album_id,
q=None,
sort_by="title",
order="asc",
limit=limit,
offset=offset,
)
total = await track_repo.count(artist_id=None, album_id=album_id, q=None)
artist_ids = list({t.artist_id for t in tracks})
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums = {album.id: album}
items = await _build_track_out(tracks, artists, albums)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{album_id}/cover")
async def get_album_cover(album_id: uuid.UUID) -> Any: ...
async def get_album_cover(album_id: uuid.UUID, _: CurrentUser) -> Any: ...
+105 -6
View File
@@ -3,26 +3,125 @@
import uuid
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Query
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep
from app.api.schemas.album import AlbumOut
from app.api.schemas.artist import ArtistOut
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut
from app.api.v1.albums import _build_album_out
from app.api.v1.tracks import _build_track_out
from app.domain.errors import NotFoundError
router = APIRouter(prefix="/artists", tags=["artists"])
@router.get("")
async def list_artists() -> Any: ...
async def list_artists(
artist_repo: ArtistRepoDep,
_: CurrentUser,
q: str | None = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[ArtistOut]:
artists = await artist_repo.list(q=q, limit=limit, offset=offset)
total = await artist_repo.count(q=q)
items = []
for a in artists:
album_cnt = await artist_repo.album_count(a.id)
track_cnt = await artist_repo.track_count(a.id)
items.append(
ArtistOut(
id=a.id,
name=a.name,
album_count=album_cnt,
track_count=track_cnt,
created_at=a.created_at,
)
)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{artist_id}")
async def get_artist(artist_id: uuid.UUID) -> Any: ...
async def get_artist(
artist_id: uuid.UUID,
artist_repo: ArtistRepoDep,
_: CurrentUser,
) -> ArtistOut:
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError(f"Artist {artist_id} not found.")
album_cnt = await artist_repo.album_count(artist_id)
track_cnt = await artist_repo.track_count(artist_id)
return ArtistOut(
id=artist.id,
name=artist.name,
album_count=album_cnt,
track_count=track_cnt,
created_at=artist.created_at,
)
@router.get("/{artist_id}/albums")
async def get_artist_albums(artist_id: uuid.UUID) -> Any: ...
async def get_artist_albums(
artist_id: uuid.UUID,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[AlbumOut]:
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError(f"Artist {artist_id} not found.")
albums = await album_repo.list(artist_id=artist_id, q=None, limit=limit, offset=offset)
total = await album_repo.count(artist_id=artist_id, q=None)
artists_map = {artist.id: artist}
track_counts = await album_repo.track_count_many([a.id for a in albums])
items = await _build_album_out(albums, artists_map, track_counts)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{artist_id}/tracks")
async def get_artist_tracks(artist_id: uuid.UUID) -> Any: ...
async def get_artist_tracks(
artist_id: uuid.UUID,
artist_repo: ArtistRepoDep,
track_repo: TrackRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[TrackOut]:
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError(f"Artist {artist_id} not found.")
tracks = await track_repo.list(
artist_id=artist_id,
album_id=None,
q=None,
sort_by="title",
order="asc",
limit=limit,
offset=offset,
)
total = await track_repo.count(artist_id=artist_id, album_id=None, q=None)
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
artists_map = {artist.id: artist}
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out(tracks, artists_map, albums_map)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{artist_id}/similar")
async def get_similar_artists(artist_id: uuid.UUID) -> Any: ...
async def get_similar_artists(artist_id: uuid.UUID, _: CurrentUser) -> Any: ...
+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)
+50 -6
View File
@@ -1,19 +1,63 @@
"""Like endpoints. Likes are an append-only event-log — never updated in place."""
from typing import Any
import uuid
from fastapi import APIRouter
from fastapi import APIRouter, Query
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, LikeRepoDep
from app.api.schemas.like import LikeEvent, LikeState
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut
from app.api.v1.tracks import _build_track_out
router = APIRouter(prefix="/likes", tags=["likes"])
@router.post("", status_code=201)
async def add_like(
body: LikeEvent,
like_repo: LikeRepoDep,
user: CurrentUser,
) -> LikeState:
like = await like_repo.add(user_id=user.id, track_id=body.track_id, value=body.value)
return LikeState(track_id=like.track_id, value=like.value, updated_at=like.created_at)
@router.get("")
async def get_likes() -> Any: ...
async def get_likes(
like_repo: LikeRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
user: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[TrackOut]:
tracks = await like_repo.list_liked_tracks(user_id=user.id, limit=limit, offset=offset)
total = await like_repo.count_liked_tracks(user_id=user.id)
artist_ids = list({t.artist_id for t in tracks})
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
@router.post("")
async def add_like() -> Any: ...
items = await _build_track_out(tracks, artists_map, albums_map)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/state")
async def get_likes_state() -> Any: ...
async def get_likes_state(
like_repo: LikeRepoDep,
user: CurrentUser,
track_ids: str = Query(default=""),
) -> list[LikeState]:
ids: list[uuid.UUID] = []
if track_ids:
try:
ids = [uuid.UUID(tid.strip()) for tid in track_ids.split(",") if tid.strip()]
except ValueError:
return []
likes = await like_repo.get_latest_state(user_id=user.id, track_ids=ids)
return [
LikeState(track_id=lk.track_id, value=lk.value, updated_at=lk.created_at) for lk in likes
]
+156 -15
View File
@@ -3,46 +3,187 @@
import uuid
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Query, Response
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
CurrentUser,
PlaylistRepoDep,
TrackRepoDep,
)
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.playlist import PlaylistAddTrack, PlaylistCreate, PlaylistOut, PlaylistUpdate
from app.api.schemas.track import TrackOut
from app.api.v1.tracks import _build_track_out
from app.domain.entities.playlist import Playlist
from app.domain.errors import NotFoundError, PermissionDeniedError
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
router = APIRouter(prefix="/playlists", tags=["playlists"])
async def _build_playlist_out(
playlists: list[Playlist], playlist_repo: SqlAlchemyPlaylistRepository
) -> list[PlaylistOut]:
ids = [p.id for p in playlists]
counts = await playlist_repo.track_count_many(ids)
return [
PlaylistOut(
id=p.id,
name=p.name,
description=p.description,
owner_id=p.owner_id,
version=p.version,
track_count=counts.get(p.id, 0),
created_at=p.created_at,
)
for p in playlists
]
@router.get("")
async def list_playlists() -> Any: ...
async def list_playlists(
playlist_repo: PlaylistRepoDep,
user: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[PlaylistOut]:
playlists = await playlist_repo.list(owner_id=user.id, limit=limit, offset=offset)
total = await playlist_repo.count(owner_id=user.id)
items = await _build_playlist_out(playlists, playlist_repo)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.post("")
async def create_playlist() -> Any: ...
@router.post("", status_code=201)
async def create_playlist(
body: PlaylistCreate,
playlist_repo: PlaylistRepoDep,
user: CurrentUser,
) -> PlaylistOut:
playlist = await playlist_repo.add(
name=body.name, description=body.description, owner_id=user.id
)
items = await _build_playlist_out([playlist], playlist_repo)
return items[0]
@router.get("/{playlist_id}")
async def get_playlist(playlist_id: uuid.UUID) -> Any: ...
async def get_playlist(
playlist_id: uuid.UUID,
playlist_repo: PlaylistRepoDep,
_: CurrentUser,
) -> PlaylistOut:
playlist = await playlist_repo.get_by_id(playlist_id)
if playlist is None:
raise NotFoundError(f"Playlist {playlist_id} not found.")
items = await _build_playlist_out([playlist], playlist_repo)
return items[0]
@router.patch("/{playlist_id}")
async def update_playlist(playlist_id: uuid.UUID) -> Any: ...
async def update_playlist(
playlist_id: uuid.UUID,
body: PlaylistUpdate,
playlist_repo: PlaylistRepoDep,
user: CurrentUser,
) -> PlaylistOut:
playlist = await playlist_repo.get_by_id(playlist_id)
if playlist is None:
raise NotFoundError(f"Playlist {playlist_id} not found.")
if playlist.owner_id != user.id:
raise PermissionDeniedError("You don't own this playlist.")
updated = await playlist_repo.update(playlist_id, name=body.name, description=body.description)
items = await _build_playlist_out([updated], playlist_repo)
return items[0]
@router.delete("/{playlist_id}")
async def delete_playlist(playlist_id: uuid.UUID) -> Any: ...
@router.delete("/{playlist_id}", status_code=204)
async def delete_playlist(
playlist_id: uuid.UUID,
playlist_repo: PlaylistRepoDep,
user: CurrentUser,
) -> Response:
playlist = await playlist_repo.get_by_id(playlist_id)
if playlist is None:
raise NotFoundError(f"Playlist {playlist_id} not found.")
if playlist.owner_id != user.id:
raise PermissionDeniedError("You don't own this playlist.")
await playlist_repo.delete(playlist_id)
return Response(status_code=204)
@router.get("/{playlist_id}/tracks")
async def get_playlist_tracks(playlist_id: uuid.UUID) -> Any: ...
async def get_playlist_tracks(
playlist_id: uuid.UUID,
playlist_repo: PlaylistRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[TrackOut]:
playlist = await playlist_repo.get_by_id(playlist_id)
if playlist is None:
raise NotFoundError(f"Playlist {playlist_id} not found.")
tracks = await playlist_repo.get_tracks(playlist_id, limit=limit, offset=offset)
total = await playlist_repo.get_track_total(playlist_id)
artist_ids = list({t.artist_id for t in tracks})
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out(tracks, artists_map, albums_map)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.post("/{playlist_id}/tracks")
async def add_playlist_tracks(playlist_id: uuid.UUID) -> Any: ...
@router.post("/{playlist_id}/tracks", status_code=204)
async def add_playlist_track(
playlist_id: uuid.UUID,
body: PlaylistAddTrack,
playlist_repo: PlaylistRepoDep,
track_repo: TrackRepoDep,
user: CurrentUser,
) -> Response:
playlist = await playlist_repo.get_by_id(playlist_id)
if playlist is None:
raise NotFoundError(f"Playlist {playlist_id} not found.")
if playlist.owner_id != user.id:
raise PermissionDeniedError("You don't own this playlist.")
track = await track_repo.get_by_id(body.track_id)
if track is None:
raise NotFoundError(f"Track {body.track_id} not found.")
position = body.position
if position is None:
position = await playlist_repo.max_position(playlist_id) + 1.0
await playlist_repo.add_track(playlist_id, body.track_id, position=position)
return Response(status_code=204)
@router.delete("/{playlist_id}/tracks/{track_id}")
async def remove_playlist_track(playlist_id: uuid.UUID, track_id: uuid.UUID) -> Any: ...
@router.delete("/{playlist_id}/tracks/{track_id}", status_code=204)
async def remove_playlist_track(
playlist_id: uuid.UUID,
track_id: uuid.UUID,
playlist_repo: PlaylistRepoDep,
user: CurrentUser,
) -> Response:
playlist = await playlist_repo.get_by_id(playlist_id)
if playlist is None:
raise NotFoundError(f"Playlist {playlist_id} not found.")
if playlist.owner_id != user.id:
raise PermissionDeniedError("You don't own this playlist.")
await playlist_repo.remove_track(playlist_id, track_id)
return Response(status_code=204)
@router.put("/{playlist_id}/tracks/reorder")
async def reorder_playlist_tracks(playlist_id: uuid.UUID) -> Any: ...
async def reorder_playlist_tracks(playlist_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.get("/{playlist_id}/cover")
async def get_playlist_cover(playlist_id: uuid.UUID) -> Any: ...
async def get_playlist_cover(playlist_id: uuid.UUID, _: CurrentUser) -> Any: ...
+66 -3
View File
@@ -2,14 +2,77 @@
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Query
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep
from app.api.schemas.album import AlbumOut
from app.api.schemas.artist import ArtistOut
from app.api.schemas.search import LibrarySearchResponse
from app.api.schemas.track import TrackOut
from app.api.v1.albums import _build_album_out
from app.api.v1.tracks import _build_track_out
router = APIRouter(prefix="/search", tags=["search"])
@router.get("")
async def search() -> Any: ...
async def search(_: CurrentUser) -> Any: ...
@router.get("/library")
async def search_library() -> Any: ...
async def search_library(
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
q: str = Query(min_length=1),
types: str = Query(default="tracks,albums,artists"),
limit: int = Query(20, ge=1, le=100),
) -> LibrarySearchResponse:
requested = {t.strip() for t in types.split(",")}
tracks_out: list[TrackOut] = []
albums_out: list[AlbumOut] = []
artists_out: list[ArtistOut] = []
if "tracks" in requested:
tracks = await track_repo.list(
artist_id=None,
album_id=None,
q=q,
sort_by="title",
order="asc",
limit=limit,
offset=0,
)
if tracks:
artist_ids = list({t.artist_id for t in tracks})
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
tracks_out = await _build_track_out(tracks, artists_map, albums_map)
if "albums" in requested:
albums = await album_repo.list(artist_id=None, q=q, limit=limit, offset=0)
if albums:
artist_ids = list({a.artist_id for a in albums})
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
track_counts = await album_repo.track_count_many([a.id for a in albums])
albums_out = await _build_album_out(albums, artists_map, track_counts)
if "artists" in requested:
raw_artists = await artist_repo.list(q=q, limit=limit, offset=0)
for a in raw_artists:
album_cnt = await artist_repo.album_count(a.id)
track_cnt = await artist_repo.track_count(a.id)
artists_out.append(
ArtistOut(
id=a.id,
name=a.name,
album_count=album_cnt,
track_count=track_cnt,
created_at=a.created_at,
)
)
return LibrarySearchResponse(tracks=tracks_out, albums=albums_out, artists=artists_out)
+123 -13
View File
@@ -1,48 +1,158 @@
"""Track endpoints (library CRUD, similarity, optimization, cover, metadata, streaming)."""
"""Track endpoints."""
import uuid
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Query, Response
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, FileStorageDep, TrackRepoDep
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut, TrackUpdate
from app.domain.entities.album import Album
from app.domain.entities.track import Artist, Track
from app.domain.errors import NotFoundError
router = APIRouter(prefix="/tracks", tags=["tracks"])
async def _build_track_out(
tracks: list[Track],
artists: dict[uuid.UUID, Artist],
albums: dict[uuid.UUID, Album],
) -> list[TrackOut]:
return [
TrackOut(
id=t.id,
title=t.title,
artist_id=t.artist_id,
artist_name=artists[t.artist_id].name if t.artist_id in artists else "Unknown Artist",
album_id=t.album_id,
album_title=albums[t.album_id].title if t.album_id and t.album_id in albums else None,
duration_seconds=t.duration_seconds,
file_format=t.file_format,
file_size=t.file_size,
metadata_status=t.metadata_status,
source=t.source,
created_at=t.created_at,
)
for t in tracks
]
@router.get("")
async def list_tracks() -> Any: ...
async def list_tracks(
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
artist_id: uuid.UUID | None = None,
album_id: uuid.UUID | None = None,
q: str | None = None,
sort_by: str = Query("created_at", pattern="^(title|created_at|artist)$"),
order: str = Query("desc", pattern="^(asc|desc)$"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[TrackOut]:
tracks = await track_repo.list(
artist_id=artist_id,
album_id=album_id,
q=q,
sort_by=sort_by,
order=order,
limit=limit,
offset=offset,
)
total = await track_repo.count(artist_id=artist_id, album_id=album_id, q=q)
artist_ids = list({t.artist_id for t in tracks})
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out(tracks, artists, albums)
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/{track_id}")
async def get_track(track_id: uuid.UUID) -> Any: ...
async def get_track(
track_id: uuid.UUID,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
) -> TrackOut:
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
artist_ids = [track.artist_id]
album_ids = [track.album_id] if track.album_id else []
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out([track], artists, albums)
return items[0]
@router.patch("/{track_id}")
async def update_track(track_id: uuid.UUID) -> Any: ...
async def update_track(
track_id: uuid.UUID,
body: TrackUpdate,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
) -> TrackOut:
track = await track_repo.update(
track_id,
title=body.title,
genre=body.genre,
year=body.year,
)
artist_ids = [track.artist_id]
album_ids = [track.album_id] if track.album_id else []
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out([track], artists, albums)
return items[0]
@router.delete("/{track_id}")
async def delete_track(track_id: uuid.UUID) -> Any: ...
@router.delete("/{track_id}", status_code=204)
async def delete_track(
track_id: uuid.UUID,
track_repo: TrackRepoDep,
storage: FileStorageDep,
_: CurrentUser,
) -> Response:
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
await track_repo.delete(track_id)
await storage.delete(track.file_path)
return Response(status_code=204)
@router.get("/{track_id}/similar")
async def get_similar_tracks(track_id: uuid.UUID) -> Any: ...
async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.post("/{track_id}/optimize")
async def optimize_track(track_id: uuid.UUID) -> Any: ...
async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.get("/{track_id}/cover")
async def get_track_cover(track_id: uuid.UUID) -> Any: ...
async def get_track_cover(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.post("/{track_id}/metadata/enrich")
async def enrich_metadata(track_id: uuid.UUID) -> Any: ...
async def enrich_metadata(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.get("/{track_id}/metadata/matches")
async def get_metadata_matches(track_id: uuid.UUID) -> Any: ...
async def get_metadata_matches(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.put("/{track_id}/metadata")
async def set_metadata(track_id: uuid.UUID) -> Any: ...
async def set_metadata(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
+15 -1
View File
@@ -1,7 +1,21 @@
"""Domain entities and value objects — pure, framework-free."""
from app.domain.entities.album import Album
from app.domain.entities.history import PlayHistoryEntry
from app.domain.entities.like import Like
from app.domain.entities.playlist import Playlist
from app.domain.entities.storage import ObjectStat
from app.domain.entities.track import Artist, Track
from app.domain.entities.user import Credentials, User
__all__ = ["Artist", "Credentials", "ObjectStat", "Track", "User"]
__all__ = [
"Album",
"Artist",
"Credentials",
"Like",
"ObjectStat",
"PlayHistoryEntry",
"Playlist",
"Track",
"User",
]
+17
View File
@@ -0,0 +1,17 @@
"""Album domain entity."""
import datetime as dt
import uuid
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Album:
id: uuid.UUID
title: str
artist_id: uuid.UUID
year: int | None
cover_path: str | None
musicbrainz_id: str | None
created_at: dt.datetime
updated_at: dt.datetime
+15
View File
@@ -0,0 +1,15 @@
"""Play history domain entity — append-only scrobble log entry."""
import datetime as dt
import uuid
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class PlayHistoryEntry:
id: uuid.UUID
user_id: uuid.UUID
track_id: uuid.UUID
played_at: dt.datetime
play_duration_seconds: int | None
completed: bool
+14
View File
@@ -0,0 +1,14 @@
"""Like domain entity — append-only event log entry."""
import datetime as dt
import uuid
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Like:
id: uuid.UUID
user_id: uuid.UUID
track_id: uuid.UUID
value: str # "like" | "dislike" | "neutral"
created_at: dt.datetime
+16
View File
@@ -0,0 +1,16 @@
"""Playlist domain entity."""
import datetime as dt
import uuid
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Playlist:
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
version: int
created_at: dt.datetime
updated_at: dt.datetime
+3
View File
@@ -18,12 +18,15 @@ class Track:
id: uuid.UUID
title: str
artist_id: uuid.UUID
album_id: uuid.UUID | None
file_path: str
file_format: str
file_size: int
source: str
source_id: str
duration_seconds: int | None
genre: str | None
year: int | None
metadata_status: str
created_at: dt.datetime
updated_at: dt.datetime
+103 -1
View File
@@ -12,7 +12,15 @@ from contextlib import AbstractAsyncContextManager
from pathlib import Path
from typing import Protocol
from app.domain.entities import Credentials, ObjectStat, User
from app.domain.entities import (
Album,
Credentials,
Like,
ObjectStat,
PlayHistoryEntry,
Playlist,
User,
)
from app.domain.entities.track import Artist, Track
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
@@ -75,6 +83,12 @@ class FileStorage(Protocol):
class ArtistRepository(Protocol):
async def get_or_create(self, name: str) -> Artist: ...
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: ...
async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]: ...
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ...
async def count(self, *, q: str | None) -> int: ...
async def album_count(self, artist_id: uuid.UUID) -> int: ...
async def track_count(self, artist_id: uuid.UUID) -> int: ...
class TrackRepository(Protocol):
@@ -95,3 +109,91 @@ class TrackRepository(Protocol):
added_by: uuid.UUID | None,
) -> Track: ...
async def delete(self, track_id: uuid.UUID) -> None: ...
async def list(
self,
*,
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
sort_by: str,
order: str,
limit: int,
offset: int,
) -> list[Track]: ...
async def count(
self,
*,
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
) -> int: ...
async def update(
self,
track_id: uuid.UUID,
*,
title: str | None,
genre: str | None,
year: int | None,
) -> Track: ...
class AlbumRepository(Protocol):
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ...
async def track_count(self, album_id: uuid.UUID) -> int: ...
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
# list must come after any method using list[...] in its signature (name shadowing)
async def list(
self, *, artist_id: uuid.UUID | None, q: str | None, limit: int, offset: int
) -> list[Album]: ...
class PlaylistRepository(Protocol):
async def get_by_id(self, playlist_id: uuid.UUID) -> Playlist | None: ...
async def count(self, *, owner_id: uuid.UUID) -> int: ...
async def add(self, *, name: str, description: str | None, owner_id: uuid.UUID) -> Playlist: ...
async def update(
self, playlist_id: uuid.UUID, *, name: str | None, description: str | None
) -> Playlist: ...
async def delete(self, playlist_id: uuid.UUID) -> None: ...
async def track_count(self, playlist_id: uuid.UUID) -> int: ...
async def track_count_many(self, playlist_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
async def get_tracks(
self, playlist_id: uuid.UUID, *, limit: int, offset: int
) -> list[Track]: ...
async def get_track_total(self, playlist_id: uuid.UUID) -> int: ...
async def add_track(
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
) -> None: ...
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None: ...
async def max_position(self, playlist_id: uuid.UUID) -> float: ...
# list must come after any method using list[...] in its signature (name shadowing)
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]: ...
class LikeRepository(Protocol):
async def add(self, *, user_id: uuid.UUID, track_id: uuid.UUID, value: str) -> Like: ...
async def get_latest_state(
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
) -> list[Like]: ...
async def list_liked_tracks(
self, *, user_id: uuid.UUID, limit: int, offset: int
) -> list[Track]: ...
async def count_liked_tracks(self, *, user_id: uuid.UUID) -> int: ...
class HistoryRepository(Protocol):
async def add(
self,
*,
user_id: uuid.UUID,
track_id: uuid.UUID,
played_at: dt.datetime,
play_duration_seconds: int | None,
completed: bool,
) -> PlayHistoryEntry: ...
async def list(
self, *, user_id: uuid.UUID, limit: int, offset: int
) -> list[PlayHistoryEntry]: ...
async def count(self, *, user_id: uuid.UUID) -> int: ...
@@ -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)