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
@@ -0,0 +1,58 @@
"""user_settings: per-user preferences + scrobbling config
Revision ID: 20260728_user_settings
Revises: dc126696f5a6
Create Date: 2026-07-28 10:00:00.000000
Adds the ``user_settings`` table (1:1 with ``users``, PK = user_id): general
preferences (theme, stream quality) plus scrobbling config. The scrobbler
session key is stored Fernet-encrypted, never in plaintext.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260728_user_settings"
down_revision: str | None = "dc126696f5a6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"user_settings",
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("theme", sa.String(length=16), nullable=False),
sa.Column("stream_quality", sa.String(length=16), nullable=False),
sa.Column("scrobble_enabled", sa.Boolean(), nullable=False),
sa.Column("scrobble_provider", sa.String(length=16), nullable=True),
sa.Column("scrobble_username", sa.String(length=255), nullable=True),
sa.Column("scrobble_session_key_enc", sa.String(length=512), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
name=op.f("fk_user_settings_user_id_users"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("user_id", name=op.f("pk_user_settings")),
)
def downgrade() -> None:
op.drop_table("user_settings")