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:
+71
-11
@@ -5,11 +5,18 @@ sign-up (plan §6.4).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.api.deps import SubsonicAuthServiceDep, SuperUser, UserServiceDep
|
||||
from app.api.deps import SourceRegistryDep, SubsonicAuthServiceDep, SuperUser, UserServiceDep
|
||||
from app.api.health import _check_db, _check_ml, _check_redis
|
||||
from app.api.schemas.admin import (
|
||||
AdminSettingsOut,
|
||||
ReindexJob,
|
||||
ReindexResponse,
|
||||
ServicesStatusOut,
|
||||
)
|
||||
from app.api.schemas.source import SourceInfoOut
|
||||
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||
from app.api.schemas.user import (
|
||||
CreateUserRequest,
|
||||
@@ -17,6 +24,9 @@ from app.api.schemas.user import (
|
||||
UpdateUserRequest,
|
||||
UserResponse,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.domain.errors import DependencyUnavailableError, NotSupportedError
|
||||
from app.workers.queue import enqueue
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -91,24 +101,74 @@ async def rotate_user_subsonic_password(
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def list_services(_admin: SuperUser) -> Any: ...
|
||||
async def list_services(_admin: SuperUser) -> ServicesStatusOut:
|
||||
"""Backing-dependency health for the admin dashboard — same probes as the
|
||||
readiness endpoint (DB + Redis required, ML optional)."""
|
||||
database = await _check_db()
|
||||
redis = await _check_redis()
|
||||
ml = await _check_ml()
|
||||
return ServicesStatusOut(database=database, redis=redis, ml=ml)
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def list_admin_sources(_admin: SuperUser) -> Any: ...
|
||||
|
||||
|
||||
@router.patch("/sources/{source}")
|
||||
async def update_admin_source(source: str, _admin: SuperUser) -> Any: ...
|
||||
async def list_admin_sources(
|
||||
_admin: SuperUser, registry: SourceRegistryDep
|
||||
) -> list[SourceInfoOut]:
|
||||
"""Configured sources and their live availability (same view as
|
||||
``/sources``, admin-scoped)."""
|
||||
return [SourceInfoOut.from_entity(info) for info in registry.infos()]
|
||||
|
||||
|
||||
@router.post("/reindex")
|
||||
async def trigger_reindex(_admin: SuperUser) -> Any: ...
|
||||
async def trigger_reindex(admin: SuperUser, registry: SourceRegistryDep) -> ReindexResponse:
|
||||
"""Enqueue a full re-scan of every indexable source. The walk + file copies
|
||||
run in the worker (never the request cycle); re-scans are idempotent."""
|
||||
indexables = registry.indexables()
|
||||
if not indexables:
|
||||
raise DependencyUnavailableError("No indexable source is configured.")
|
||||
jobs: list[ReindexJob] = []
|
||||
for backend in indexables:
|
||||
job_id = await enqueue("scan_local_folder", source=backend.name, added_by=str(admin.id))
|
||||
jobs.append(ReindexJob(source=backend.name, job_id=job_id))
|
||||
return ReindexResponse(jobs=jobs)
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_admin_settings(_admin: SuperUser) -> Any: ...
|
||||
async def get_admin_settings(_admin: SuperUser) -> AdminSettingsOut:
|
||||
"""Effective, non-secret instance configuration. Reflects the environment the
|
||||
process booted with; secrets/connection strings are never returned — only
|
||||
whether each optional integration is configured."""
|
||||
settings = get_settings()
|
||||
return AdminSettingsOut(
|
||||
environment=settings.environment,
|
||||
allow_registration=settings.allow_registration,
|
||||
storage_backend=settings.storage_backend,
|
||||
media_path=str(settings.media_path),
|
||||
youtube_enabled=settings.youtube_enabled,
|
||||
coverart_enabled=settings.coverart_enabled,
|
||||
ml_configured=settings.ml_service_url is not None,
|
||||
acoustid_configured=settings.acoustid_api_key is not None,
|
||||
local_import_configured=settings.local_media_import_path is not None,
|
||||
)
|
||||
|
||||
|
||||
# -- runtime config mutation (intentionally unsupported) ----------------------
|
||||
# The instance is env-configured (CLAUDE.md: nothing hardcoded, all from env) and
|
||||
# get_settings() is a cached singleton, so config is not mutable at runtime.
|
||||
# These endpoints answer 501 with a clear reason rather than silently no-op'ing;
|
||||
# a persistent override layer that shadows env would be a deliberate future
|
||||
# departure. Read the effective config via GET /admin/settings.
|
||||
@router.patch("/sources/{source}")
|
||||
async def update_admin_source(source: str, _admin: SuperUser) -> None:
|
||||
raise NotSupportedError(
|
||||
"Sources are configured via environment variables (e.g. YOUTUBE_ENABLED, "
|
||||
"LOCAL_MEDIA_IMPORT_PATH); runtime changes are not supported."
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def update_admin_settings(_admin: SuperUser) -> Any: ...
|
||||
async def update_admin_settings(_admin: SuperUser) -> None:
|
||||
raise NotSupportedError(
|
||||
"Instance settings are managed via environment configuration; "
|
||||
"runtime changes are not supported."
|
||||
)
|
||||
|
||||
+15
-2
@@ -1,15 +1,18 @@
|
||||
"""Playlist endpoints."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.covers import stream_cover
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
FileStorageDep,
|
||||
PlaylistRepoDep,
|
||||
StreamUser,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
@@ -217,4 +220,14 @@ async def reorder_playlist_tracks(
|
||||
|
||||
|
||||
@router.get("/{playlist_id}/cover")
|
||||
async def get_playlist_cover(playlist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
async def get_playlist_cover(
|
||||
playlist_id: uuid.UUID,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
storage: FileStorageDep,
|
||||
_: StreamUser,
|
||||
) -> StreamingResponse:
|
||||
# ``<img>`` can't send a bearer header → StreamUser accepts ``?token=``.
|
||||
cover_path = await playlist_repo.get_cover_path(playlist_id)
|
||||
if not cover_path:
|
||||
raise NotFoundError("Cover not found.")
|
||||
return await stream_cover(storage, cover_path)
|
||||
|
||||
+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)
|
||||
|
||||
@@ -1,23 +1,67 @@
|
||||
"""User settings endpoints, including scrobbling configuration."""
|
||||
"""User settings endpoints, including scrobbling configuration.
|
||||
|
||||
from typing import Any
|
||||
Settings are per-caller and created lazily, so a first read returns defaults.
|
||||
The scrobbler session key is write-only — accepted on ``PUT`` but never returned.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, UserSettingsServiceDep
|
||||
from app.api.schemas.settings import (
|
||||
ScrobblingOut,
|
||||
ScrobblingUpdate,
|
||||
SettingsOut,
|
||||
SettingsUpdate,
|
||||
)
|
||||
from app.domain.entities.settings import UserSettings
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _to_settings_out(settings: UserSettings) -> SettingsOut:
|
||||
return SettingsOut(theme=settings.theme, stream_quality=settings.stream_quality)
|
||||
|
||||
|
||||
def _to_scrobbling_out(settings: UserSettings) -> ScrobblingOut:
|
||||
return ScrobblingOut(
|
||||
enabled=settings.scrobble_enabled,
|
||||
provider=settings.scrobble_provider,
|
||||
username=settings.scrobble_username,
|
||||
configured=settings.scrobble_session_key_enc is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings() -> Any: ...
|
||||
async def get_settings(user: CurrentUser, service: UserSettingsServiceDep) -> SettingsOut:
|
||||
return _to_settings_out(await service.get(user.id))
|
||||
|
||||
|
||||
@router.patch("")
|
||||
async def update_settings() -> Any: ...
|
||||
async def update_settings(
|
||||
body: SettingsUpdate, user: CurrentUser, service: UserSettingsServiceDep
|
||||
) -> SettingsOut:
|
||||
settings = await service.update_general(
|
||||
user.id, theme=body.theme, stream_quality=body.stream_quality
|
||||
)
|
||||
return _to_settings_out(settings)
|
||||
|
||||
|
||||
@router.get("/scrobbling")
|
||||
async def get_scrobbling_settings() -> Any: ...
|
||||
async def get_scrobbling_settings(
|
||||
user: CurrentUser, service: UserSettingsServiceDep
|
||||
) -> ScrobblingOut:
|
||||
return _to_scrobbling_out(await service.get(user.id))
|
||||
|
||||
|
||||
@router.put("/scrobbling")
|
||||
async def set_scrobbling_settings() -> Any: ...
|
||||
async def set_scrobbling_settings(
|
||||
body: ScrobblingUpdate, user: CurrentUser, service: UserSettingsServiceDep
|
||||
) -> ScrobblingOut:
|
||||
settings = await service.set_scrobbling(
|
||||
user.id,
|
||||
enabled=body.enabled,
|
||||
provider=body.provider,
|
||||
username=body.username,
|
||||
session_key=body.session_key,
|
||||
)
|
||||
return _to_scrobbling_out(settings)
|
||||
|
||||
Reference in New Issue
Block a user