From ea880edd57091abab592d59170fe9180c643c761 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sun, 14 Jun 2026 01:35:51 +0300 Subject: [PATCH] feat(tracks): filter track list by ingest source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `source` filter to `GET /api/v1/tracks` (and the `TrackRepository.list`/`count` port + SQLAlchemy adapter). Lets clients query, e.g., only uploaded tracks (`?source=upload`) newest-first — the backing for the webui's persistent "Recently uploaded" view. - test: upload then list with `?source=upload` (hit) / `?source=youtube` (miss) Co-Authored-By: Claude Opus 4.8 --- app/api/v1/tracks.py | 6 ++++- app/domain/ports.py | 2 ++ .../db/repositories/track_repository.py | 6 +++++ tests/test_upload_stream_api.py | 22 +++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/api/v1/tracks.py b/app/api/v1/tracks.py index 39e2e8e..8c24e41 100644 --- a/app/api/v1/tracks.py +++ b/app/api/v1/tracks.py @@ -71,6 +71,7 @@ async def list_tracks( artist_id: uuid.UUID | None = None, album_id: uuid.UUID | None = None, q: str | None = None, + source: str | None = Query(None, max_length=32), 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), @@ -80,12 +81,15 @@ async def list_tracks( artist_id=artist_id, album_id=album_id, q=q, + source=source, 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) + total = await track_repo.count( + artist_id=artist_id, album_id=album_id, q=q, source=source + ) 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}) diff --git a/app/domain/ports.py b/app/domain/ports.py index 0ce8192..977a75d 100644 --- a/app/domain/ports.py +++ b/app/domain/ports.py @@ -145,6 +145,7 @@ class TrackRepository(Protocol): artist_id: uuid.UUID | None, album_id: uuid.UUID | None, q: str | None, + source: str | None = None, sort_by: str, order: str, limit: int, @@ -156,6 +157,7 @@ class TrackRepository(Protocol): artist_id: uuid.UUID | None, album_id: uuid.UUID | None, q: str | None, + source: str | None = None, ) -> int: ... async def update( self, diff --git a/app/infrastructure/db/repositories/track_repository.py b/app/infrastructure/db/repositories/track_repository.py index 72ec7cb..d76c1e6 100644 --- a/app/infrastructure/db/repositories/track_repository.py +++ b/app/infrastructure/db/repositories/track_repository.py @@ -170,6 +170,7 @@ class SqlAlchemyTrackRepository: artist_id: uuid.UUID | None, album_id: uuid.UUID | None, q: str | None, + source: str | None = None, sort_by: str = "created_at", order: str = "desc", limit: int = 50, @@ -180,6 +181,8 @@ class SqlAlchemyTrackRepository: stmt = stmt.where(TrackModel.artist_id == artist_id) if album_id is not None: stmt = stmt.where(TrackModel.album_id == album_id) + if source is not None: + stmt = stmt.where(TrackModel.source == source) if q: stmt = stmt.where(TrackModel.title.ilike(f"%{q}%")) @@ -204,12 +207,15 @@ class SqlAlchemyTrackRepository: artist_id: uuid.UUID | None, album_id: uuid.UUID | None, q: str | None, + source: str | None = 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 source is not None: + stmt = stmt.where(TrackModel.source == source) if q: stmt = stmt.where(TrackModel.title.ilike(f"%{q}%")) return (await self._session.execute(stmt)).scalar_one() diff --git a/tests/test_upload_stream_api.py b/tests/test_upload_stream_api.py index f87f88c..2687d59 100644 --- a/tests/test_upload_stream_api.py +++ b/tests/test_upload_stream_api.py @@ -190,3 +190,25 @@ async def test_upload_requires_auth(api: AsyncClient) -> None: files={"file": ("x.mp3", b"data", "audio/mpeg")}, ) assert resp.status_code == 401 + + +async def test_list_tracks_filters_by_source(api: AsyncClient) -> None: + # Uploaded tracks carry source="upload"; `?source=` narrows the list (this + # powers the webui's "Recently uploaded" view, which survives a refresh). + token = await _login(api) + headers = {"Authorization": f"Bearer {token}"} + await api.post( + "/api/v1/upload", + files={"file": ("uploaded.mp3", b"upload bytes" * 80, "audio/mpeg")}, + headers=headers, + ) + + hit = await api.get("/api/v1/tracks", params={"source": "upload"}, headers=headers) + assert hit.status_code == 200, hit.text + items = hit.json()["items"] + assert len(items) == 1 + assert items[0]["source"] == "upload" + + miss = await api.get("/api/v1/tracks", params={"source": "youtube"}, headers=headers) + assert miss.status_code == 200 + assert miss.json()["items"] == []