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
+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)