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:
@@ -14,6 +14,7 @@ from app.infrastructure.db.models.play_history import PlayHistoryModel
|
||||
from app.infrastructure.db.models.playlist import PlaylistModel, PlaylistTrackModel
|
||||
from app.infrastructure.db.models.track import TrackModel
|
||||
from app.infrastructure.db.models.user import RefreshTokenModel, UserModel
|
||||
from app.infrastructure.db.models.user_settings import UserSettingsModel
|
||||
|
||||
__all__ = [
|
||||
"AlbumModel",
|
||||
@@ -27,4 +28,5 @@ __all__ = [
|
||||
"RefreshTokenModel",
|
||||
"TrackModel",
|
||||
"UserModel",
|
||||
"UserSettingsModel",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""ORM model for per-user settings (general preferences + scrobbling)."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin
|
||||
|
||||
|
||||
class UserSettingsModel(TimestampMixin, Base):
|
||||
"""One row per user, created lazily on first save. The primary key *is* the
|
||||
user id (a 1:1 extension of ``users``), so there's no separate surrogate id."""
|
||||
|
||||
__tablename__ = "user_settings"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
theme: Mapped[str] = mapped_column(String(16), default="system", nullable=False)
|
||||
stream_quality: Mapped[str] = mapped_column(String(16), default="original", nullable=False)
|
||||
scrobble_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
scrobble_provider: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
scrobble_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Fernet-encrypted scrobbler session key / token (see core.security). Never
|
||||
# the plaintext — mirrors how the Subsonic app-password is stored.
|
||||
scrobble_session_key_enc: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@@ -13,6 +13,9 @@ from app.infrastructure.db.repositories.refresh_token_repository import (
|
||||
)
|
||||
from app.infrastructure.db.repositories.track_repository import SqlAlchemyTrackRepository
|
||||
from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRepository
|
||||
from app.infrastructure.db.repositories.user_settings_repository import (
|
||||
SqlAlchemyUserSettingsRepository,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SqlAlchemyAlbumRepository",
|
||||
@@ -24,4 +27,5 @@ __all__ = [
|
||||
"SqlAlchemyRefreshTokenRepository",
|
||||
"SqlAlchemyTrackRepository",
|
||||
"SqlAlchemyUserRepository",
|
||||
"SqlAlchemyUserSettingsRepository",
|
||||
]
|
||||
|
||||
@@ -207,6 +207,15 @@ class SqlAlchemyPlaylistRepository:
|
||||
).scalar_one_or_none()
|
||||
return float(result) if result is not None else 0.0
|
||||
|
||||
async def get_cover_path(self, playlist_id: uuid.UUID) -> str | None:
|
||||
"""The playlist's stored cover key, or ``None`` (missing playlist or no
|
||||
cover). Read directly — the entity doesn't carry the storage key."""
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(PlaylistModel.cover_path).where(PlaylistModel.id == playlist_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
# list must come after methods using list[...] in signatures (builtin name shadowing)
|
||||
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]:
|
||||
rows = (
|
||||
|
||||
@@ -194,6 +194,78 @@ class SqlAlchemyTrackRepository:
|
||||
by_source={source: cnt for source, cnt in source_rows},
|
||||
)
|
||||
|
||||
async def find_duplicate_groups(self) -> list[tuple[str, list[Track]]]:
|
||||
"""Tracks that share an ``acoustid_fingerprint`` (the dedup key), grouped
|
||||
by it — only fingerprints with more than one track. Empty when clean.
|
||||
Defined before ``list`` for the same name-shadowing reason as ``genres``."""
|
||||
dup_fps = (
|
||||
select(TrackModel.acoustid_fingerprint)
|
||||
.where(TrackModel.acoustid_fingerprint.is_not(None))
|
||||
.group_by(TrackModel.acoustid_fingerprint)
|
||||
.having(func.count(TrackModel.id) > 1)
|
||||
.scalar_subquery()
|
||||
)
|
||||
rows = (
|
||||
(
|
||||
await self._session.execute(
|
||||
select(TrackModel)
|
||||
.where(TrackModel.acoustid_fingerprint.in_(dup_fps))
|
||||
.order_by(TrackModel.acoustid_fingerprint, TrackModel.created_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
groups: dict[str, list[Track]] = {}
|
||||
for row in rows:
|
||||
fingerprint = row.acoustid_fingerprint
|
||||
assert fingerprint is not None # filtered to non-null above
|
||||
groups.setdefault(fingerprint, []).append(_to_entity(row))
|
||||
return list(groups.items())
|
||||
|
||||
async def list_by_metadata_status(
|
||||
self, status: str, *, limit: int, offset: int
|
||||
) -> list[Track]:
|
||||
"""Tracks in a given ``metadata_status`` (e.g. ``pending``/``failed``),
|
||||
newest first. Defined before ``list`` (name-shadowing)."""
|
||||
rows = (
|
||||
(
|
||||
await self._session.execute(
|
||||
select(TrackModel)
|
||||
.where(TrackModel.metadata_status == status)
|
||||
.order_by(TrackModel.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def all_storage_refs(self) -> list[tuple[uuid.UUID, str]]:
|
||||
"""``(id, storage_uri)`` for every *local* track — for the cleanup
|
||||
worker's filesystem reconciliation. Remote placeholders have no local
|
||||
file (``availability != local``) and are skipped. No entity hydration."""
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(TrackModel.id, TrackModel.storage_uri).where(
|
||||
TrackModel.availability == TrackAvailability.LOCAL.value,
|
||||
TrackModel.storage_uri.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return [(row.id, row.storage_uri) for row in rows]
|
||||
|
||||
async def count_by_metadata_status(self, status: str) -> int:
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(func.count())
|
||||
.select_from(TrackModel)
|
||||
.where(TrackModel.metadata_status == status)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""User-settings repository — adapter over ``AsyncSession``."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.settings import UserSettings
|
||||
from app.infrastructure.db.models.user_settings import UserSettingsModel
|
||||
|
||||
|
||||
def _to_entity(row: UserSettingsModel) -> UserSettings:
|
||||
return UserSettings(
|
||||
user_id=row.user_id,
|
||||
theme=row.theme,
|
||||
stream_quality=row.stream_quality,
|
||||
scrobble_enabled=row.scrobble_enabled,
|
||||
scrobble_provider=row.scrobble_provider,
|
||||
scrobble_username=row.scrobble_username,
|
||||
scrobble_session_key_enc=row.scrobble_session_key_enc,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyUserSettingsRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get(self, user_id: uuid.UUID) -> UserSettings | None:
|
||||
row = await self._session.get(UserSettingsModel, user_id)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def upsert(self, settings: UserSettings) -> UserSettings:
|
||||
"""Create or replace the caller's settings row with ``settings`` in full.
|
||||
The service merges partial updates against current values before calling
|
||||
this, so the write always carries the complete desired state."""
|
||||
row = await self._session.get(UserSettingsModel, settings.user_id)
|
||||
if row is None:
|
||||
row = UserSettingsModel(user_id=settings.user_id)
|
||||
self._session.add(row)
|
||||
row.theme = settings.theme
|
||||
row.stream_quality = settings.stream_quality
|
||||
row.scrobble_enabled = settings.scrobble_enabled
|
||||
row.scrobble_provider = settings.scrobble_provider
|
||||
row.scrobble_username = settings.scrobble_username
|
||||
row.scrobble_session_key_enc = settings.scrobble_session_key_enc
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
Reference in New Issue
Block a user