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:
Цвылев Александр Вадимович
2026-07-28 14:33:12 +03:00
parent df580578f6
commit a263272935
27 changed files with 1362 additions and 27 deletions
+71 -11
View File
@@ -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."
)