a263272935
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>
175 lines
6.6 KiB
Python
175 lines
6.6 KiB
Python
"""Admin endpoints: user management, services, sources, reindex, settings.
|
|
|
|
Registration is admin-only — this is a private instance, there is no public
|
|
sign-up (plan §6.4).
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Query, status
|
|
|
|
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,
|
|
ResetPasswordRequest,
|
|
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"])
|
|
|
|
|
|
@router.get("/users", response_model=list[UserResponse])
|
|
async def list_users(
|
|
_admin: SuperUser,
|
|
users: UserServiceDep,
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> list[UserResponse]:
|
|
result = await users.list_users(limit=limit, offset=offset)
|
|
return [UserResponse.from_entity(u) for u in result]
|
|
|
|
|
|
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_user(
|
|
body: CreateUserRequest, _admin: SuperUser, users: UserServiceDep
|
|
) -> UserResponse:
|
|
user = await users.create_user(
|
|
username=body.username,
|
|
password=body.password,
|
|
is_superuser=body.is_superuser,
|
|
)
|
|
return UserResponse.from_entity(user)
|
|
|
|
|
|
@router.get("/users/{user_id}", response_model=UserResponse)
|
|
async def get_user(user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep) -> UserResponse:
|
|
return UserResponse.from_entity(await users.get_user(user_id))
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=UserResponse)
|
|
async def update_user(
|
|
user_id: uuid.UUID,
|
|
body: UpdateUserRequest,
|
|
_admin: SuperUser,
|
|
users: UserServiceDep,
|
|
) -> UserResponse:
|
|
user = await users.get_user(user_id)
|
|
if body.is_superuser is not None:
|
|
user = await users.set_superuser(user_id, is_superuser=body.is_superuser)
|
|
if body.is_active is not None:
|
|
user = await users.set_active(user_id, is_active=body.is_active)
|
|
return UserResponse.from_entity(user)
|
|
|
|
|
|
@router.post("/users/{user_id}/reset-password", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def reset_password(
|
|
user_id: uuid.UUID,
|
|
body: ResetPasswordRequest,
|
|
_admin: SuperUser,
|
|
users: UserServiceDep,
|
|
) -> None:
|
|
await users.reset_password(user_id, new_password=body.new_password)
|
|
|
|
|
|
@router.delete("/users/{user_id}", response_model=UserResponse)
|
|
async def deactivate_user(
|
|
user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep
|
|
) -> UserResponse:
|
|
"""Soft delete — deactivates the account and revokes its sessions."""
|
|
return UserResponse.from_entity(await users.deactivate(user_id))
|
|
|
|
|
|
@router.post("/users/{user_id}/subsonic-password", response_model=SubsonicPasswordResponse)
|
|
async def rotate_user_subsonic_password(
|
|
user_id: uuid.UUID, _admin: SuperUser, subsonic: SubsonicAuthServiceDep
|
|
) -> SubsonicPasswordResponse:
|
|
"""Rotate any user's Subsonic app-password and return the new plaintext."""
|
|
return SubsonicPasswordResponse(password=await subsonic.rotate(user_id))
|
|
|
|
|
|
@router.get("/services")
|
|
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, 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, 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) -> 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) -> None:
|
|
raise NotSupportedError(
|
|
"Instance settings are managed via environment configuration; "
|
|
"runtime changes are not supported."
|
|
)
|