feat(api): finish Group A stubbed endpoints
Implements previously-stubbed /api/v1 endpoints (hexagonal: ports -> repos
-> services -> routers wired in deps):
- playlists: GET /{id}/cover (serves stored cover, 404 when absent)
- settings: GET/PATCH /settings + GET/PUT /settings/scrobbling — lazy
per-user row, write-only Fernet-encrypted scrobble session key; adds
user_settings table + migration (chains off dc126696f5a6)
- storage: GET /duplicates, /broken, /missing-metadata + admin POST
/cleanup (arq cleanup_storage worker; reconciles local refs only,
guarded against a storage-outage mass delete)
- admin: GET /services, /sources, /settings + POST /reindex; PATCH
/settings and /sources/{source} return 501 (config is env-managed)
Adds NotSupportedError (-> HTTP 501). Integration tests for each surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+73
-8
@@ -1,22 +1,28 @@
|
||||
"""Storage analysis and cleanup endpoints."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
FileStorageDep,
|
||||
SuperUser,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.storage import (
|
||||
CleanupEnqueuedOut,
|
||||
DiskUsageOut,
|
||||
DuplicateGroupOut,
|
||||
FormatBreakdownOut,
|
||||
GenreCountOut,
|
||||
StorageStatsOut,
|
||||
)
|
||||
from app.api.schemas.track import TrackOut
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
from app.domain.entities.track import Track
|
||||
from app.workers.queue import enqueue
|
||||
|
||||
router = APIRouter(prefix="/storage", tags=["storage"])
|
||||
|
||||
@@ -24,6 +30,18 @@ router = APIRouter(prefix="/storage", tags=["storage"])
|
||||
_TOP_GENRES = 8
|
||||
|
||||
|
||||
async def _tracks_to_out(
|
||||
tracks: list[Track], artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep
|
||||
) -> list[TrackOut]:
|
||||
"""Hydrate a batch of tracks into ``TrackOut`` (artist/album names + cover
|
||||
flag), resolving each referenced artist/album in a single query."""
|
||||
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)}
|
||||
return await _build_track_out(tracks, artists, albums)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_storage_stats(
|
||||
track_repo: TrackRepoDep,
|
||||
@@ -70,16 +88,63 @@ async def get_storage_stats(
|
||||
|
||||
|
||||
@router.get("/duplicates")
|
||||
async def get_duplicates() -> Any: ...
|
||||
async def get_duplicates(
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> list[DuplicateGroupOut]:
|
||||
"""Tracks sharing an acoustic fingerprint, grouped — the library's real
|
||||
duplicates (``(source, source_id)`` is already unique). Cheap DB GROUP BY."""
|
||||
groups = await track_repo.find_duplicate_groups()
|
||||
all_tracks = [track for _, tracks in groups for track in tracks]
|
||||
out = await _tracks_to_out(all_tracks, artist_repo, album_repo)
|
||||
by_id = {item.id: item for item in out}
|
||||
return [
|
||||
DuplicateGroupOut(fingerprint=fingerprint, tracks=[by_id[t.id] for t in tracks])
|
||||
for fingerprint, tracks in groups
|
||||
]
|
||||
|
||||
|
||||
@router.get("/broken")
|
||||
async def get_broken_files() -> Any: ...
|
||||
async def get_broken_files(
|
||||
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]:
|
||||
"""Tracks whose last enrichment run failed (``metadata_status=failed``) —
|
||||
each carries its ``metadata_error``. A file gone missing on disk is instead
|
||||
reconciled by ``POST /storage/cleanup`` (that needs a filesystem scan)."""
|
||||
tracks = await track_repo.list_by_metadata_status("failed", limit=limit, offset=offset)
|
||||
total = await track_repo.count_by_metadata_status("failed")
|
||||
items = await _tracks_to_out(tracks, artist_repo, album_repo)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/missing-metadata")
|
||||
async def get_missing_metadata() -> Any: ...
|
||||
async def get_missing_metadata(
|
||||
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]:
|
||||
"""Tracks still awaiting enrichment (``metadata_status=pending``) — imported
|
||||
but never identified."""
|
||||
tracks = await track_repo.list_by_metadata_status("pending", limit=limit, offset=offset)
|
||||
total = await track_repo.count_by_metadata_status("pending")
|
||||
items = await _tracks_to_out(tracks, artist_repo, album_repo)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def run_cleanup() -> Any: ...
|
||||
@router.post("/cleanup", status_code=202)
|
||||
async def run_cleanup(_: SuperUser) -> CleanupEnqueuedOut:
|
||||
"""Admin: enqueue the storage reconciliation job. It scans the catalogue and
|
||||
removes rows whose backing file has vanished (dangling references). Runs in
|
||||
the worker — the filesystem scan must not block the request cycle."""
|
||||
job_id = await enqueue("cleanup_storage")
|
||||
return CleanupEnqueuedOut(status="enqueued", job_id=job_id)
|
||||
|
||||
Reference in New Issue
Block a user