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
+62
View File
@@ -0,0 +1,62 @@
"""User-settings use cases: general preferences + scrobbling configuration.
Settings rows are created lazily — a user who never saved anything reads clean
defaults. Partial updates are merged against current values here, so the
repository always persists the complete desired state. The scrobbler session
key is encrypted before it touches the DB (never stored or returned in plain).
"""
import uuid
from dataclasses import replace
from app.domain.entities.settings import UserSettings
from app.domain.ports import SubsonicCipher, UserSettingsRepository
class UserSettingsService:
def __init__(self, *, settings: UserSettingsRepository, cipher: SubsonicCipher) -> None:
self._settings = settings
# Same Fernet cipher used for the Subsonic app-password — reused here to
# encrypt the scrobbler session key at rest (symmetric, recoverable).
self._cipher = cipher
async def get(self, user_id: uuid.UUID) -> UserSettings:
return await self._settings.get(user_id) or UserSettings.defaults(user_id)
async def update_general(
self, user_id: uuid.UUID, *, theme: str | None, stream_quality: str | None
) -> UserSettings:
current = await self.get(user_id)
merged = replace(
current,
theme=theme if theme is not None else current.theme,
stream_quality=stream_quality if stream_quality is not None else current.stream_quality,
)
return await self._settings.upsert(merged)
async def set_scrobbling(
self,
user_id: uuid.UUID,
*,
enabled: bool,
provider: str | None,
username: str | None,
session_key: str | None,
) -> UserSettings:
"""Replace the scrobbling config. ``session_key`` is write-only: a new
value is encrypted and stored; omitting it keeps the existing key (the
client can't read it back to re-send it)."""
current = await self.get(user_id)
session_key_enc: str | None
if session_key is not None:
session_key_enc = self._cipher.encrypt(session_key)
else:
session_key_enc = current.scrobble_session_key_enc
merged = replace(
current,
scrobble_enabled=enabled,
scrobble_provider=provider,
scrobble_username=username,
scrobble_session_key_enc=session_key_enc,
)
return await self._settings.upsert(merged)