Compare commits
5 Commits
4ade6939b6
...
c72d19599a
| Author | SHA1 | Date | |
|---|---|---|---|
| c72d19599a | |||
| 48e3418c7f | |||
| 551afbab13 | |||
| b975164fc2 | |||
| 7a17e3babd |
@@ -17,11 +17,20 @@ JWT_SECRET=change-me-in-prod
|
|||||||
ACCESS_TOKEN_TTL_SECONDS=900
|
ACCESS_TOKEN_TTL_SECONDS=900
|
||||||
REFRESH_TOKEN_TTL_SECONDS=2592000
|
REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||||
|
|
||||||
|
# subsonic — key that encrypts per-user Subsonic app-passwords at rest.
|
||||||
|
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
|
||||||
|
# invalidates all stored app-passwords. NOTE: /rest must be served over HTTPS.
|
||||||
|
SUBSONIC_SECRET_KEY=change-me-subsonic-key
|
||||||
|
|
||||||
# media / storage
|
# media / storage
|
||||||
MEDIA_PATH=/data/media
|
MEDIA_PATH=/data/media
|
||||||
TRANSCODE_CACHE_PATH=/data/transcode-cache
|
TRANSCODE_CACHE_PATH=/data/transcode-cache
|
||||||
MAX_PARALLEL_DOWNLOADS=2
|
MAX_PARALLEL_DOWNLOADS=2
|
||||||
|
|
||||||
|
# sources — mounted folder the `local` source indexes (copies into MEDIA_PATH).
|
||||||
|
# Unset → the local source is not registered. Mount read-only in compose.
|
||||||
|
# LOCAL_MEDIA_IMPORT_PATH=/import
|
||||||
|
|
||||||
# external services (all optional — backend degrades gracefully if unset)
|
# external services (all optional — backend degrades gracefully if unset)
|
||||||
# ML_SERVICE_URL=http://ml:9000
|
# ML_SERVICE_URL=http://ml:9000
|
||||||
# ACOUSTID_API_KEY=
|
# ACOUSTID_API_KEY=
|
||||||
|
|||||||
@@ -70,3 +70,57 @@ The DB URL is injected from app settings — never hardcoded in `alembic.ini`.
|
|||||||
All settings come from environment variables (or `.env` in dev). See
|
All settings come from environment variables (or `.env` in dev). See
|
||||||
[`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz)
|
[`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz)
|
||||||
are **optional** — the backend degrades gracefully when they are absent.
|
are **optional** — the backend degrades gracefully when they are absent.
|
||||||
|
|
||||||
|
## Sources & importing music
|
||||||
|
|
||||||
|
Music enters the library through **source backends** (`app/infrastructure/sources`),
|
||||||
|
selected via a registry. The first backend is **`local`** — it indexes a mounted
|
||||||
|
folder, copying each audio file into managed storage and creating a track
|
||||||
|
(`metadata_status=pending`; real metadata is filled later by enrichment).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# point the instance at an existing library (mount read-only in compose)
|
||||||
|
LOCAL_MEDIA_IMPORT_PATH=/import
|
||||||
|
|
||||||
|
GET /api/v1/sources # list configured sources + availability
|
||||||
|
POST /api/v1/sources/local/scan # admin: enqueue an import (runs in the worker)
|
||||||
|
GET /api/v1/sources/local/health # availability check
|
||||||
|
```
|
||||||
|
|
||||||
|
Scanning is a background job (arq worker) — the endpoint only enqueues it; the
|
||||||
|
walk + file copies never run in the request cycle. Re-scans are idempotent
|
||||||
|
(dedup on `(source, source_id)`, where `source_id` is the path within the root).
|
||||||
|
|
||||||
|
## Subsonic API (`/rest`)
|
||||||
|
|
||||||
|
A Subsonic-compatible API is mounted at `/rest`, so standard clients (Symfonium,
|
||||||
|
DSub, play:Sub, …) can browse the library and stream. It is a thin adapter over
|
||||||
|
the native services — it adds no business logic of its own.
|
||||||
|
|
||||||
|
**HTTPS is mandatory.** Subsonic authentication puts the credential in the URL
|
||||||
|
(`t=md5(password+salt)&s=…`, or the legacy `p=`), so `/rest` must only ever be
|
||||||
|
exposed behind TLS (terminate at the reverse proxy). Never serve it over plain
|
||||||
|
HTTP.
|
||||||
|
|
||||||
|
### App-passwords
|
||||||
|
|
||||||
|
Subsonic auth needs a recoverable secret, but login passwords are stored as a
|
||||||
|
one-way argon2 hash. So Subsonic clients authenticate against a separate,
|
||||||
|
per-user **app-password** — high-entropy, random, and encrypted at rest with a
|
||||||
|
key derived from `SUBSONIC_SECRET_KEY` (set this to a strong random string in
|
||||||
|
prod; rotating it invalidates all stored app-passwords).
|
||||||
|
|
||||||
|
Self-service lifecycle (native API, needs a normal JWT login):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/v1/users/me/subsonic-password # reveal (generated lazily on first read)
|
||||||
|
POST /api/v1/users/me/subsonic-password # rotate
|
||||||
|
# admin, for any user:
|
||||||
|
POST /api/v1/admin/users/{user_id}/subsonic-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the client at the instance URL, use your **username** + the revealed
|
||||||
|
**app-password** (not your login password).
|
||||||
|
|
||||||
|
> **Cover art** (`getCoverArt`) currently returns a placeholder — the cover
|
||||||
|
> pipeline (`/api/v1/.../cover` endpoints) is not implemented yet.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""subsonic: per-user encrypted app-password
|
||||||
|
|
||||||
|
Revision ID: 20260608_subsonic_pw
|
||||||
|
Revises: 20260608_storage_uri
|
||||||
|
Create Date: 2026-06-08 12:00:00.000000
|
||||||
|
|
||||||
|
Adds ``users.subsonic_password_enc`` — the recoverable, Fernet-encrypted
|
||||||
|
Subsonic app-password (plan §7). NULL until the user generates one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260608_subsonic_pw"
|
||||||
|
down_revision: str | None = "20260608_storage_uri"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("subsonic_password_enc", sa.String(length=255), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "subsonic_password_enc")
|
||||||
+54
-3
@@ -10,19 +10,20 @@ from collections.abc import AsyncIterator
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends, Query
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.application.auth_service import AuthService
|
from app.application.auth_service import AuthService
|
||||||
from app.application.streaming_service import StreamingService
|
from app.application.streaming_service import StreamingService
|
||||||
|
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||||
from app.application.upload_service import UploadService
|
from app.application.upload_service import UploadService
|
||||||
from app.application.user_service import UserService
|
from app.application.user_service import UserService
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.security import Argon2PasswordHasher, JwtTokenService
|
from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher
|
||||||
from app.domain.entities import User
|
from app.domain.entities import User
|
||||||
from app.domain.errors import AuthenticationError, PermissionDeniedError
|
from app.domain.errors import AuthenticationError, PermissionDeniedError
|
||||||
from app.domain.ports import FileStorage, PasswordHasher, TokenService
|
from app.domain.ports import FileStorage, PasswordHasher, SubsonicCipher, TokenService
|
||||||
from app.infrastructure.db import get_sessionmaker
|
from app.infrastructure.db import get_sessionmaker
|
||||||
from app.infrastructure.db.repositories import (
|
from app.infrastructure.db.repositories import (
|
||||||
SqlAlchemyAlbumRepository,
|
SqlAlchemyAlbumRepository,
|
||||||
@@ -34,7 +35,9 @@ from app.infrastructure.db.repositories import (
|
|||||||
SqlAlchemyTrackRepository,
|
SqlAlchemyTrackRepository,
|
||||||
SqlAlchemyUserRepository,
|
SqlAlchemyUserRepository,
|
||||||
)
|
)
|
||||||
|
from app.infrastructure.sources.registry import SourceRegistry, build_source_registry
|
||||||
from app.infrastructure.storage.provider import get_file_storage
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
|
from app.workers.queue import enqueue_enrich
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
@@ -64,6 +67,19 @@ def get_token_service() -> TokenService:
|
|||||||
return JwtTokenService(get_settings())
|
return JwtTokenService(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_subsonic_cipher() -> SubsonicCipher:
|
||||||
|
return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value())
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_source_registry() -> SourceRegistry:
|
||||||
|
return build_source_registry(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
SourceRegistryDep = Annotated[SourceRegistry, Depends(get_source_registry)]
|
||||||
|
|
||||||
|
|
||||||
# -- request-scoped services ---------------------------------------------------
|
# -- request-scoped services ---------------------------------------------------
|
||||||
def get_auth_service(session: SessionDep) -> AuthService:
|
def get_auth_service(session: SessionDep) -> AuthService:
|
||||||
return AuthService(
|
return AuthService(
|
||||||
@@ -82,8 +98,16 @@ def get_user_service(session: SessionDep) -> UserService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_subsonic_auth_service(session: SessionDep) -> SubsonicAuthService:
|
||||||
|
return SubsonicAuthService(
|
||||||
|
users=SqlAlchemyUserRepository(session),
|
||||||
|
cipher=get_subsonic_cipher(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
||||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||||
|
SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)]
|
||||||
|
|
||||||
|
|
||||||
# -- file storage (process-cached) ---------------------------------------------
|
# -- file storage (process-cached) ---------------------------------------------
|
||||||
@@ -97,6 +121,7 @@ def get_upload_service(session: SessionDep, storage: FileStorageDep) -> UploadSe
|
|||||||
artists=SqlAlchemyArtistRepository(session),
|
artists=SqlAlchemyArtistRepository(session),
|
||||||
storage=storage,
|
storage=storage,
|
||||||
tmp_dir=settings.upload_tmp_dir,
|
tmp_dir=settings.upload_tmp_dir,
|
||||||
|
enqueue_enrich=enqueue_enrich,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,3 +212,29 @@ async def get_streaming_user(
|
|||||||
|
|
||||||
|
|
||||||
StreamUser = Annotated[User, Depends(get_streaming_user)]
|
StreamUser = Annotated[User, Depends(get_streaming_user)]
|
||||||
|
|
||||||
|
|
||||||
|
# -- subsonic (/rest) authentication -------------------------------------------
|
||||||
|
# Subsonic puts credentials in the query string: u + (t & s) | p, plus c/v/f.
|
||||||
|
# The dep extracts them and delegates verification to the service; domain errors
|
||||||
|
# propagate to the rest-aware exception handler, which renders the Subsonic
|
||||||
|
# error envelope (HTTP 200). HTTPS is mandatory — the secret rides in the URL.
|
||||||
|
async def get_subsonic_user(
|
||||||
|
service: SubsonicAuthServiceDep,
|
||||||
|
u: Annotated[str | None, Query()] = None,
|
||||||
|
t: Annotated[str | None, Query()] = None,
|
||||||
|
s: Annotated[str | None, Query()] = None,
|
||||||
|
p: Annotated[str | None, Query()] = None,
|
||||||
|
) -> User:
|
||||||
|
return await service.authenticate(username=u, token=t, salt=s, password=p)
|
||||||
|
|
||||||
|
|
||||||
|
SubsonicUser = Annotated[User, Depends(get_subsonic_user)]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subsonic_format(f: Annotated[str | None, Query()] = None) -> str | None:
|
||||||
|
"""The requested response format (``f``): ``xml`` (default) or ``json``."""
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
SubsonicFormat = Annotated[str | None, Depends(get_subsonic_format)]
|
||||||
|
|||||||
+33
-4
@@ -1,8 +1,15 @@
|
|||||||
"""Maps domain exceptions to HTTP responses. The only place that knows both."""
|
"""Maps domain exceptions to HTTP responses. The only place that knows both.
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, status
|
Two surfaces share this mapping: the native ``/api/v1`` API answers with a JSON
|
||||||
|
error body and an HTTP status code, while the Subsonic ``/rest`` layer answers
|
||||||
|
with its own envelope and **always HTTP 200** (the status lives in the body). A
|
||||||
|
request is routed to the Subsonic renderer by path prefix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.rest.envelope import subsonic_error
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.domain.errors import (
|
from app.domain.errors import (
|
||||||
AlreadyExistsError,
|
AlreadyExistsError,
|
||||||
@@ -30,6 +37,21 @@ _STATUS_BY_ERROR: dict[type[DomainError], int] = {
|
|||||||
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Subsonic error codes (subsonic.org/restapi): 10 missing param, 40 wrong
|
||||||
|
# credentials, 50 not authorized, 70 not found, 0 generic.
|
||||||
|
_SUBSONIC_CODE_BY_ERROR: dict[type[DomainError], int] = {
|
||||||
|
ValidationError: 10,
|
||||||
|
AuthenticationError: 40,
|
||||||
|
PermissionDeniedError: 50,
|
||||||
|
NotFoundError: 70,
|
||||||
|
}
|
||||||
|
|
||||||
|
_SUBSONIC_PREFIX = "/rest"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subsonic(request: Request) -> bool:
|
||||||
|
return request.url.path.startswith(_SUBSONIC_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
|
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
|
||||||
return {"error": {"code": code, "message": message}}
|
return {"error": {"code": code, "message": message}}
|
||||||
@@ -45,7 +67,10 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(DomainError)
|
@app.exception_handler(DomainError)
|
||||||
async def _handle_domain_error(_request: Request, exc: DomainError) -> JSONResponse:
|
async def _handle_domain_error(request: Request, exc: DomainError) -> Response:
|
||||||
|
if _is_subsonic(request):
|
||||||
|
code = _SUBSONIC_CODE_BY_ERROR.get(type(exc), 0)
|
||||||
|
return subsonic_error(code, exc.message, fmt=request.query_params.get("f"))
|
||||||
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=http_status,
|
status_code=http_status,
|
||||||
@@ -53,8 +78,12 @@ def register_exception_handlers(app: FastAPI) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def _handle_unexpected(_request: Request, exc: Exception) -> JSONResponse:
|
async def _handle_unexpected(request: Request, exc: Exception) -> Response:
|
||||||
log.error("unhandled_exception", exc_info=exc)
|
log.error("unhandled_exception", exc_info=exc)
|
||||||
|
if _is_subsonic(request):
|
||||||
|
return subsonic_error(
|
||||||
|
0, "An unexpected error occurred.", fmt=request.query_params.get("f")
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content=_error_body("internal_error", "An unexpected error occurred."),
|
content=_error_body("internal_error", "An unexpected error occurred."),
|
||||||
|
|||||||
+89
-11
@@ -1,23 +1,101 @@
|
|||||||
"""Subsonic annotation endpoints: star, rating, scrobble."""
|
"""Subsonic annotation endpoints: star/unstar, rating, scrobble.
|
||||||
|
|
||||||
from typing import Any
|
* ``star``/``unstar`` map to the **append-only** like event-log (a new event per
|
||||||
|
call — never a mutated boolean; CLAUDE.md invariant). Album/artist stars are
|
||||||
|
accepted but not persisted (no album/artist likes yet).
|
||||||
|
* ``scrobble`` appends to play history.
|
||||||
|
* ``setRating`` has no backing store yet — it's accepted as a clean no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
import datetime as dt
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Response
|
||||||
|
|
||||||
|
from app.api.deps import HistoryRepoDep, LikeRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
||||||
|
from app.api.rest.envelope import subsonic_response
|
||||||
|
from app.api.rest.ids import decode_track
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/star")
|
@router.api_route("/star", methods=["GET", "POST"])
|
||||||
async def star() -> Any: ...
|
@router.api_route("/star.view", methods=["GET", "POST"])
|
||||||
|
async def star(
|
||||||
|
user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
like_repo: LikeRepoDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
id: Annotated[list[str] | None, Query()] = None,
|
||||||
|
albumId: Annotated[list[str] | None, Query()] = None,
|
||||||
|
artistId: Annotated[list[str] | None, Query()] = None,
|
||||||
|
) -> Response:
|
||||||
|
# albumId/artistId are accepted for client compatibility but not persisted.
|
||||||
|
for raw in id or []:
|
||||||
|
track_id = decode_track(raw)
|
||||||
|
if await track_repo.get_by_id(track_id) is None:
|
||||||
|
raise NotFoundError("Song not found.")
|
||||||
|
await like_repo.add(user_id=user.id, track_id=track_id, value="like")
|
||||||
|
return subsonic_response(fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unstar")
|
@router.api_route("/unstar", methods=["GET", "POST"])
|
||||||
async def unstar() -> Any: ...
|
@router.api_route("/unstar.view", methods=["GET", "POST"])
|
||||||
|
async def unstar(
|
||||||
|
user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
like_repo: LikeRepoDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
id: Annotated[list[str] | None, Query()] = None,
|
||||||
|
albumId: Annotated[list[str] | None, Query()] = None,
|
||||||
|
artistId: Annotated[list[str] | None, Query()] = None,
|
||||||
|
) -> Response:
|
||||||
|
for raw in id or []:
|
||||||
|
track_id = decode_track(raw)
|
||||||
|
if await track_repo.get_by_id(track_id) is None:
|
||||||
|
raise NotFoundError("Song not found.")
|
||||||
|
await like_repo.add(user_id=user.id, track_id=track_id, value="neutral")
|
||||||
|
return subsonic_response(fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/setRating")
|
@router.api_route("/setRating", methods=["GET", "POST"])
|
||||||
async def set_rating() -> Any: ...
|
@router.api_route("/setRating.view", methods=["GET", "POST"])
|
||||||
|
async def set_rating(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
rating: Annotated[int, Query(ge=0, le=5)],
|
||||||
|
) -> Response:
|
||||||
|
# No rating store yet — accept cleanly so clients don't error.
|
||||||
|
return subsonic_response(fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scrobble")
|
@router.api_route("/scrobble", methods=["GET", "POST"])
|
||||||
async def scrobble() -> Any: ...
|
@router.api_route("/scrobble.view", methods=["GET", "POST"])
|
||||||
|
async def scrobble(
|
||||||
|
user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
history_repo: HistoryRepoDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
id: Annotated[list[str] | None, Query()] = None,
|
||||||
|
time: Annotated[list[int] | None, Query()] = None,
|
||||||
|
submission: Annotated[bool, Query()] = True,
|
||||||
|
) -> Response:
|
||||||
|
times = time or []
|
||||||
|
for index, raw in enumerate(id or []):
|
||||||
|
track_id = decode_track(raw)
|
||||||
|
if await track_repo.get_by_id(track_id) is None:
|
||||||
|
raise NotFoundError("Song not found.")
|
||||||
|
if index < len(times):
|
||||||
|
played_at = dt.datetime.fromtimestamp(times[index] / 1000, tz=dt.UTC)
|
||||||
|
else:
|
||||||
|
played_at = dt.datetime.now(dt.UTC)
|
||||||
|
await history_repo.add(
|
||||||
|
user_id=user.id,
|
||||||
|
track_id=track_id,
|
||||||
|
played_at=played_at,
|
||||||
|
play_duration_seconds=None,
|
||||||
|
completed=submission,
|
||||||
|
)
|
||||||
|
return subsonic_response(fmt=fmt)
|
||||||
|
|||||||
+254
-24
@@ -1,47 +1,277 @@
|
|||||||
"""Subsonic browsing endpoints."""
|
"""Subsonic browsing endpoints — thin adapters over the library repositories.
|
||||||
|
|
||||||
from typing import Any
|
A single synthetic music folder (id ``0``) is exposed; this is a homelab, not a
|
||||||
|
multi-library server. Heavy lifting stays in the repositories; these handlers
|
||||||
|
only fan out queries and reshape rows into the Subsonic element dicts.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Response
|
||||||
|
|
||||||
|
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
||||||
|
from app.api.rest.envelope import subsonic_response
|
||||||
|
from app.api.rest.ids import IdKind, encode_album, encode_artist, parse
|
||||||
|
from app.api.rest.serializers import album_dict, artist_dict, iso, song_dict
|
||||||
|
from app.domain.entities import Album, Artist
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_IGNORED_ARTICLES = "The El La Los Las Le Les"
|
||||||
@router.get("/getMusicFolders")
|
_MAX_ARTISTS = 10_000 # homelab scale; one pass is fine
|
||||||
async def get_music_folders() -> Any: ...
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getIndexes")
|
async def _artists_index(artist_repo: ArtistRepoDep) -> list[dict[str, Any]]:
|
||||||
async def get_indexes() -> Any: ...
|
"""Group artists into Subsonic A-Z index buckets, each with an album count."""
|
||||||
|
artists = await artist_repo.list(q=None, limit=_MAX_ARTISTS, offset=0)
|
||||||
|
buckets: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for artist in artists:
|
||||||
|
album_count = await artist_repo.album_count(artist.id)
|
||||||
|
letter = artist.name[:1].upper()
|
||||||
|
if not letter.isalpha():
|
||||||
|
letter = "#"
|
||||||
|
buckets.setdefault(letter, []).append(artist_dict(artist, album_count=album_count))
|
||||||
|
return [{"name": name, "artist": buckets[name]} for name in sorted(buckets)]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getMusicDirectory")
|
async def _albums_for_artist(artist: Artist, album_repo: AlbumRepoDep) -> list[dict[str, Any]]:
|
||||||
async def get_music_directory() -> Any: ...
|
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
|
||||||
|
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||||
|
return [album_dict(a, artist, song_count=counts.get(a.id, 0)) for a in albums]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getArtists")
|
@router.api_route("/getMusicFolders", methods=["GET", "POST"])
|
||||||
async def get_artists() -> Any: ...
|
@router.api_route("/getMusicFolders.view", methods=["GET", "POST"])
|
||||||
|
async def get_music_folders(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||||
|
return subsonic_response(
|
||||||
|
{"musicFolders": {"musicFolder": [{"id": 0, "name": "Music"}]}}, fmt=fmt
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getArtist")
|
@router.api_route("/getIndexes", methods=["GET", "POST"])
|
||||||
async def get_artist() -> Any: ...
|
@router.api_route("/getIndexes.view", methods=["GET", "POST"])
|
||||||
|
async def get_indexes(
|
||||||
|
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
|
||||||
|
) -> Response:
|
||||||
|
index = await _artists_index(artist_repo)
|
||||||
|
return subsonic_response(
|
||||||
|
{"indexes": {"ignoredArticles": _IGNORED_ARTICLES, "lastModified": 0, "index": index}},
|
||||||
|
fmt=fmt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getAlbum")
|
@router.api_route("/getArtists", methods=["GET", "POST"])
|
||||||
async def get_album() -> Any: ...
|
@router.api_route("/getArtists.view", methods=["GET", "POST"])
|
||||||
|
async def get_artists(
|
||||||
|
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
|
||||||
|
) -> Response:
|
||||||
|
index = await _artists_index(artist_repo)
|
||||||
|
return subsonic_response(
|
||||||
|
{"artists": {"ignoredArticles": _IGNORED_ARTICLES, "index": index}}, fmt=fmt
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getAlbumList")
|
@router.api_route("/getArtist", methods=["GET", "POST"])
|
||||||
async def get_album_list() -> Any: ...
|
@router.api_route("/getArtist.view", methods=["GET", "POST"])
|
||||||
|
async def get_artist(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> Response:
|
||||||
|
_, artist_id = parse(id)
|
||||||
|
artist = await artist_repo.get_by_id(artist_id)
|
||||||
|
if artist is None:
|
||||||
|
raise NotFoundError("Artist not found.")
|
||||||
|
albums = await _albums_for_artist(artist, album_repo)
|
||||||
|
payload = {
|
||||||
|
**artist_dict(artist, album_count=len(albums)),
|
||||||
|
"album": albums,
|
||||||
|
}
|
||||||
|
return subsonic_response({"artist": payload}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getAlbumList2")
|
@router.api_route("/getAlbum", methods=["GET", "POST"])
|
||||||
async def get_album_list2() -> Any: ...
|
@router.api_route("/getAlbum.view", methods=["GET", "POST"])
|
||||||
|
async def get_album(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> Response:
|
||||||
|
_, album_id = parse(id)
|
||||||
|
album = await album_repo.get_by_id(album_id)
|
||||||
|
if album is None:
|
||||||
|
raise NotFoundError("Album not found.")
|
||||||
|
artist = await artist_repo.get_by_id(album.artist_id)
|
||||||
|
tracks = await track_repo.list(
|
||||||
|
artist_id=None,
|
||||||
|
album_id=album_id,
|
||||||
|
q=None,
|
||||||
|
sort_by="title",
|
||||||
|
order="asc",
|
||||||
|
limit=500,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
duration = sum(t.duration_seconds or 0 for t in tracks)
|
||||||
|
songs = [song_dict(t, artist, album) for t in tracks]
|
||||||
|
payload = {
|
||||||
|
**album_dict(album, artist, song_count=len(songs), duration=duration),
|
||||||
|
"song": songs,
|
||||||
|
}
|
||||||
|
return subsonic_response({"album": payload}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getSong")
|
@router.api_route("/getAlbumList", methods=["GET", "POST"])
|
||||||
async def get_song() -> Any: ...
|
@router.api_route("/getAlbumList.view", methods=["GET", "POST"])
|
||||||
|
async def get_album_list(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
type: Annotated[str, Query()] = "newest",
|
||||||
|
size: Annotated[int, Query(ge=1, le=500)] = 10,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> Response:
|
||||||
|
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
|
||||||
|
return subsonic_response({"albumList": {"album": albums}}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getGenres")
|
@router.api_route("/getAlbumList2", methods=["GET", "POST"])
|
||||||
async def get_genres() -> Any: ...
|
@router.api_route("/getAlbumList2.view", methods=["GET", "POST"])
|
||||||
|
async def get_album_list2(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
type: Annotated[str, Query()] = "newest",
|
||||||
|
size: Annotated[int, Query(ge=1, le=500)] = 10,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> Response:
|
||||||
|
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
|
||||||
|
return subsonic_response({"albumList2": {"album": albums}}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_albums(
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
type_: str,
|
||||||
|
size: int,
|
||||||
|
offset: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if type_ == "alphabeticalByName":
|
||||||
|
sort_by, order = "title", "asc"
|
||||||
|
elif type_ == "random":
|
||||||
|
sort_by, order = "title", "random"
|
||||||
|
else: # newest / recent / frequent → newest (no play stats yet)
|
||||||
|
sort_by, order = "created", "desc"
|
||||||
|
albums = await album_repo.list(
|
||||||
|
artist_id=None, q=None, limit=size, offset=offset, sort_by=sort_by, order=order
|
||||||
|
)
|
||||||
|
return await _decorate_albums(albums, album_repo, artist_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def _decorate_albums(
|
||||||
|
albums: list[Album], album_repo: AlbumRepoDep, artist_repo: ArtistRepoDep
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
artist_ids = list({a.artist_id for a in albums})
|
||||||
|
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||||
|
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||||
|
return [album_dict(a, artists.get(a.artist_id), song_count=counts.get(a.id, 0)) for a in albums]
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/getSong", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/getSong.view", methods=["GET", "POST"])
|
||||||
|
async def get_song(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> Response:
|
||||||
|
_, track_id = parse(id)
|
||||||
|
track = await track_repo.get_by_id(track_id)
|
||||||
|
if track is None:
|
||||||
|
raise NotFoundError("Song not found.")
|
||||||
|
artist = await artist_repo.get_by_id(track.artist_id)
|
||||||
|
album = await album_repo.get_by_id(track.album_id) if track.album_id else None
|
||||||
|
return subsonic_response({"song": song_dict(track, artist, album)}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/getGenres", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/getGenres.view", methods=["GET", "POST"])
|
||||||
|
async def get_genres(
|
||||||
|
_user: SubsonicUser, fmt: SubsonicFormat, track_repo: TrackRepoDep
|
||||||
|
) -> Response:
|
||||||
|
genres = [
|
||||||
|
{"value": name, "songCount": count, "albumCount": 0}
|
||||||
|
for name, count in await track_repo.genres()
|
||||||
|
]
|
||||||
|
return subsonic_response({"genres": {"genre": genres}}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/getMusicDirectory", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/getMusicDirectory.view", methods=["GET", "POST"])
|
||||||
|
async def get_music_directory(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> Response:
|
||||||
|
kind, entity_id = parse(id)
|
||||||
|
if kind is IdKind.ARTIST:
|
||||||
|
artist = await artist_repo.get_by_id(entity_id)
|
||||||
|
if artist is None:
|
||||||
|
raise NotFoundError("Artist not found.")
|
||||||
|
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
|
||||||
|
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||||
|
children = [
|
||||||
|
{
|
||||||
|
"id": encode_album(a.id),
|
||||||
|
"parent": encode_artist(artist.id),
|
||||||
|
"isDir": True,
|
||||||
|
"title": a.title,
|
||||||
|
"name": a.title,
|
||||||
|
"artist": artist.name,
|
||||||
|
"artistId": encode_artist(artist.id),
|
||||||
|
"coverArt": encode_album(a.id),
|
||||||
|
"songCount": counts.get(a.id, 0),
|
||||||
|
"created": iso(a.created_at),
|
||||||
|
"year": a.year,
|
||||||
|
}
|
||||||
|
for a in albums
|
||||||
|
]
|
||||||
|
directory = {"id": id, "name": artist.name, "child": children}
|
||||||
|
return subsonic_response({"directory": directory}, fmt=fmt)
|
||||||
|
|
||||||
|
if kind is IdKind.ALBUM:
|
||||||
|
album = await album_repo.get_by_id(entity_id)
|
||||||
|
if album is None:
|
||||||
|
raise NotFoundError("Album not found.")
|
||||||
|
artist = await artist_repo.get_by_id(album.artist_id)
|
||||||
|
tracks = await track_repo.list(
|
||||||
|
artist_id=None,
|
||||||
|
album_id=album.id,
|
||||||
|
q=None,
|
||||||
|
sort_by="title",
|
||||||
|
order="asc",
|
||||||
|
limit=500,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
children = [song_dict(t, artist, album) for t in tracks]
|
||||||
|
directory = {
|
||||||
|
"id": id,
|
||||||
|
"parent": encode_artist(album.artist_id),
|
||||||
|
"name": album.title,
|
||||||
|
"child": children,
|
||||||
|
}
|
||||||
|
return subsonic_response({"directory": directory}, fmt=fmt)
|
||||||
|
|
||||||
|
raise NotFoundError("Directory not found.")
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""The Subsonic response envelope — one serializer, two wire formats.
|
||||||
|
|
||||||
|
Every Subsonic endpoint answers with a ``<subsonic-response>`` wrapper carrying
|
||||||
|
``status`` / ``version`` / ``type`` / ``serverVersion``, in XML (default) or JSON
|
||||||
|
(``f=json``). All handlers return through :func:`subsonic_response`; errors go
|
||||||
|
through the rest-aware exception handler (see ``app.api.errors``).
|
||||||
|
|
||||||
|
Payload data model (shared by both formats):
|
||||||
|
|
||||||
|
* a scalar value → an XML attribute / a JSON field
|
||||||
|
* a nested dict → a single child element / nested object
|
||||||
|
* a list of dicts → repeated child elements / a JSON array
|
||||||
|
* the key ``"value"`` → element text content (used by e.g. lyrics)
|
||||||
|
|
||||||
|
``None`` values are dropped. Subsonic always replies with **HTTP 200**, even for
|
||||||
|
errors — the status lives inside the envelope — so clients parse the body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from fastapi import Response
|
||||||
|
|
||||||
|
SUBSONIC_API_VERSION = "1.16.1"
|
||||||
|
SERVER_TYPE = "mcma"
|
||||||
|
SERVER_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
_XML_NS = "http://subsonic.org/restapi"
|
||||||
|
_XML_MEDIA_TYPE = "application/xml; charset=utf-8"
|
||||||
|
_JSON_MEDIA_TYPE = "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_json(fmt: str | None) -> bool:
|
||||||
|
return fmt in ("json", "jsonp")
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(value: object) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_xml(parent: ET.Element, data: Mapping[str, Any]) -> None:
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if key == "value":
|
||||||
|
parent.text = _scalar(value)
|
||||||
|
elif isinstance(value, Mapping):
|
||||||
|
_build_xml(ET.SubElement(parent, key), value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
_build_xml(ET.SubElement(parent, key), item)
|
||||||
|
else:
|
||||||
|
parent.set(key, _scalar(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_none(value: Any) -> Any:
|
||||||
|
"""Recursively drop ``None`` values so JSON output matches XML (no empty attrs)."""
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {k: _strip_none(v) for k, v in value.items() if v is not None}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_strip_none(v) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _render(body: Mapping[str, Any], fmt: str | None) -> Response:
|
||||||
|
envelope: dict[str, Any] = {
|
||||||
|
"status": body["status"],
|
||||||
|
"version": SUBSONIC_API_VERSION,
|
||||||
|
"type": SERVER_TYPE,
|
||||||
|
"serverVersion": SERVER_VERSION,
|
||||||
|
"openSubsonic": True,
|
||||||
|
**{k: v for k, v in body.items() if k != "status"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _is_json(fmt):
|
||||||
|
payload = json.dumps({"subsonic-response": _strip_none(envelope)})
|
||||||
|
return Response(content=payload, media_type=_JSON_MEDIA_TYPE)
|
||||||
|
|
||||||
|
root = ET.Element("subsonic-response", {"xmlns": _XML_NS})
|
||||||
|
_build_xml(root, envelope)
|
||||||
|
xml = b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="utf-8")
|
||||||
|
return Response(content=xml, media_type=_XML_MEDIA_TYPE)
|
||||||
|
|
||||||
|
|
||||||
|
def subsonic_response(
|
||||||
|
payload: Mapping[str, Any] | None = None, *, fmt: str | None = None
|
||||||
|
) -> Response:
|
||||||
|
"""A successful ``status="ok"`` envelope wrapping ``payload``."""
|
||||||
|
body: dict[str, Any] = {"status": "ok"}
|
||||||
|
if payload:
|
||||||
|
body.update(payload)
|
||||||
|
return _render(body, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def subsonic_error(code: int, message: str, *, fmt: str | None = None) -> Response:
|
||||||
|
"""A ``status="failed"`` envelope carrying a Subsonic ``<error>``."""
|
||||||
|
body = {"status": "failed", "error": {"code": code, "message": message}}
|
||||||
|
return _render(body, fmt)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Stable, reversible mapping between Subsonic opaque string ids and our UUIDs.
|
||||||
|
|
||||||
|
Subsonic ids are opaque strings; ours are UUIDs. We use a type-prefixed,
|
||||||
|
human-debuggable convention (``tr-<uuid>`` track, ``al-<uuid>`` album,
|
||||||
|
``ar-<uuid>`` artist, ``pl-<uuid>`` playlist). Cover-art ids reuse the entity's
|
||||||
|
own id (an album cover is ``al-<uuid>``, a track cover ``tr-<uuid>``). Centralize
|
||||||
|
encode/decode here so the convention lives in exactly one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class IdKind(StrEnum):
|
||||||
|
TRACK = "tr"
|
||||||
|
ALBUM = "al"
|
||||||
|
ARTIST = "ar"
|
||||||
|
PLAYLIST = "pl"
|
||||||
|
|
||||||
|
|
||||||
|
def encode(kind: IdKind, value: uuid.UUID) -> str:
|
||||||
|
return f"{kind.value}-{value}"
|
||||||
|
|
||||||
|
|
||||||
|
def encode_track(value: uuid.UUID) -> str:
|
||||||
|
return encode(IdKind.TRACK, value)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_album(value: uuid.UUID) -> str:
|
||||||
|
return encode(IdKind.ALBUM, value)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_artist(value: uuid.UUID) -> str:
|
||||||
|
return encode(IdKind.ARTIST, value)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_playlist(value: uuid.UUID) -> str:
|
||||||
|
return encode(IdKind.PLAYLIST, value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(raw: str) -> tuple[IdKind, uuid.UUID]:
|
||||||
|
"""Decode any prefixed id into its kind + UUID. Raises ``NotFoundError`` on a
|
||||||
|
malformed id (an unknown id is, from the client's view, simply not found)."""
|
||||||
|
prefix, _, rest = raw.partition("-")
|
||||||
|
try:
|
||||||
|
kind = IdKind(prefix)
|
||||||
|
value = uuid.UUID(rest)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise NotFoundError(f"Unknown id {raw!r}.") from exc
|
||||||
|
return kind, value
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_as(raw: str, expected: IdKind) -> uuid.UUID:
|
||||||
|
kind, value = parse(raw)
|
||||||
|
if kind is not expected:
|
||||||
|
raise NotFoundError(f"Expected a {expected.name.lower()} id, got {raw!r}.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def decode_track(raw: str) -> uuid.UUID:
|
||||||
|
return _decode_as(raw, IdKind.TRACK)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_album(raw: str) -> uuid.UUID:
|
||||||
|
return _decode_as(raw, IdKind.ALBUM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_artist(raw: str) -> uuid.UUID:
|
||||||
|
return _decode_as(raw, IdKind.ARTIST)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_playlist(raw: str) -> uuid.UUID:
|
||||||
|
return _decode_as(raw, IdKind.PLAYLIST)
|
||||||
+69
-10
@@ -1,19 +1,78 @@
|
|||||||
"""Subsonic media endpoints: stream, download, cover art."""
|
"""Subsonic media endpoints: stream, download, cover art.
|
||||||
|
|
||||||
from typing import Any
|
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
|
||||||
|
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
|
||||||
|
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
|
||||||
|
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
|
||||||
|
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
import base64
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, Query
|
||||||
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
from app.api.deps import StreamingServiceDep, SubsonicUser, TrackRepoDep
|
||||||
|
from app.api.rest.ids import decode_track, parse
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# 1x1 transparent PNG - a graceful placeholder until cover art is wired up.
|
||||||
@router.get("/stream")
|
_PLACEHOLDER_PNG = base64.b64decode(
|
||||||
async def stream() -> Any: ...
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download")
|
@router.api_route("/stream", methods=["GET", "POST"])
|
||||||
async def download() -> Any: ...
|
@router.api_route("/stream.view", methods=["GET", "POST"])
|
||||||
|
async def stream(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
service: StreamingServiceDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
result = await service.open_stream(decode_track(id), range_header)
|
||||||
|
headers = {"Accept-Ranges": "bytes", "Content-Length": str(result.content_length)}
|
||||||
|
status_code = 200
|
||||||
|
if result.is_partial:
|
||||||
|
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
||||||
|
status_code = 206
|
||||||
|
return StreamingResponse(
|
||||||
|
result.stream, status_code=status_code, headers=headers, media_type=result.content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getCoverArt")
|
@router.api_route("/download", methods=["GET", "POST"])
|
||||||
async def get_cover_art() -> Any: ...
|
@router.api_route("/download.view", methods=["GET", "POST"])
|
||||||
|
async def download(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
service: StreamingServiceDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> StreamingResponse:
|
||||||
|
track_id = decode_track(id)
|
||||||
|
track = await track_repo.get_by_id(track_id)
|
||||||
|
if track is None:
|
||||||
|
raise NotFoundError("Song not found.")
|
||||||
|
result = await service.open_stream(track_id, None)
|
||||||
|
filename = f"{track.title}.{track.file_format}"
|
||||||
|
headers = {
|
||||||
|
"Content-Length": str(result.content_length),
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
}
|
||||||
|
return StreamingResponse(result.stream, headers=headers, media_type=result.content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/getCoverArt", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
|
||||||
|
async def get_cover_art(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
size: Annotated[int | None, Query()] = None,
|
||||||
|
) -> Response:
|
||||||
|
# Validate the id shape so clients get a clean error on garbage, then serve a
|
||||||
|
# placeholder. TODO: stream real covers once the cover pipeline exists.
|
||||||
|
parse(id)
|
||||||
|
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
|
||||||
|
|||||||
+168
-13
@@ -1,27 +1,182 @@
|
|||||||
"""Subsonic playlist endpoints."""
|
"""Subsonic playlist endpoints — adapters over the playlist repository.
|
||||||
|
|
||||||
from typing import Any
|
Playlists are private to their owner (no public-playlist concept yet), so every
|
||||||
|
read/write is scoped to the authenticated user. ``createPlaylist`` doubles as a
|
||||||
|
full replace when given a ``playlistId`` (Subsonic overloads it that way).
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Response
|
||||||
|
|
||||||
|
from app.api.deps import (
|
||||||
|
AlbumRepoDep,
|
||||||
|
ArtistRepoDep,
|
||||||
|
PlaylistRepoDep,
|
||||||
|
SubsonicFormat,
|
||||||
|
SubsonicUser,
|
||||||
|
)
|
||||||
|
from app.api.rest.envelope import subsonic_response
|
||||||
|
from app.api.rest.ids import decode_playlist, decode_track, encode_playlist
|
||||||
|
from app.api.rest.serializers import iso, song_dict
|
||||||
|
from app.domain.entities import Playlist, User
|
||||||
|
from app.domain.errors import NotFoundError, PermissionDeniedError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getPlaylists")
|
def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]:
|
||||||
async def get_playlists() -> Any: ...
|
return {
|
||||||
|
"id": encode_playlist(playlist.id),
|
||||||
|
"name": playlist.name,
|
||||||
|
"comment": playlist.description,
|
||||||
|
"owner": owner,
|
||||||
|
"public": False,
|
||||||
|
"songCount": song_count,
|
||||||
|
"created": iso(playlist.created_at),
|
||||||
|
"changed": iso(playlist.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getPlaylist")
|
async def _owned_playlist(
|
||||||
async def get_playlist() -> Any: ...
|
playlist_id_raw: str, playlist_repo: PlaylistRepoDep, user: User
|
||||||
|
) -> Playlist:
|
||||||
|
playlist = await playlist_repo.get_by_id(decode_playlist(playlist_id_raw))
|
||||||
|
if playlist is None:
|
||||||
|
raise NotFoundError("Playlist not found.")
|
||||||
|
if playlist.owner_id != user.id:
|
||||||
|
raise PermissionDeniedError("You don't own this playlist.")
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
|
||||||
@router.get("/createPlaylist")
|
async def _playlist_songs(
|
||||||
async def create_playlist() -> Any: ...
|
playlist_id: str,
|
||||||
|
playlist_repo: PlaylistRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
tracks = await playlist_repo.get_tracks(decode_playlist(playlist_id), limit=10_000, offset=0)
|
||||||
|
artist_map = {a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))}
|
||||||
|
album_map = {
|
||||||
|
a.id: a
|
||||||
|
for a in await album_repo.get_many(
|
||||||
|
list({t.album_id for t in tracks if t.album_id is not None})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
song_dict(
|
||||||
|
t,
|
||||||
|
artist_map.get(t.artist_id),
|
||||||
|
album_map.get(t.album_id) if t.album_id is not None else None,
|
||||||
|
)
|
||||||
|
for t in tracks
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/updatePlaylist")
|
@router.api_route("/getPlaylists", methods=["GET", "POST"])
|
||||||
async def update_playlist() -> Any: ...
|
@router.api_route("/getPlaylists.view", methods=["GET", "POST"])
|
||||||
|
async def get_playlists(
|
||||||
|
user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep
|
||||||
|
) -> Response:
|
||||||
|
playlists = await playlist_repo.list(owner_id=user.id, limit=500, offset=0)
|
||||||
|
counts = await playlist_repo.track_count_many([p.id for p in playlists])
|
||||||
|
items = [_playlist_dict(p, user.username, song_count=counts.get(p.id, 0)) for p in playlists]
|
||||||
|
return subsonic_response({"playlists": {"playlist": items}}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/deletePlaylist")
|
@router.api_route("/getPlaylist", methods=["GET", "POST"])
|
||||||
async def delete_playlist() -> Any: ...
|
@router.api_route("/getPlaylist.view", methods=["GET", "POST"])
|
||||||
|
async def get_playlist(
|
||||||
|
user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
playlist_repo: PlaylistRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> Response:
|
||||||
|
playlist = await _owned_playlist(id, playlist_repo, user)
|
||||||
|
songs = await _playlist_songs(id, playlist_repo, artist_repo, album_repo)
|
||||||
|
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
|
||||||
|
return subsonic_response({"playlist": payload}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/createPlaylist", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/createPlaylist.view", methods=["GET", "POST"])
|
||||||
|
async def create_playlist(
|
||||||
|
user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
playlist_repo: PlaylistRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
name: Annotated[str | None, Query()] = None,
|
||||||
|
playlistId: Annotated[str | None, Query()] = None,
|
||||||
|
songId: Annotated[list[str] | None, Query()] = None,
|
||||||
|
) -> Response:
|
||||||
|
song_ids = [decode_track(s) for s in (songId or [])]
|
||||||
|
|
||||||
|
if playlistId is not None:
|
||||||
|
# Overloaded form: replace the existing playlist's tracks (and name).
|
||||||
|
playlist = await _owned_playlist(playlistId, playlist_repo, user)
|
||||||
|
if name is not None:
|
||||||
|
playlist = await playlist_repo.update(playlist.id, name=name, description=None)
|
||||||
|
existing = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
|
||||||
|
for t in existing:
|
||||||
|
await playlist_repo.remove_track(playlist.id, t.id)
|
||||||
|
else:
|
||||||
|
playlist = await playlist_repo.add(
|
||||||
|
name=name or "Untitled", description=None, owner_id=user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
for position, track_id in enumerate(song_ids, start=1):
|
||||||
|
await playlist_repo.add_track(playlist.id, track_id, position=float(position))
|
||||||
|
|
||||||
|
encoded = encode_playlist(playlist.id)
|
||||||
|
songs = await _playlist_songs(encoded, playlist_repo, artist_repo, album_repo)
|
||||||
|
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
|
||||||
|
return subsonic_response({"playlist": payload}, fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/updatePlaylist", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/updatePlaylist.view", methods=["GET", "POST"])
|
||||||
|
async def update_playlist(
|
||||||
|
user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
playlist_repo: PlaylistRepoDep,
|
||||||
|
playlistId: Annotated[str, Query()],
|
||||||
|
name: Annotated[str | None, Query()] = None,
|
||||||
|
comment: Annotated[str | None, Query()] = None,
|
||||||
|
songIdToAdd: Annotated[list[str] | None, Query()] = None,
|
||||||
|
songIndexToRemove: Annotated[list[int] | None, Query()] = None,
|
||||||
|
) -> Response:
|
||||||
|
playlist = await _owned_playlist(playlistId, playlist_repo, user)
|
||||||
|
|
||||||
|
if name is not None or comment is not None:
|
||||||
|
playlist = await playlist_repo.update(playlist.id, name=name, description=comment)
|
||||||
|
|
||||||
|
# Removals are by index into the current ordered track list — resolve first.
|
||||||
|
if songIndexToRemove:
|
||||||
|
current = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
|
||||||
|
for index in sorted(set(songIndexToRemove)):
|
||||||
|
if 0 <= index < len(current):
|
||||||
|
await playlist_repo.remove_track(playlist.id, current[index].id)
|
||||||
|
|
||||||
|
if songIdToAdd:
|
||||||
|
position = await playlist_repo.max_position(playlist.id)
|
||||||
|
for raw in songIdToAdd:
|
||||||
|
position += 1.0
|
||||||
|
await playlist_repo.add_track(playlist.id, decode_track(raw), position=position)
|
||||||
|
|
||||||
|
return subsonic_response(fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/deletePlaylist", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/deletePlaylist.view", methods=["GET", "POST"])
|
||||||
|
async def delete_playlist(
|
||||||
|
user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
playlist_repo: PlaylistRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> Response:
|
||||||
|
playlist = await _owned_playlist(id, playlist_repo, user)
|
||||||
|
await playlist_repo.delete(playlist.id)
|
||||||
|
return subsonic_response(fmt=fmt)
|
||||||
|
|||||||
+80
-5
@@ -1,11 +1,86 @@
|
|||||||
"""Subsonic search endpoints."""
|
"""Subsonic search endpoints — search3 over the library repositories.
|
||||||
|
|
||||||
from typing import Any
|
Mirrors the native ``/api/v1/search/library`` fan-out (tracks/albums/artists),
|
||||||
|
reshaped into the Subsonic ``searchResult3`` element. An empty query returns
|
||||||
|
results so clients can use search3 to browse.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Response
|
||||||
|
|
||||||
|
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
||||||
|
from app.api.rest.envelope import subsonic_response
|
||||||
|
from app.api.rest.serializers import album_dict, artist_dict, song_dict
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search3")
|
@router.api_route("/search3", methods=["GET", "POST"])
|
||||||
async def search3() -> Any: ...
|
@router.api_route("/search3.view", methods=["GET", "POST"])
|
||||||
|
async def search3(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
query: Annotated[str, Query()] = "",
|
||||||
|
artistCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
||||||
|
artistOffset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
albumCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
||||||
|
albumOffset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
songCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
||||||
|
songOffset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> Response:
|
||||||
|
# Subsonic sends "" (and some clients '""') to mean "everything".
|
||||||
|
q: str | None = query.strip().strip('"') or None
|
||||||
|
|
||||||
|
artists_out: list[dict[str, Any]] = []
|
||||||
|
if artistCount:
|
||||||
|
artists = await artist_repo.list(q=q, limit=artistCount, offset=artistOffset)
|
||||||
|
for a in artists:
|
||||||
|
album_count = await artist_repo.album_count(a.id)
|
||||||
|
artists_out.append(artist_dict(a, album_count=album_count))
|
||||||
|
|
||||||
|
albums_out: list[dict[str, Any]] = []
|
||||||
|
if albumCount:
|
||||||
|
albums = await album_repo.list(artist_id=None, q=q, limit=albumCount, offset=albumOffset)
|
||||||
|
album_artist_ids = list({a.artist_id for a in albums})
|
||||||
|
album_artist_map = {a.id: a for a in await artist_repo.get_many(album_artist_ids)}
|
||||||
|
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||||
|
albums_out = [
|
||||||
|
album_dict(a, album_artist_map.get(a.artist_id), song_count=counts.get(a.id, 0))
|
||||||
|
for a in albums
|
||||||
|
]
|
||||||
|
|
||||||
|
songs_out: list[dict[str, Any]] = []
|
||||||
|
if songCount:
|
||||||
|
tracks = await track_repo.list(
|
||||||
|
artist_id=None,
|
||||||
|
album_id=None,
|
||||||
|
q=q,
|
||||||
|
sort_by="title",
|
||||||
|
order="asc",
|
||||||
|
limit=songCount,
|
||||||
|
offset=songOffset,
|
||||||
|
)
|
||||||
|
song_artist_map = {
|
||||||
|
a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))
|
||||||
|
}
|
||||||
|
song_album_map = {
|
||||||
|
a.id: a
|
||||||
|
for a in await album_repo.get_many(
|
||||||
|
list({t.album_id for t in tracks if t.album_id is not None})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
songs_out = [
|
||||||
|
song_dict(
|
||||||
|
t,
|
||||||
|
song_artist_map.get(t.artist_id),
|
||||||
|
song_album_map.get(t.album_id) if t.album_id is not None else None,
|
||||||
|
)
|
||||||
|
for t in tracks
|
||||||
|
]
|
||||||
|
|
||||||
|
payload = {"searchResult3": {"artist": artists_out, "album": albums_out, "song": songs_out}}
|
||||||
|
return subsonic_response(payload, fmt=fmt)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Entity → Subsonic child-dict mappers (presentation only).
|
||||||
|
|
||||||
|
Pure functions turning domain entities into the attribute dicts the envelope
|
||||||
|
serializer renders as ``<artist>`` / ``<album>`` / ``<song>`` elements (or their
|
||||||
|
JSON equivalents). No business logic — they only reshape and rename.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.rest.ids import encode_album, encode_artist, encode_track
|
||||||
|
from app.domain.entities import Album, Artist, Track
|
||||||
|
|
||||||
|
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
|
||||||
|
# presentation detail (mirrors StreamingService's content-type negotiation).
|
||||||
|
_CONTENT_TYPE: dict[str, str] = {
|
||||||
|
"mp3": "audio/mpeg",
|
||||||
|
"flac": "audio/flac",
|
||||||
|
"m4a": "audio/mp4",
|
||||||
|
"aac": "audio/aac",
|
||||||
|
"ogg": "audio/ogg",
|
||||||
|
"opus": "audio/ogg",
|
||||||
|
"wav": "audio/wav",
|
||||||
|
"aiff": "audio/aiff",
|
||||||
|
"aif": "audio/aiff",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iso(value: dt.datetime) -> str:
|
||||||
|
return value.astimezone(dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
|
||||||
|
|
||||||
|
def content_type_for(file_format: str) -> str:
|
||||||
|
return _CONTENT_TYPE.get(file_format.lower(), "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def artist_dict(artist: Artist, *, album_count: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": encode_artist(artist.id),
|
||||||
|
"name": artist.name,
|
||||||
|
"albumCount": album_count,
|
||||||
|
"coverArt": encode_artist(artist.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def album_dict(
|
||||||
|
album: Album,
|
||||||
|
artist: Artist | None,
|
||||||
|
*,
|
||||||
|
song_count: int,
|
||||||
|
duration: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": encode_album(album.id),
|
||||||
|
"name": album.title,
|
||||||
|
"title": album.title,
|
||||||
|
"artist": artist.name if artist is not None else None,
|
||||||
|
"artistId": encode_artist(album.artist_id),
|
||||||
|
"coverArt": encode_album(album.id),
|
||||||
|
"songCount": song_count,
|
||||||
|
"duration": duration,
|
||||||
|
"created": iso(album.created_at),
|
||||||
|
"year": album.year,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def song_dict(
|
||||||
|
track: Track,
|
||||||
|
artist: Artist | None,
|
||||||
|
album: Album | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cover = encode_album(track.album_id) if track.album_id is not None else encode_track(track.id)
|
||||||
|
return {
|
||||||
|
"id": encode_track(track.id),
|
||||||
|
"parent": encode_album(track.album_id) if track.album_id is not None else None,
|
||||||
|
"isDir": False,
|
||||||
|
"title": track.title,
|
||||||
|
"album": album.title if album is not None else None,
|
||||||
|
"artist": artist.name if artist is not None else None,
|
||||||
|
"albumId": encode_album(track.album_id) if track.album_id is not None else None,
|
||||||
|
"artistId": encode_artist(track.artist_id),
|
||||||
|
"coverArt": cover,
|
||||||
|
"size": track.file_size,
|
||||||
|
"contentType": content_type_for(track.file_format),
|
||||||
|
"suffix": track.file_format,
|
||||||
|
"duration": track.duration_seconds,
|
||||||
|
"year": track.year,
|
||||||
|
"genre": track.genre,
|
||||||
|
"created": iso(track.created_at),
|
||||||
|
"type": "music",
|
||||||
|
"isVideo": False,
|
||||||
|
}
|
||||||
+13
-6
@@ -1,15 +1,22 @@
|
|||||||
"""Subsonic system endpoints: ping and license."""
|
"""Subsonic system endpoints: ping and license."""
|
||||||
|
|
||||||
from typing import Any
|
from fastapi import APIRouter, Response
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from app.api.deps import SubsonicFormat, SubsonicUser
|
||||||
|
from app.api.rest.envelope import subsonic_response
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ping")
|
@router.api_route("/ping", methods=["GET", "POST"])
|
||||||
async def ping() -> Any: ...
|
@router.api_route("/ping.view", methods=["GET", "POST"])
|
||||||
|
async def ping(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||||
|
# Requiring auth makes ping a credential check — exactly how clients use it.
|
||||||
|
return subsonic_response(fmt=fmt)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/getLicense")
|
@router.api_route("/getLicense", methods=["GET", "POST"])
|
||||||
async def get_license() -> Any: ...
|
@router.api_route("/getLicense.view", methods=["GET", "POST"])
|
||||||
|
async def get_license(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||||
|
# Self-hosted and free — the license is always valid.
|
||||||
|
return subsonic_response({"license": {"valid": True}}, fmt=fmt)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Schemas for the source endpoints."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.domain.sources import SourceInfo
|
||||||
|
|
||||||
|
|
||||||
|
class SourceInfoOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
kind: str
|
||||||
|
available: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_entity(cls, info: SourceInfo) -> SourceInfoOut:
|
||||||
|
return cls(name=info.name, label=info.label, kind=info.kind, available=info.available)
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResponse(BaseModel):
|
||||||
|
"""Result of enqueuing a source scan."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
job_id: str
|
||||||
|
status: str = "queued"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceHealthOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
available: bool
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Schemas for Subsonic app-password self-service (native /api/v1 surface).
|
||||||
|
|
||||||
|
The Subsonic /rest layer itself returns its own XML/JSON envelope, not these
|
||||||
|
pydantic models — these only back the lifecycle endpoints that reveal/rotate the
|
||||||
|
recoverable app-password."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicPasswordResponse(BaseModel):
|
||||||
|
"""The plaintext Subsonic app-password, for pasting into a client."""
|
||||||
|
|
||||||
|
password: str
|
||||||
+10
-1
@@ -9,7 +9,8 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query, status
|
from fastapi import APIRouter, Query, status
|
||||||
|
|
||||||
from app.api.deps import SuperUser, UserServiceDep
|
from app.api.deps import SubsonicAuthServiceDep, SuperUser, UserServiceDep
|
||||||
|
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||||
from app.api.schemas.user import (
|
from app.api.schemas.user import (
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
ResetPasswordRequest,
|
ResetPasswordRequest,
|
||||||
@@ -81,6 +82,14 @@ async def deactivate_user(
|
|||||||
return UserResponse.from_entity(await users.deactivate(user_id))
|
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")
|
@router.get("/services")
|
||||||
async def list_services(_admin: SuperUser) -> Any: ...
|
async def list_services(_admin: SuperUser) -> Any: ...
|
||||||
|
|
||||||
|
|||||||
+30
-5
@@ -1,19 +1,44 @@
|
|||||||
"""External source endpoints (yt-dlp etc.)."""
|
"""External source endpoints: enumerate sources and trigger imports.
|
||||||
|
|
||||||
|
Listing/health are read-only (any authenticated user). Scanning a source is an
|
||||||
|
admin action and runs in a worker — the endpoint only enqueues it.
|
||||||
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser
|
||||||
|
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
|
||||||
|
from app.domain.errors import DependencyUnavailableError
|
||||||
|
from app.workers.queue import enqueue
|
||||||
|
|
||||||
router = APIRouter(prefix="/sources", tags=["sources"])
|
router = APIRouter(prefix="/sources", tags=["sources"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_sources() -> Any: ...
|
async def list_sources(_: CurrentUser, registry: SourceRegistryDep) -> list[SourceInfoOut]:
|
||||||
|
return [SourceInfoOut.from_entity(info) for info in registry.infos()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{source}/search")
|
@router.post("/{source}/scan")
|
||||||
async def search_source(source: str) -> Any: ...
|
async def scan_source(source: str, user: SuperUser, registry: SourceRegistryDep) -> ScanResponse:
|
||||||
|
backend = registry.indexable(source) # 404 if unknown, 422 if not indexable
|
||||||
|
if not backend.is_available():
|
||||||
|
raise DependencyUnavailableError(f"Source {source!r} is not available.")
|
||||||
|
job_id = await enqueue("scan_local_folder", source=source, added_by=str(user.id))
|
||||||
|
return ScanResponse(source=source, job_id=job_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{source}/health")
|
@router.get("/{source}/health")
|
||||||
async def source_health(source: str) -> Any: ...
|
async def source_health(
|
||||||
|
source: str, _: CurrentUser, registry: SourceRegistryDep
|
||||||
|
) -> SourceHealthOut:
|
||||||
|
backend = registry.get(source) # 404 if unknown
|
||||||
|
return SourceHealthOut(name=backend.name, available=backend.is_available())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{source}/search")
|
||||||
|
async def search_source(source: str, _: CurrentUser) -> Any:
|
||||||
|
# Search is for fetch-style sources (youtube, …) — not yet implemented.
|
||||||
|
...
|
||||||
|
|||||||
+13
-1
@@ -11,6 +11,7 @@ from app.api.schemas.track import TrackOut, TrackUpdate
|
|||||||
from app.domain.entities.album import Album
|
from app.domain.entities.album import Album
|
||||||
from app.domain.entities.track import Artist, Track
|
from app.domain.entities.track import Artist, Track
|
||||||
from app.domain.errors import NotFoundError
|
from app.domain.errors import NotFoundError
|
||||||
|
from app.workers.queue import enqueue
|
||||||
|
|
||||||
router = APIRouter(prefix="/tracks", tags=["tracks"])
|
router = APIRouter(prefix="/tracks", tags=["tracks"])
|
||||||
|
|
||||||
@@ -147,7 +148,18 @@ async def get_track_cover(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{track_id}/metadata/enrich")
|
@router.post("/{track_id}/metadata/enrich")
|
||||||
async def enrich_metadata(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
async def enrich_metadata(
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
_: CurrentUser,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Re-run metadata enrichment for a track (admin/user-triggered). The work
|
||||||
|
happens in a worker; this only enqueues it. 503 if the queue is down."""
|
||||||
|
track = await track_repo.get_by_id(track_id)
|
||||||
|
if track is None:
|
||||||
|
raise NotFoundError(f"Track {track_id} not found.")
|
||||||
|
job_id = await enqueue("enrich_track", track_id=str(track_id))
|
||||||
|
return {"track_id": str(track_id), "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_id}/metadata/matches")
|
@router.get("/{track_id}/metadata/matches")
|
||||||
|
|||||||
+21
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, UserServiceDep
|
from app.api.deps import CurrentUser, SubsonicAuthServiceDep, UserServiceDep
|
||||||
|
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||||
from app.api.schemas.user import ChangePasswordRequest
|
from app.api.schemas.user import ChangePasswordRequest
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
@@ -17,3 +18,22 @@ async def change_my_password(
|
|||||||
current_password=body.current_password,
|
current_password=body.current_password,
|
||||||
new_password=body.new_password,
|
new_password=body.new_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||||
|
async def reveal_my_subsonic_password(
|
||||||
|
user: CurrentUser, subsonic: SubsonicAuthServiceDep
|
||||||
|
) -> SubsonicPasswordResponse:
|
||||||
|
"""Reveal the caller's Subsonic app-password for copying into a client.
|
||||||
|
|
||||||
|
It's recoverable, so it can be read on demand; one is generated lazily on
|
||||||
|
first access. Paste it (with the username) into Symfonium/DSub."""
|
||||||
|
return SubsonicPasswordResponse(password=await subsonic.reveal(user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||||
|
async def rotate_my_subsonic_password(
|
||||||
|
user: CurrentUser, subsonic: SubsonicAuthServiceDep
|
||||||
|
) -> SubsonicPasswordResponse:
|
||||||
|
"""Rotate the caller's Subsonic app-password (invalidates the previous one)."""
|
||||||
|
return SubsonicPasswordResponse(password=await subsonic.rotate(user.id))
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""LibraryImportService — imports files discovered by an indexable source.
|
||||||
|
|
||||||
|
Batch sibling of :class:`UploadService`: for each discovered file it dedups on
|
||||||
|
``(source, source_id)``, copies the file into managed storage, creates a minimal
|
||||||
|
track (artist ``Unknown Artist``, ``metadata_status=pending``), and leaves the
|
||||||
|
rest to enrichment (plan §6.2). Per-file failures are isolated — one bad file
|
||||||
|
must not abort the whole scan (graceful degradation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.ports import ArtistRepository, FileStorage, IndexableSource, TrackRepository
|
||||||
|
from app.domain.sources import SourceFile
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_UNKNOWN_ARTIST = "Unknown Artist"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImportSummary:
|
||||||
|
source: str
|
||||||
|
seen: int
|
||||||
|
imported: int
|
||||||
|
skipped: int
|
||||||
|
failed: int
|
||||||
|
# IDs of freshly imported tracks, for the caller to enqueue enrichment
|
||||||
|
# *after* its transaction commits (enqueuing mid-scan would race the worker).
|
||||||
|
imported_ids: list[uuid.UUID] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryImportService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tracks: TrackRepository,
|
||||||
|
artists: ArtistRepository,
|
||||||
|
storage: FileStorage,
|
||||||
|
) -> None:
|
||||||
|
self._tracks = tracks
|
||||||
|
self._artists = artists
|
||||||
|
self._storage = storage
|
||||||
|
|
||||||
|
async def scan_and_import(
|
||||||
|
self, source: IndexableSource, *, added_by: uuid.UUID | None
|
||||||
|
) -> ImportSummary:
|
||||||
|
seen = skipped = failed = 0
|
||||||
|
imported_ids: list[uuid.UUID] = []
|
||||||
|
for file in source.scan():
|
||||||
|
seen += 1
|
||||||
|
try:
|
||||||
|
existing = await self._tracks.get_by_source(source.name, file.source_id)
|
||||||
|
if existing is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
track_id = await self._import_one(source.name, file, added_by)
|
||||||
|
imported_ids.append(track_id)
|
||||||
|
except Exception:
|
||||||
|
failed += 1
|
||||||
|
log.warning("import_file_failed", source=source.name, source_id=file.source_id)
|
||||||
|
summary = ImportSummary(
|
||||||
|
source=source.name,
|
||||||
|
seen=seen,
|
||||||
|
imported=len(imported_ids),
|
||||||
|
skipped=skipped,
|
||||||
|
failed=failed,
|
||||||
|
imported_ids=imported_ids,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"import_complete",
|
||||||
|
source=summary.source,
|
||||||
|
seen=summary.seen,
|
||||||
|
imported=summary.imported,
|
||||||
|
skipped=summary.skipped,
|
||||||
|
failed=summary.failed,
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
async def _import_one(
|
||||||
|
self, source_name: str, file: SourceFile, added_by: uuid.UUID | None
|
||||||
|
) -> uuid.UUID:
|
||||||
|
track_id = uuid.uuid4()
|
||||||
|
key = f"tracks/{str(track_id)[:2]}/{track_id}.{file.file_format}"
|
||||||
|
await self._storage.save_file(key, file.path)
|
||||||
|
try:
|
||||||
|
artist = await self._artists.get_or_create(_UNKNOWN_ARTIST)
|
||||||
|
await self._tracks.add(
|
||||||
|
id=track_id,
|
||||||
|
title=file.suggested_title,
|
||||||
|
artist_id=artist.id,
|
||||||
|
storage_uri=key,
|
||||||
|
file_format=file.file_format,
|
||||||
|
file_size=file.file_size,
|
||||||
|
source=source_name,
|
||||||
|
source_id=file.source_id,
|
||||||
|
metadata_status="pending",
|
||||||
|
added_by=added_by,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self._storage.delete(key)
|
||||||
|
raise
|
||||||
|
return track_id
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""MetadataEnrichmentService — the §6.2 pipeline orchestrator.
|
||||||
|
|
||||||
|
Order (tag-first): embedded tags → Chromaprint fingerprint → AcoustID lookup.
|
||||||
|
Tags fix the common well-tagged case offline; AcoustID identifies the rest and
|
||||||
|
supplies a MusicBrainz id. The result updates the track and sets
|
||||||
|
``metadata_status`` to ``enriched`` (identity found) or ``failed`` (nothing).
|
||||||
|
|
||||||
|
Invariants (plan §6.2, CLAUDE.md):
|
||||||
|
- **Never touch ``manual``** — a user-edited track is returned untouched.
|
||||||
|
- **Graceful degradation** — every external step is wrapped; one failure (no
|
||||||
|
fpcalc, no API key, service down) degrades the result, never crashes.
|
||||||
|
- **Idempotent** — re-running only fills gaps; ``apply_enrichment`` never erases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.entities.metadata import AudioTags, RecordingMatch
|
||||||
|
from app.domain.ports import (
|
||||||
|
AcoustIdClient,
|
||||||
|
AlbumRepository,
|
||||||
|
ArtistRepository,
|
||||||
|
AudioFingerprinter,
|
||||||
|
AudioTagReader,
|
||||||
|
FileStorage,
|
||||||
|
TrackRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_UNKNOWN_ARTIST = "Unknown Artist"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnrichmentResult:
|
||||||
|
track_id: uuid.UUID
|
||||||
|
status: str # "enriched" | "failed" | "skipped"
|
||||||
|
matched_mbid: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataEnrichmentService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tracks: TrackRepository,
|
||||||
|
artists: ArtistRepository,
|
||||||
|
albums: AlbumRepository,
|
||||||
|
storage: FileStorage,
|
||||||
|
tag_reader: AudioTagReader,
|
||||||
|
fingerprinter: AudioFingerprinter,
|
||||||
|
acoustid: AcoustIdClient,
|
||||||
|
) -> None:
|
||||||
|
self._tracks = tracks
|
||||||
|
self._artists = artists
|
||||||
|
self._albums = albums
|
||||||
|
self._storage = storage
|
||||||
|
self._tag_reader = tag_reader
|
||||||
|
self._fingerprinter = fingerprinter
|
||||||
|
self._acoustid = acoustid
|
||||||
|
|
||||||
|
async def enrich(self, track_id: uuid.UUID) -> EnrichmentResult:
|
||||||
|
track = await self._tracks.get_by_id(track_id)
|
||||||
|
if track is None:
|
||||||
|
log.info("enrich_track_missing", track_id=str(track_id))
|
||||||
|
return EnrichmentResult(track_id=track_id, status="skipped")
|
||||||
|
if track.metadata_status == "manual":
|
||||||
|
log.info("enrich_skip_manual", track_id=str(track_id))
|
||||||
|
return EnrichmentResult(track_id=track_id, status="skipped")
|
||||||
|
|
||||||
|
tags = await self._read_local(track.storage_uri)
|
||||||
|
match = await self._identify(track.storage_uri)
|
||||||
|
|
||||||
|
# Merge sources: prefer embedded tags, fall back to the AcoustID match.
|
||||||
|
# ``title`` is guaranteed non-None by the existing track title; the rest
|
||||||
|
# stay None when neither source has them.
|
||||||
|
tag_title = tags.title if tags else None
|
||||||
|
tag_artist = tags.artist if tags else None
|
||||||
|
tag_album = tags.album if tags else None
|
||||||
|
title = _opt_str(tag_title, match.title if match else None) or track.title
|
||||||
|
artist_name = _opt_str(tag_artist, match.artist if match else None)
|
||||||
|
album_title = _opt_str(tag_album, match.album if match else None)
|
||||||
|
year = _first_int(tags.year if tags else None, match.year if match else None)
|
||||||
|
genre = tags.genre if tags else None
|
||||||
|
track_number = tags.track_number if tags else None
|
||||||
|
duration = _first_int(
|
||||||
|
tags.duration_seconds if tags else None,
|
||||||
|
track.duration_seconds,
|
||||||
|
)
|
||||||
|
bitrate = tags.bitrate if tags else None
|
||||||
|
mbid = match.recording_mbid if match else None
|
||||||
|
acoustid_id = match.acoustid if match else None
|
||||||
|
|
||||||
|
artist_id = await self._resolve_artist(artist_name, fallback=track.artist_id)
|
||||||
|
album_id = await self._resolve_album(album_title, artist_id=artist_id, year=year, mbid=mbid)
|
||||||
|
|
||||||
|
identified = bool(artist_name) or album_id is not None or mbid is not None
|
||||||
|
status = "enriched" if identified else "failed"
|
||||||
|
|
||||||
|
await self._tracks.apply_enrichment(
|
||||||
|
track_id,
|
||||||
|
title=title,
|
||||||
|
artist_id=artist_id,
|
||||||
|
album_id=album_id,
|
||||||
|
genre=genre,
|
||||||
|
year=year,
|
||||||
|
track_number=track_number,
|
||||||
|
duration_seconds=duration,
|
||||||
|
bitrate=bitrate,
|
||||||
|
acoustid_fingerprint=acoustid_id,
|
||||||
|
musicbrainz_id=mbid,
|
||||||
|
metadata_status=status,
|
||||||
|
)
|
||||||
|
log.info("enrich_complete", track_id=str(track_id), status=status, mbid=mbid)
|
||||||
|
return EnrichmentResult(track_id=track_id, status=status, matched_mbid=mbid)
|
||||||
|
|
||||||
|
async def _read_local(self, storage_uri: str) -> AudioTags | None:
|
||||||
|
try:
|
||||||
|
async with self._storage.as_local_path(storage_uri) as path:
|
||||||
|
return await self._tag_reader.read(path)
|
||||||
|
except Exception:
|
||||||
|
log.warning("enrich_tag_step_failed", storage_uri=storage_uri)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _identify(self, storage_uri: str) -> RecordingMatch | None:
|
||||||
|
if not self._acoustid.is_available() or not self._fingerprinter.is_available():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
async with self._storage.as_local_path(storage_uri) as path:
|
||||||
|
fingerprint = await self._fingerprinter.calculate(path)
|
||||||
|
if fingerprint is None:
|
||||||
|
return None
|
||||||
|
return await self._acoustid.lookup(fingerprint)
|
||||||
|
except Exception:
|
||||||
|
log.warning("enrich_identify_step_failed", storage_uri=storage_uri)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _resolve_artist(self, name: str | None, *, fallback: uuid.UUID) -> uuid.UUID:
|
||||||
|
if not name or name == _UNKNOWN_ARTIST:
|
||||||
|
return fallback
|
||||||
|
artist = await self._artists.get_or_create(name)
|
||||||
|
return artist.id
|
||||||
|
|
||||||
|
async def _resolve_album(
|
||||||
|
self,
|
||||||
|
title: str | None,
|
||||||
|
*,
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
year: int | None,
|
||||||
|
mbid: str | None,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
album = await self._albums.get_or_create(
|
||||||
|
title=title,
|
||||||
|
artist_id=artist_id,
|
||||||
|
year=year,
|
||||||
|
musicbrainz_id=mbid,
|
||||||
|
)
|
||||||
|
return album.id
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_str(*values: str | None) -> str | None:
|
||||||
|
for value in values:
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_int(*values: int | None) -> int | None:
|
||||||
|
for value in values:
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""SubsonicAuthService — app-password lifecycle + Subsonic auth verification.
|
||||||
|
|
||||||
|
The Subsonic protocol authenticates with either ``t=md5(password+salt)`` (+``s``)
|
||||||
|
or the legacy ``p=`` (plaintext or ``enc:<hex>``). Both need a *recoverable*
|
||||||
|
secret server-side, so Subsonic clients authenticate against a dedicated,
|
||||||
|
high-entropy app-password — never the argon2 login password. That app-password
|
||||||
|
is encrypted at rest (:class:`~app.domain.ports.SubsonicCipher`) and decrypted
|
||||||
|
only here, to verify a request or to reveal it for copying into a client.
|
||||||
|
|
||||||
|
This is an adapter over the existing user store; it adds no business state of its
|
||||||
|
own beyond the app-password column (CLAUDE.md: Subsonic is an adapter, not a
|
||||||
|
reimplementation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.security import generate_subsonic_password
|
||||||
|
from app.domain.entities import User
|
||||||
|
from app.domain.errors import AuthenticationError, NotFoundError, ValidationError
|
||||||
|
from app.domain.ports import SubsonicCipher, UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _md5_hex(value: str) -> str:
|
||||||
|
return hashlib.md5(value.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_legacy_password(p: str) -> str:
|
||||||
|
"""Decode a Subsonic ``p`` param: ``enc:<hex>`` (hex-encoded) or plaintext."""
|
||||||
|
if p.startswith("enc:"):
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(p[4:]).decode("utf-8")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AuthenticationError("Wrong username or password.") from exc
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicAuthService:
|
||||||
|
def __init__(self, *, users: UserRepository, cipher: SubsonicCipher) -> None:
|
||||||
|
self._users = users
|
||||||
|
self._cipher = cipher
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str | None,
|
||||||
|
token: str | None,
|
||||||
|
salt: str | None,
|
||||||
|
password: str | None,
|
||||||
|
) -> User:
|
||||||
|
"""Resolve Subsonic query auth params to a domain :class:`User`.
|
||||||
|
|
||||||
|
Raises :class:`ValidationError` (Subsonic code 10) for missing params and
|
||||||
|
:class:`AuthenticationError` (code 40) for any credential mismatch — an
|
||||||
|
unknown user is reported identically to a wrong password (no enumeration).
|
||||||
|
"""
|
||||||
|
if not username:
|
||||||
|
raise ValidationError("Required parameter 'u' is missing.")
|
||||||
|
if not ((token and salt) or password):
|
||||||
|
raise ValidationError("Required authentication parameter is missing.")
|
||||||
|
|
||||||
|
creds = await self._users.get_subsonic_credentials_by_username(username)
|
||||||
|
if creds is None or not creds.user.is_active or creds.password_enc is None:
|
||||||
|
raise AuthenticationError("Wrong username or password.")
|
||||||
|
|
||||||
|
app_password = self._cipher.decrypt(creds.password_enc)
|
||||||
|
|
||||||
|
if token and salt:
|
||||||
|
expected = _md5_hex(app_password + salt)
|
||||||
|
if not hmac.compare_digest(expected, token.lower()):
|
||||||
|
raise AuthenticationError("Wrong username or password.")
|
||||||
|
else:
|
||||||
|
assert password is not None # guaranteed by the missing-param check above
|
||||||
|
supplied = _decode_legacy_password(password)
|
||||||
|
if not hmac.compare_digest(supplied, app_password):
|
||||||
|
raise AuthenticationError("Wrong username or password.")
|
||||||
|
|
||||||
|
return creds.user
|
||||||
|
|
||||||
|
async def rotate(self, user_id: uuid.UUID) -> str:
|
||||||
|
"""Generate a fresh app-password, store it encrypted, return the plaintext."""
|
||||||
|
await self._require_user(user_id)
|
||||||
|
password = generate_subsonic_password()
|
||||||
|
await self._users.set_subsonic_password_enc(user_id, self._cipher.encrypt(password))
|
||||||
|
return password
|
||||||
|
|
||||||
|
async def reveal(self, user_id: uuid.UUID) -> str:
|
||||||
|
"""Return the current app-password, generating one on first access."""
|
||||||
|
await self._require_user(user_id)
|
||||||
|
enc = await self._users.get_subsonic_password_enc(user_id)
|
||||||
|
if enc is None:
|
||||||
|
return await self.rotate(user_id)
|
||||||
|
return self._cipher.decrypt(enc)
|
||||||
|
|
||||||
|
async def _require_user(self, user_id: uuid.UUID) -> User:
|
||||||
|
user = await self._users.get_by_id(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("User not found.")
|
||||||
|
return user
|
||||||
@@ -5,6 +5,7 @@ import hashlib
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
@@ -14,6 +15,8 @@ import anyio
|
|||||||
from app.domain.entities.user import User
|
from app.domain.entities.user import User
|
||||||
from app.domain.ports import ArtistRepository, FileStorage, TrackRepository
|
from app.domain.ports import ArtistRepository, FileStorage, TrackRepository
|
||||||
|
|
||||||
|
EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
class UploadFileProtocol(Protocol):
|
class UploadFileProtocol(Protocol):
|
||||||
filename: str | None
|
filename: str | None
|
||||||
@@ -49,11 +52,13 @@ class UploadService:
|
|||||||
artists: ArtistRepository,
|
artists: ArtistRepository,
|
||||||
storage: FileStorage,
|
storage: FileStorage,
|
||||||
tmp_dir: Path | None = None,
|
tmp_dir: Path | None = None,
|
||||||
|
enqueue_enrich: EnrichEnqueuer | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._tracks = tracks
|
self._tracks = tracks
|
||||||
self._artists = artists
|
self._artists = artists
|
||||||
self._storage = storage
|
self._storage = storage
|
||||||
self._tmp_dir = tmp_dir
|
self._tmp_dir = tmp_dir
|
||||||
|
self._enqueue_enrich = enqueue_enrich
|
||||||
|
|
||||||
async def handle_upload(
|
async def handle_upload(
|
||||||
self,
|
self,
|
||||||
@@ -105,7 +110,8 @@ class UploadService:
|
|||||||
await self._storage.delete(key)
|
await self._storage.delete(key)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# TODO(1D): enqueue metadata enrichment task
|
if self._enqueue_enrich is not None:
|
||||||
|
await self._enqueue_enrich(track.id)
|
||||||
|
|
||||||
return UploadResult(
|
return UploadResult(
|
||||||
track_id=track.id,
|
track_id=track.id,
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
|
|||||||
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
||||||
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
|
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
|
||||||
|
|
||||||
|
# -- subsonic ---------------------------------------------------------
|
||||||
|
# Symmetric key (any string) used to encrypt each user's recoverable
|
||||||
|
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
|
||||||
|
# this value renders stored app-passwords undecryptable (rotate them too).
|
||||||
|
subsonic_secret_key: SecretStr = SecretStr("change-me-subsonic-key")
|
||||||
|
|
||||||
# -- media / storage --------------------------------------------------
|
# -- media / storage --------------------------------------------------
|
||||||
media_path: Path = Path("/data/media")
|
media_path: Path = Path("/data/media")
|
||||||
transcode_cache_path: Path = Path("/data/transcode-cache")
|
transcode_cache_path: Path = Path("/data/transcode-cache")
|
||||||
@@ -52,6 +58,11 @@ class Settings(BaseSettings):
|
|||||||
storage_backend: Literal["local", "s3"] = "local"
|
storage_backend: Literal["local", "s3"] = "local"
|
||||||
upload_tmp_dir: Path | None = None
|
upload_tmp_dir: Path | None = None
|
||||||
|
|
||||||
|
# -- sources ----------------------------------------------------------
|
||||||
|
# Mounted folder the ``local`` source indexes (copies into managed storage).
|
||||||
|
# Unset → the local source is simply not registered.
|
||||||
|
local_media_import_path: Path | None = None
|
||||||
|
|
||||||
# -- S3 storage (deferred; set storage_backend="s3" to use) ----------
|
# -- S3 storage (deferred; set storage_backend="s3" to use) ----------
|
||||||
s3_endpoint_url: str | None = None
|
s3_endpoint_url: str | None = None
|
||||||
s3_bucket: str | None = None
|
s3_bucket: str | None = None
|
||||||
@@ -62,9 +73,15 @@ class Settings(BaseSettings):
|
|||||||
# -- external services (all optional; graceful degradation) ----------
|
# -- external services (all optional; graceful degradation) ----------
|
||||||
ml_service_url: str | None = None
|
ml_service_url: str | None = None
|
||||||
acoustid_api_key: SecretStr | None = None
|
acoustid_api_key: SecretStr | None = None
|
||||||
|
acoustid_api_url: str = "https://api.acoustid.org/v2/lookup"
|
||||||
musicbrainz_user_agent: str = "mcma-backend/0.1.0 ( https://github.com/your/repo )"
|
musicbrainz_user_agent: str = "mcma-backend/0.1.0 ( https://github.com/your/repo )"
|
||||||
youtube_cookies_path: Path | None = None
|
youtube_cookies_path: Path | None = None
|
||||||
|
|
||||||
|
# -- enrichment -------------------------------------------------------
|
||||||
|
# ``fpcalc`` (Chromaprint) binary; resolved on PATH by default. The Docker
|
||||||
|
# image installs it via libchromaprint-tools.
|
||||||
|
fpcalc_path: str = "fpcalc"
|
||||||
|
|
||||||
@field_validator("database_url")
|
@field_validator("database_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _require_async_driver(cls, v: str) -> str:
|
def _require_async_driver(cls, v: str) -> str:
|
||||||
|
|||||||
@@ -6,16 +6,54 @@ to this module (CLAUDE.md: security is a cross-cutting concern in ``core``).
|
|||||||
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
|
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from pwdlib import PasswordHash
|
from pwdlib import PasswordHash
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.domain.errors import AuthenticationError
|
from app.domain.errors import AuthenticationError
|
||||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||||
|
|
||||||
|
# Length (in bytes of entropy) of a generated Subsonic app-password. 18 bytes of
|
||||||
|
# url-safe base64 → 24 characters, well above the Subsonic auth threat model.
|
||||||
|
_SUBSONIC_PASSWORD_ENTROPY_BYTES = 18
|
||||||
|
|
||||||
|
|
||||||
|
def generate_subsonic_password() -> str:
|
||||||
|
"""A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)."""
|
||||||
|
return secrets.token_urlsafe(_SUBSONIC_PASSWORD_ENTROPY_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicPasswordCipher:
|
||||||
|
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password.
|
||||||
|
|
||||||
|
Subsonic auth (``t=md5(password+salt)`` and legacy ``p=``) needs the plaintext
|
||||||
|
password server-side, so — unlike the argon2-hashed login password — the
|
||||||
|
app-password is stored *encrypted*, not hashed. A Fernet key (AES-128-CBC +
|
||||||
|
HMAC) is derived from the configured secret; the plaintext key never touches
|
||||||
|
the DB. Implements :class:`app.domain.ports.SubsonicCipher`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, secret_key: str) -> None:
|
||||||
|
digest = hashlib.sha256(secret_key.encode("utf-8")).digest()
|
||||||
|
self._fernet = Fernet(base64.urlsafe_b64encode(digest))
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str) -> str:
|
||||||
|
return self._fernet.encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
def decrypt(self, token: str) -> str:
|
||||||
|
try:
|
||||||
|
return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
|
||||||
|
except InvalidToken as exc:
|
||||||
|
# Wrong/rotated secret key, or corrupted ciphertext.
|
||||||
|
raise AuthenticationError("Stored Subsonic password could not be decrypted.") from exc
|
||||||
|
|
||||||
|
|
||||||
class Argon2PasswordHasher:
|
class Argon2PasswordHasher:
|
||||||
"""argon2id hasher with sensible defaults from pwdlib."""
|
"""argon2id hasher with sensible defaults from pwdlib."""
|
||||||
|
|||||||
@@ -3,19 +3,24 @@
|
|||||||
from app.domain.entities.album import Album
|
from app.domain.entities.album import Album
|
||||||
from app.domain.entities.history import PlayHistoryEntry
|
from app.domain.entities.history import PlayHistoryEntry
|
||||||
from app.domain.entities.like import Like
|
from app.domain.entities.like import Like
|
||||||
|
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
|
||||||
from app.domain.entities.playlist import Playlist
|
from app.domain.entities.playlist import Playlist
|
||||||
from app.domain.entities.storage import ObjectStat
|
from app.domain.entities.storage import ObjectStat
|
||||||
from app.domain.entities.track import Artist, Track
|
from app.domain.entities.track import Artist, Track
|
||||||
from app.domain.entities.user import Credentials, User
|
from app.domain.entities.user import Credentials, SubsonicCredentials, User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Album",
|
"Album",
|
||||||
"Artist",
|
"Artist",
|
||||||
|
"AudioTags",
|
||||||
"Credentials",
|
"Credentials",
|
||||||
|
"Fingerprint",
|
||||||
"Like",
|
"Like",
|
||||||
"ObjectStat",
|
"ObjectStat",
|
||||||
"PlayHistoryEntry",
|
"PlayHistoryEntry",
|
||||||
"Playlist",
|
"Playlist",
|
||||||
|
"RecordingMatch",
|
||||||
|
"SubsonicCredentials",
|
||||||
"Track",
|
"Track",
|
||||||
"User",
|
"User",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Value objects for the metadata-enrichment pipeline (plan §6.2).
|
||||||
|
|
||||||
|
Pure data carriers between the enrichment service and its adapters (tag reader,
|
||||||
|
fingerprinter, AcoustID). No framework imports — these cross the domain boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AudioTags:
|
||||||
|
"""Embedded tags read from the file itself (ID3 / Vorbis / MP4 …).
|
||||||
|
|
||||||
|
Every field is optional — files are tagged inconsistently. The reader fills
|
||||||
|
what it can and leaves the rest ``None`` for downstream identification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: str | None = None
|
||||||
|
artist: str | None = None
|
||||||
|
album: str | None = None
|
||||||
|
album_artist: str | None = None
|
||||||
|
genre: str | None = None
|
||||||
|
year: int | None = None
|
||||||
|
track_number: int | None = None
|
||||||
|
duration_seconds: int | None = None
|
||||||
|
bitrate: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Fingerprint:
|
||||||
|
"""Chromaprint fingerprint plus the decoded duration (both needed by AcoustID)."""
|
||||||
|
|
||||||
|
fingerprint: str
|
||||||
|
duration_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecordingMatch:
|
||||||
|
"""A single AcoustID result, flattened to the fields enrichment cares about.
|
||||||
|
|
||||||
|
``acoustid`` is the stable AcoustID identifier (a UUID) — used as the
|
||||||
|
dedup key persisted on ``track.acoustid_fingerprint`` (fits the 64-char
|
||||||
|
column; the raw fingerprint does not). ``recording_mbid`` is the MusicBrainz
|
||||||
|
recording id when present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
acoustid: str
|
||||||
|
score: float
|
||||||
|
recording_mbid: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
artist: str | None = None
|
||||||
|
album: str | None = None
|
||||||
|
year: int | None = None
|
||||||
@@ -31,3 +31,14 @@ class Credentials:
|
|||||||
|
|
||||||
user: User
|
user: User
|
||||||
password_hash: str
|
password_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SubsonicCredentials:
|
||||||
|
"""A user paired with their *encrypted* Subsonic app-password.
|
||||||
|
|
||||||
|
``password_enc`` is ``None`` until the user generates one. Stays inside the
|
||||||
|
application layer; the plaintext is only recovered for auth verification."""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
password_enc: str | None
|
||||||
|
|||||||
+102
-2
@@ -7,21 +7,26 @@ are bound to these ports at the composition root (``app.api.deps``).
|
|||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from contextlib import AbstractAsyncContextManager
|
from contextlib import AbstractAsyncContextManager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from app.domain.entities import (
|
from app.domain.entities import (
|
||||||
Album,
|
Album,
|
||||||
|
AudioTags,
|
||||||
Credentials,
|
Credentials,
|
||||||
|
Fingerprint,
|
||||||
Like,
|
Like,
|
||||||
ObjectStat,
|
ObjectStat,
|
||||||
PlayHistoryEntry,
|
PlayHistoryEntry,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
RecordingMatch,
|
||||||
|
SubsonicCredentials,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.domain.entities.track import Artist, Track
|
from app.domain.entities.track import Artist, Track
|
||||||
|
from app.domain.sources import SourceFile, SourceInfo
|
||||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +39,19 @@ class UserRepository(Protocol):
|
|||||||
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
|
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
|
||||||
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
||||||
async def count(self) -> int: ...
|
async def count(self) -> int: ...
|
||||||
|
# -- subsonic app-password (recoverable, encrypted at rest) ----------
|
||||||
|
async def get_subsonic_credentials_by_username(
|
||||||
|
self, username: str
|
||||||
|
) -> SubsonicCredentials | None: ...
|
||||||
|
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None: ...
|
||||||
|
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicCipher(Protocol):
|
||||||
|
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str) -> str: ...
|
||||||
|
def decrypt(self, token: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenRepository(Protocol):
|
class RefreshTokenRepository(Protocol):
|
||||||
@@ -109,6 +127,9 @@ class TrackRepository(Protocol):
|
|||||||
added_by: uuid.UUID | None,
|
added_by: uuid.UUID | None,
|
||||||
) -> Track: ...
|
) -> Track: ...
|
||||||
async def delete(self, track_id: uuid.UUID) -> None: ...
|
async def delete(self, track_id: uuid.UUID) -> None: ...
|
||||||
|
# genres must come before ``list`` — the method named ``list`` shadows the
|
||||||
|
# builtin in later annotations (same pattern as AlbumRepository below).
|
||||||
|
async def genres(self) -> list[tuple[str, int]]: ...
|
||||||
async def list(
|
async def list(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -135,9 +156,38 @@ class TrackRepository(Protocol):
|
|||||||
genre: str | None,
|
genre: str | None,
|
||||||
year: int | None,
|
year: int | None,
|
||||||
) -> Track: ...
|
) -> Track: ...
|
||||||
|
async def apply_enrichment(
|
||||||
|
self,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
album_id: uuid.UUID | None,
|
||||||
|
genre: str | None,
|
||||||
|
year: int | None,
|
||||||
|
track_number: int | None,
|
||||||
|
duration_seconds: int | None,
|
||||||
|
bitrate: int | None,
|
||||||
|
acoustid_fingerprint: str | None,
|
||||||
|
musicbrainz_id: str | None,
|
||||||
|
metadata_status: str,
|
||||||
|
) -> Track:
|
||||||
|
"""Persist auto-enrichment results. Nullable fields are filled only when
|
||||||
|
a non-``None`` value is supplied (re-enrich never erases prior data);
|
||||||
|
``title``/``artist_id``/``metadata_status`` are always written. Callers
|
||||||
|
must not invoke this for ``metadata_status == 'manual'`` tracks."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class AlbumRepository(Protocol):
|
class AlbumRepository(Protocol):
|
||||||
|
async def get_or_create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
year: int | None,
|
||||||
|
musicbrainz_id: str | None,
|
||||||
|
) -> Album: ...
|
||||||
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
|
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
|
||||||
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
|
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
|
||||||
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ...
|
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ...
|
||||||
@@ -145,7 +195,14 @@ class AlbumRepository(Protocol):
|
|||||||
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
|
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
|
||||||
# list must come after any method using list[...] in its signature (name shadowing)
|
# list must come after any method using list[...] in its signature (name shadowing)
|
||||||
async def list(
|
async def list(
|
||||||
self, *, artist_id: uuid.UUID | None, q: str | None, limit: int, offset: int
|
self,
|
||||||
|
*,
|
||||||
|
artist_id: uuid.UUID | None,
|
||||||
|
q: str | None,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
sort_by: str = "title",
|
||||||
|
order: str = "asc",
|
||||||
) -> list[Album]: ...
|
) -> list[Album]: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -197,3 +254,46 @@ class HistoryRepository(Protocol):
|
|||||||
self, *, user_id: uuid.UUID, limit: int, offset: int
|
self, *, user_id: uuid.UUID, limit: int, offset: int
|
||||||
) -> list[PlayHistoryEntry]: ...
|
) -> list[PlayHistoryEntry]: ...
|
||||||
async def count(self, *, user_id: uuid.UUID) -> int: ...
|
async def count(self, *, user_id: uuid.UUID) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SourceBackend(Protocol):
|
||||||
|
"""A registered source of tracks (mounted folder, YouTube, …).
|
||||||
|
|
||||||
|
``name`` is the stable identifier used in URLs and stored on ``track.source``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def info(self) -> SourceInfo: ...
|
||||||
|
def is_available(self) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IndexableSource(SourceBackend, Protocol):
|
||||||
|
"""A source that enumerates files already on disk (e.g. the local folder)."""
|
||||||
|
|
||||||
|
def scan(self) -> Iterator[SourceFile]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# -- metadata enrichment (plan §6.2) -----------------------------------------
|
||||||
|
class AudioTagReader(Protocol):
|
||||||
|
"""Reads embedded tags from a local audio file. Returns ``None`` only when
|
||||||
|
the file can't be parsed at all — never raises (graceful degradation)."""
|
||||||
|
|
||||||
|
async def read(self, path: Path) -> AudioTags | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class AudioFingerprinter(Protocol):
|
||||||
|
"""Chromaprint (fpcalc) wrapper. ``is_available`` reflects whether the
|
||||||
|
binary is present; ``calculate`` returns ``None`` on any failure."""
|
||||||
|
|
||||||
|
def is_available(self) -> bool: ...
|
||||||
|
async def calculate(self, path: Path) -> Fingerprint | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class AcoustIdClient(Protocol):
|
||||||
|
"""AcoustID lookup. ``is_available`` is False without an API key (the whole
|
||||||
|
fingerprint path is then skipped). ``lookup`` returns the best match or
|
||||||
|
``None`` (no result / service down), never raising."""
|
||||||
|
|
||||||
|
def is_available(self) -> bool: ...
|
||||||
|
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Source-backend value objects — framework-free.
|
||||||
|
|
||||||
|
A *source* is a place tracks come from (a mounted folder, YouTube, an upload).
|
||||||
|
Backends are driven adapters (``app.infrastructure.sources``); these are the
|
||||||
|
shapes they speak in, and the ports they satisfy live in ``app.domain.ports``.
|
||||||
|
|
||||||
|
The first backend, ``local``, is *indexable*: it enumerates files already on
|
||||||
|
disk. Concrete metadata (artist/album/tags) is intentionally **not** resolved
|
||||||
|
here — a source yields a file plus a minimal title; enrichment (plan §6.2) fills
|
||||||
|
the rest later, so this stays a thin discovery layer (CLAUDE.md: no duplicated
|
||||||
|
business logic)."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceInfo:
|
||||||
|
"""Describes a registered source for enumeration / health (UI, admin)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
kind: str # "indexable" (more kinds — search/download — arrive with youtube)
|
||||||
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceFile:
|
||||||
|
"""A single importable file discovered by an indexable source.
|
||||||
|
|
||||||
|
``source_id`` is stable per source (the local backend uses the path relative
|
||||||
|
to its root) so re-scans are idempotent — already-imported files are skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_id: str
|
||||||
|
path: Path
|
||||||
|
suggested_title: str
|
||||||
|
file_format: str
|
||||||
|
file_size: int
|
||||||
@@ -18,6 +18,9 @@ class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
# Admin is a single flag in Phase 1 — no role system (plan §3.5).
|
# Admin is a single flag in Phase 1 — no role system (plan §3.5).
|
||||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
# Recoverable Subsonic app-password, Fernet-encrypted at rest. NULL until the
|
||||||
|
# user generates one. Never the argon2 login password — see core.security.
|
||||||
|
subsonic_password_enc: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
|
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
|
||||||
|
|||||||
@@ -27,6 +27,42 @@ class SqlAlchemyAlbumRepository:
|
|||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
|
async def get_or_create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
year: int | None,
|
||||||
|
musicbrainz_id: str | None,
|
||||||
|
) -> Album:
|
||||||
|
"""Resolve an album by ``(title, artist_id)``, creating it if absent.
|
||||||
|
Backfills ``year``/``musicbrainz_id`` onto an existing row when it lacks
|
||||||
|
them and enrichment now has values (gap-fill, never overwrite)."""
|
||||||
|
row = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(AlbumModel).where(
|
||||||
|
AlbumModel.title == title,
|
||||||
|
AlbumModel.artist_id == artist_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
row = AlbumModel(
|
||||||
|
title=title,
|
||||||
|
artist_id=artist_id,
|
||||||
|
year=year,
|
||||||
|
musicbrainz_id=musicbrainz_id,
|
||||||
|
)
|
||||||
|
self._session.add(row)
|
||||||
|
else:
|
||||||
|
if row.year is None and year is not None:
|
||||||
|
row.year = year
|
||||||
|
if row.musicbrainz_id is None and musicbrainz_id is not None:
|
||||||
|
row.musicbrainz_id = musicbrainz_id
|
||||||
|
await self._session.flush()
|
||||||
|
await self._session.refresh(row)
|
||||||
|
return _to_entity(row)
|
||||||
|
|
||||||
async def get_by_id(self, album_id: uuid.UUID) -> Album | None:
|
async def get_by_id(self, album_id: uuid.UUID) -> Album | None:
|
||||||
row = await self._session.get(AlbumModel, album_id)
|
row = await self._session.get(AlbumModel, album_id)
|
||||||
return _to_entity(row) if row is not None else None
|
return _to_entity(row) if row is not None else None
|
||||||
@@ -76,12 +112,20 @@ class SqlAlchemyAlbumRepository:
|
|||||||
q: str | None,
|
q: str | None,
|
||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
|
sort_by: str = "title",
|
||||||
|
order: str = "asc",
|
||||||
) -> list[Album]:
|
) -> list[Album]:
|
||||||
stmt = select(AlbumModel)
|
stmt = select(AlbumModel)
|
||||||
if artist_id is not None:
|
if artist_id is not None:
|
||||||
stmt = stmt.where(AlbumModel.artist_id == artist_id)
|
stmt = stmt.where(AlbumModel.artist_id == artist_id)
|
||||||
if q:
|
if q:
|
||||||
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
|
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
|
||||||
stmt = stmt.order_by(AlbumModel.title).limit(limit).offset(offset)
|
|
||||||
|
if order == "random":
|
||||||
|
stmt = stmt.order_by(func.random())
|
||||||
|
else:
|
||||||
|
col = AlbumModel.created_at if sort_by == "created" else AlbumModel.title
|
||||||
|
stmt = stmt.order_by(col.desc() if order == "desc" else col.asc())
|
||||||
|
stmt = stmt.limit(limit).offset(offset)
|
||||||
rows = (await self._session.execute(stmt)).scalars().all()
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
return [_to_entity(r) for r in rows]
|
return [_to_entity(r) for r in rows]
|
||||||
|
|||||||
@@ -87,6 +87,21 @@ class SqlAlchemyTrackRepository:
|
|||||||
await self._session.delete(row)
|
await self._session.delete(row)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
|
|
||||||
|
async def genres(self) -> list[tuple[str, int]]:
|
||||||
|
"""Distinct non-null genres with their song counts, most common first.
|
||||||
|
|
||||||
|
Defined before ``list`` — the method named ``list`` shadows the builtin
|
||||||
|
in later annotations within the class body."""
|
||||||
|
rows = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(TrackModel.genre, func.count(TrackModel.id).label("cnt"))
|
||||||
|
.where(TrackModel.genre.is_not(None))
|
||||||
|
.group_by(TrackModel.genre)
|
||||||
|
.order_by(func.count(TrackModel.id).desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [(row.genre, row.cnt) for row in rows]
|
||||||
|
|
||||||
async def list(
|
async def list(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -158,3 +173,47 @@ class SqlAlchemyTrackRepository:
|
|||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
await self._session.refresh(row)
|
await self._session.refresh(row)
|
||||||
return _to_entity(row)
|
return _to_entity(row)
|
||||||
|
|
||||||
|
async def apply_enrichment(
|
||||||
|
self,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
album_id: uuid.UUID | None,
|
||||||
|
genre: str | None,
|
||||||
|
year: int | None,
|
||||||
|
track_number: int | None,
|
||||||
|
duration_seconds: int | None,
|
||||||
|
bitrate: int | None,
|
||||||
|
acoustid_fingerprint: str | None,
|
||||||
|
musicbrainz_id: str | None,
|
||||||
|
metadata_status: str,
|
||||||
|
) -> Track:
|
||||||
|
row = await self._session.get(TrackModel, track_id)
|
||||||
|
if row is None:
|
||||||
|
raise NotFoundError(f"Track {track_id} not found.")
|
||||||
|
# Identity + status are authoritative for an enrichment run.
|
||||||
|
row.title = title
|
||||||
|
row.artist_id = artist_id
|
||||||
|
row.metadata_status = metadata_status
|
||||||
|
# Nullable extras: fill gaps only — never erase data a prior run found.
|
||||||
|
if album_id is not None:
|
||||||
|
row.album_id = album_id
|
||||||
|
if genre is not None:
|
||||||
|
row.genre = genre
|
||||||
|
if year is not None:
|
||||||
|
row.year = year
|
||||||
|
if track_number is not None:
|
||||||
|
row.track_number = track_number
|
||||||
|
if duration_seconds is not None:
|
||||||
|
row.duration_seconds = duration_seconds
|
||||||
|
if bitrate is not None:
|
||||||
|
row.bitrate = bitrate
|
||||||
|
if acoustid_fingerprint is not None:
|
||||||
|
row.acoustid_fingerprint = acoustid_fingerprint
|
||||||
|
if musicbrainz_id is not None:
|
||||||
|
row.musicbrainz_id = musicbrainz_id
|
||||||
|
await self._session.flush()
|
||||||
|
await self._session.refresh(row)
|
||||||
|
return _to_entity(row)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import uuid
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.domain.entities import Credentials, User
|
from app.domain.entities import Credentials, SubsonicCredentials, User
|
||||||
from app.domain.errors import NotFoundError
|
from app.domain.errors import NotFoundError
|
||||||
from app.infrastructure.db.models import UserModel
|
from app.infrastructure.db.models import UserModel
|
||||||
|
|
||||||
@@ -91,3 +91,22 @@ class SqlAlchemyUserRepository:
|
|||||||
return (
|
return (
|
||||||
await self._session.execute(select(func.count()).select_from(UserModel))
|
await self._session.execute(select(func.count()).select_from(UserModel))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
async def get_subsonic_credentials_by_username(
|
||||||
|
self, username: str
|
||||||
|
) -> SubsonicCredentials | None:
|
||||||
|
row = (
|
||||||
|
await self._session.execute(select(UserModel).where(UserModel.username == username))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return SubsonicCredentials(user=_to_entity(row), password_enc=row.subsonic_password_enc)
|
||||||
|
|
||||||
|
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
|
||||||
|
row = await self._get_row(user_id)
|
||||||
|
return row.subsonic_password_enc
|
||||||
|
|
||||||
|
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
|
||||||
|
row = await self._get_row(user_id)
|
||||||
|
row.subsonic_password_enc = password_enc
|
||||||
|
await self._session.flush()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Metadata-enrichment adapters: tag reader, fingerprinter, AcoustID client."""
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""AcoustIdHttpClient — identifies a recording from its Chromaprint fingerprint.
|
||||||
|
|
||||||
|
One ``/v2/lookup`` call with ``meta=recordings+releasegroups`` returns the
|
||||||
|
AcoustID id, the MusicBrainz recording id, and canonical title/artist/album —
|
||||||
|
metadata that itself originates from MusicBrainz, so a separate MB call is not
|
||||||
|
needed for Phase 1 (plan §6.2 steps 2-3 collapsed into one request).
|
||||||
|
|
||||||
|
Graceful degradation: no API key → ``is_available()`` is False and the whole
|
||||||
|
fingerprint path is skipped; any network/parse error → ``lookup`` returns
|
||||||
|
``None``. A small inter-call delay keeps us within AcoustID's rate limit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.entities.metadata import Fingerprint, RecordingMatch
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_URL = "https://api.acoustid.org/v2/lookup"
|
||||||
|
_TIMEOUT_SECONDS = 10.0
|
||||||
|
_MIN_INTERVAL_SECONDS = 0.34 # AcoustID allows ~3 req/s; stay polite
|
||||||
|
|
||||||
|
|
||||||
|
class AcoustIdHttpClient:
|
||||||
|
"""Implements :class:`app.domain.ports.AcoustIdClient`."""
|
||||||
|
|
||||||
|
_throttle_lock = asyncio.Lock()
|
||||||
|
_last_call_monotonic = 0.0
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str | None,
|
||||||
|
user_agent: str,
|
||||||
|
api_url: str = _DEFAULT_URL,
|
||||||
|
) -> None:
|
||||||
|
self._api_key = api_key
|
||||||
|
self._user_agent = user_agent
|
||||||
|
self._api_url = api_url
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return bool(self._api_key)
|
||||||
|
|
||||||
|
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
|
||||||
|
if not self._api_key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
await self._throttle()
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=_TIMEOUT_SECONDS,
|
||||||
|
headers={"User-Agent": self._user_agent},
|
||||||
|
) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
self._api_url,
|
||||||
|
params={
|
||||||
|
"client": self._api_key,
|
||||||
|
"duration": str(fingerprint.duration_seconds),
|
||||||
|
"fingerprint": fingerprint.fingerprint,
|
||||||
|
"meta": "recordings releasegroups",
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
log.warning("acoustid_lookup_failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _parse_best_match(payload)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _throttle(cls) -> None:
|
||||||
|
async with cls._throttle_lock:
|
||||||
|
elapsed = time.monotonic() - cls._last_call_monotonic
|
||||||
|
wait = _MIN_INTERVAL_SECONDS - elapsed
|
||||||
|
if wait > 0:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
cls._last_call_monotonic = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_best_match(payload: object) -> RecordingMatch | None:
|
||||||
|
if not isinstance(payload, dict) or payload.get("status") != "ok":
|
||||||
|
return None
|
||||||
|
results = payload.get("results")
|
||||||
|
if not isinstance(results, list) or not results:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Results are returned best-score-first; take the top scoring one.
|
||||||
|
best = max(results, key=lambda r: r.get("score", 0.0) if isinstance(r, dict) else 0.0)
|
||||||
|
if not isinstance(best, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
acoustid = best.get("id")
|
||||||
|
if not isinstance(acoustid, str):
|
||||||
|
return None
|
||||||
|
score = float(best.get("score", 0.0))
|
||||||
|
|
||||||
|
recording_mbid: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
artist: str | None = None
|
||||||
|
album: str | None = None
|
||||||
|
|
||||||
|
recordings = best.get("recordings")
|
||||||
|
if isinstance(recordings, list) and recordings and isinstance(recordings[0], dict):
|
||||||
|
rec = recordings[0]
|
||||||
|
recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None
|
||||||
|
title = rec.get("title") if isinstance(rec.get("title"), str) else None
|
||||||
|
artists = rec.get("artists")
|
||||||
|
if isinstance(artists, list) and artists and isinstance(artists[0], dict):
|
||||||
|
name = artists[0].get("name")
|
||||||
|
artist = name if isinstance(name, str) else None
|
||||||
|
groups = rec.get("releasegroups")
|
||||||
|
if isinstance(groups, list) and groups and isinstance(groups[0], dict):
|
||||||
|
gtitle = groups[0].get("title")
|
||||||
|
album = gtitle if isinstance(gtitle, str) else None
|
||||||
|
|
||||||
|
return RecordingMatch(
|
||||||
|
acoustid=acoustid,
|
||||||
|
score=score,
|
||||||
|
recording_mbid=recording_mbid,
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
year=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""FpcalcFingerprinter — Chromaprint fingerprint via the ``fpcalc`` binary.
|
||||||
|
|
||||||
|
``fpcalc -json <file>`` emits ``{"duration": float, "fingerprint": str}``. The
|
||||||
|
binary ships in the Docker image (``libchromaprint-tools``). Any failure (binary
|
||||||
|
missing, bad file, timeout) degrades to ``None`` — the pipeline then falls back
|
||||||
|
to tag-only metadata (plan §6.2: one external dependency must never crash it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.entities.metadata import Fingerprint
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
class FpcalcFingerprinter:
|
||||||
|
"""Implements :class:`app.domain.ports.AudioFingerprinter`."""
|
||||||
|
|
||||||
|
def __init__(self, binary: str = "fpcalc") -> None:
|
||||||
|
self._binary = binary
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return shutil.which(self._binary) is not None
|
||||||
|
|
||||||
|
async def calculate(self, path: Path) -> Fingerprint | None:
|
||||||
|
if not self.is_available():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
self._binary,
|
||||||
|
"-json",
|
||||||
|
str(path),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
async with asyncio.timeout(_TIMEOUT_SECONDS):
|
||||||
|
stdout, _stderr = await proc.communicate()
|
||||||
|
except (TimeoutError, OSError):
|
||||||
|
log.warning("fpcalc_failed", path=str(path))
|
||||||
|
return None
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
log.warning("fpcalc_nonzero", path=str(path), returncode=proc.returncode)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
fingerprint = str(data["fingerprint"])
|
||||||
|
duration = round(float(data["duration"]))
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError):
|
||||||
|
log.warning("fpcalc_bad_output", path=str(path))
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not fingerprint or duration <= 0:
|
||||||
|
return None
|
||||||
|
return Fingerprint(fingerprint=fingerprint, duration_seconds=duration)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""MutagenTagReader — reads embedded tags from a local audio file.
|
||||||
|
|
||||||
|
The offline first pass of enrichment (plan §6.2): well-tagged files get correct
|
||||||
|
artist/album/title without any network call. mutagen's ``easy=True`` mode
|
||||||
|
normalises tag keys across ID3 / Vorbis / MP4, so one code path covers all the
|
||||||
|
formats the library accepts. Parsing is blocking, so it runs in a worker thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
from mutagen import File as MutagenFile # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.entities.metadata import AudioTags
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_YEAR_RE = re.compile(r"(\d{4})")
|
||||||
|
|
||||||
|
|
||||||
|
def _first(value: object) -> str | None:
|
||||||
|
"""EasyXxx tags expose values as lists; take the first non-empty string."""
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = value[0] if value else None
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_year(value: object) -> int | None:
|
||||||
|
text = _first(value)
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
m = _YEAR_RE.search(text)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_track_number(value: object) -> int | None:
|
||||||
|
text = _first(value)
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
# "3" or "3/12" → 3
|
||||||
|
head = text.split("/", 1)[0].strip()
|
||||||
|
return int(head) if head.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
class MutagenTagReader:
|
||||||
|
"""Implements :class:`app.domain.ports.AudioTagReader`."""
|
||||||
|
|
||||||
|
async def read(self, path: Path) -> AudioTags | None:
|
||||||
|
try:
|
||||||
|
return await anyio.to_thread.run_sync(self._read_sync, path)
|
||||||
|
except Exception:
|
||||||
|
log.warning("tag_read_failed", path=str(path))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _read_sync(self, path: Path) -> AudioTags | None:
|
||||||
|
audio = MutagenFile(str(path), easy=True)
|
||||||
|
if audio is None:
|
||||||
|
return None # unrecognised container
|
||||||
|
|
||||||
|
tags = audio.tags or {}
|
||||||
|
info = getattr(audio, "info", None)
|
||||||
|
|
||||||
|
duration = None
|
||||||
|
bitrate = None
|
||||||
|
if info is not None:
|
||||||
|
length = getattr(info, "length", None)
|
||||||
|
if length:
|
||||||
|
duration = round(float(length))
|
||||||
|
raw_bitrate = getattr(info, "bitrate", None)
|
||||||
|
if raw_bitrate:
|
||||||
|
bitrate = int(raw_bitrate) // 1000 # bits/s → kbps for display
|
||||||
|
|
||||||
|
return AudioTags(
|
||||||
|
title=_first(tags.get("title")),
|
||||||
|
artist=_first(tags.get("artist")),
|
||||||
|
album=_first(tags.get("album")),
|
||||||
|
album_artist=_first(tags.get("albumartist")),
|
||||||
|
genre=_first(tags.get("genre")),
|
||||||
|
year=_parse_year(tags.get("date") or tags.get("year")),
|
||||||
|
track_number=_parse_track_number(tags.get("tracknumber")),
|
||||||
|
duration_seconds=duration,
|
||||||
|
bitrate=bitrate,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Source backends — driven adapters that discover/fetch tracks."""
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""``local`` source — indexes audio files from a mounted folder.
|
||||||
|
|
||||||
|
Walks a configured root directory and yields each audio file as a
|
||||||
|
:class:`SourceFile`. It does **not** parse tags or resolve artist/album — that's
|
||||||
|
enrichment's job (plan §6.2); this stays a thin discovery layer. ``source_id``
|
||||||
|
is the path relative to the root, so re-scans are idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.domain.sources import SourceFile, SourceInfo
|
||||||
|
from app.infrastructure.db.models.enums import TrackSource
|
||||||
|
|
||||||
|
# Extensions we treat as audio. Mirrors the formats StreamingService serves.
|
||||||
|
_AUDIO_EXTENSIONS = frozenset(
|
||||||
|
{"mp3", "flac", "m4a", "aac", "ogg", "opus", "wav", "wma", "aiff", "aif", "alac"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFolderSource:
|
||||||
|
"""Implements :class:`app.domain.ports.IndexableSource`."""
|
||||||
|
|
||||||
|
name = TrackSource.LOCAL.value
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self._root = root
|
||||||
|
|
||||||
|
def info(self) -> SourceInfo:
|
||||||
|
return SourceInfo(
|
||||||
|
name=self.name,
|
||||||
|
label="Local folder",
|
||||||
|
kind="indexable",
|
||||||
|
available=self.is_available(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return self._root.is_dir()
|
||||||
|
|
||||||
|
def scan(self) -> Iterator[SourceFile]:
|
||||||
|
if not self.is_available():
|
||||||
|
return
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(self._root):
|
||||||
|
for filename in sorted(filenames):
|
||||||
|
ext = Path(filename).suffix.lower().lstrip(".")
|
||||||
|
if ext not in _AUDIO_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
path = Path(dirpath) / filename
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
continue # vanished/unreadable between walk and stat → skip
|
||||||
|
yield SourceFile(
|
||||||
|
source_id=path.relative_to(self._root).as_posix(),
|
||||||
|
path=path,
|
||||||
|
suggested_title=path.stem or "Unknown",
|
||||||
|
file_format=ext,
|
||||||
|
file_size=size,
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Source registry — selection + enumeration of configured backends.
|
||||||
|
|
||||||
|
Built from settings at the composition root. Only sources that are configured
|
||||||
|
are registered (e.g. ``local`` appears only when ``LOCAL_MEDIA_IMPORT_PATH`` is
|
||||||
|
set), so enumeration reflects what the instance can actually use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.domain.errors import NotFoundError, ValidationError
|
||||||
|
from app.domain.ports import IndexableSource, SourceBackend
|
||||||
|
from app.domain.sources import SourceInfo
|
||||||
|
from app.infrastructure.sources.local_folder import LocalFolderSource
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRegistry:
|
||||||
|
def __init__(self, backends: list[SourceBackend]) -> None:
|
||||||
|
self._by_name = {backend.name: backend for backend in backends}
|
||||||
|
|
||||||
|
def get(self, name: str) -> SourceBackend:
|
||||||
|
backend = self._by_name.get(name)
|
||||||
|
if backend is None:
|
||||||
|
raise NotFoundError(f"Source {name!r} is not configured.")
|
||||||
|
return backend
|
||||||
|
|
||||||
|
def indexable(self, name: str) -> IndexableSource:
|
||||||
|
backend = self.get(name)
|
||||||
|
if not hasattr(backend, "scan"):
|
||||||
|
raise ValidationError(f"Source {name!r} cannot be indexed.")
|
||||||
|
return cast(IndexableSource, backend)
|
||||||
|
|
||||||
|
def infos(self) -> list[SourceInfo]:
|
||||||
|
return [backend.info() for backend in self._by_name.values()]
|
||||||
|
|
||||||
|
|
||||||
|
def build_source_registry(settings: Settings) -> SourceRegistry:
|
||||||
|
backends: list[SourceBackend] = []
|
||||||
|
if settings.local_media_import_path is not None:
|
||||||
|
backends.append(LocalFolderSource(settings.local_media_import_path))
|
||||||
|
return SourceRegistry(backends)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""arq worker settings — the queue runtime. Task functions register here.
|
"""arq worker settings — the queue runtime. Task functions register here.
|
||||||
|
|
||||||
Run with: ``arq app.workers.arq_worker.WorkerSettings``.
|
Run with: ``arq app.workers.arq_worker.WorkerSettings``.
|
||||||
Tasks (download, enrich, transcode) are appended to ``functions`` in later steps.
|
Tasks (download, transcode) are appended to ``functions`` in later steps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
@@ -10,6 +10,8 @@ from arq.connections import RedisSettings
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import configure_logging, get_logger
|
from app.core.logging import configure_logging, get_logger
|
||||||
|
from app.workers.tasks.enrich_task import enrich_track
|
||||||
|
from app.workers.tasks.import_task import scan_local_folder
|
||||||
|
|
||||||
log = get_logger("worker")
|
log = get_logger("worker")
|
||||||
|
|
||||||
@@ -24,12 +26,8 @@ async def shutdown(_ctx: dict[str, Any]) -> None:
|
|||||||
log.info("worker_shutdown")
|
log.info("worker_shutdown")
|
||||||
|
|
||||||
|
|
||||||
async def _noop(_ctx: dict[str, Any]) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerSettings:
|
class WorkerSettings:
|
||||||
functions: ClassVar[list[Any]] = [_noop] # populated as tasks are implemented
|
functions: ClassVar[list[Any]] = [scan_local_folder, enrich_track]
|
||||||
on_startup = startup
|
on_startup = startup
|
||||||
on_shutdown = shutdown
|
on_shutdown = shutdown
|
||||||
max_jobs = get_settings().max_parallel_downloads
|
max_jobs = get_settings().max_parallel_downloads
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Enqueue helper — submit a job to the arq queue from the request cycle.
|
||||||
|
|
||||||
|
A short-lived pool per call keeps things simple (enqueues are rare, admin-driven
|
||||||
|
actions). Redis being down degrades to a clean 503 rather than a crash
|
||||||
|
(graceful degradation)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from arq import create_pool
|
||||||
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.errors import DependencyUnavailableError
|
||||||
|
|
||||||
|
log = get_logger("worker.queue")
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue(function: str, **kwargs: Any) -> str:
|
||||||
|
"""Enqueue ``function`` by name, returning the job id. Raises
|
||||||
|
:class:`DependencyUnavailableError` if the queue can't be reached."""
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
pool = await create_pool(RedisSettings.from_dsn(str(settings.redis_url)))
|
||||||
|
except Exception as exc:
|
||||||
|
raise DependencyUnavailableError("Task queue (Redis) is unavailable.") from exc
|
||||||
|
try:
|
||||||
|
job = await pool.enqueue_job(function, **kwargs)
|
||||||
|
finally:
|
||||||
|
await pool.aclose()
|
||||||
|
if job is None:
|
||||||
|
raise DependencyUnavailableError("Could not enqueue job.")
|
||||||
|
return str(job.job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_enrich(track_id: uuid.UUID) -> None:
|
||||||
|
"""Best-effort enqueue of metadata enrichment for a freshly stored track.
|
||||||
|
|
||||||
|
The track is already persisted, so enrichment is a follow-up, not a barrier:
|
||||||
|
if the queue is unreachable we log and move on (graceful degradation). The
|
||||||
|
track stays ``metadata_status=pending`` and can be re-enriched later.
|
||||||
|
|
||||||
|
Deferred a few seconds so the caller's DB transaction is committed before the
|
||||||
|
worker looks the track up (the upload request commits only after it returns)."""
|
||||||
|
try:
|
||||||
|
await enqueue("enrich_track", track_id=str(track_id), _defer_by=5)
|
||||||
|
except DependencyUnavailableError:
|
||||||
|
log.warning("enrich_enqueue_failed", track_id=str(track_id))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""arq task functions. Registered in ``app.workers.arq_worker.WorkerSettings``."""
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""arq task: enrich one track's metadata (plan §6.2, §1D).
|
||||||
|
|
||||||
|
Wires the §6.2 pipeline adapters to :class:`MetadataEnrichmentService` and runs
|
||||||
|
it in the worker's own transactional session. Enqueued (deferred) after upload
|
||||||
|
and after a local-folder import. Idempotent and best-effort — a missing track or
|
||||||
|
a ``manual`` one is a clean no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.application.metadata_service import MetadataEnrichmentService
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.infrastructure.db import session_scope
|
||||||
|
from app.infrastructure.db.repositories import (
|
||||||
|
SqlAlchemyAlbumRepository,
|
||||||
|
SqlAlchemyArtistRepository,
|
||||||
|
SqlAlchemyTrackRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
|
||||||
|
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
|
||||||
|
from app.infrastructure.metadata.tags import MutagenTagReader
|
||||||
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
|
|
||||||
|
log = get_logger("worker.enrich")
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_track(_ctx: dict[str, Any], *, track_id: str) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
api_key = (
|
||||||
|
settings.acoustid_api_key.get_secret_value() if settings.acoustid_api_key else None
|
||||||
|
)
|
||||||
|
acoustid = AcoustIdHttpClient(
|
||||||
|
api_key=api_key,
|
||||||
|
user_agent=settings.musicbrainz_user_agent,
|
||||||
|
api_url=settings.acoustid_api_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
service = MetadataEnrichmentService(
|
||||||
|
tracks=SqlAlchemyTrackRepository(session),
|
||||||
|
artists=SqlAlchemyArtistRepository(session),
|
||||||
|
albums=SqlAlchemyAlbumRepository(session),
|
||||||
|
storage=get_file_storage(),
|
||||||
|
tag_reader=MutagenTagReader(),
|
||||||
|
fingerprinter=FpcalcFingerprinter(settings.fpcalc_path),
|
||||||
|
acoustid=acoustid,
|
||||||
|
)
|
||||||
|
result = await service.enrich(uuid.UUID(track_id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"track_id": str(result.track_id),
|
||||||
|
"status": result.status,
|
||||||
|
"mbid": result.matched_mbid,
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""arq task: scan an indexable source and import its files into the library.
|
||||||
|
|
||||||
|
Heavy work (directory walk + file copies) belongs off the request cycle
|
||||||
|
(CLAUDE.md). The HTTP endpoint enqueues this; the worker runs it with its own
|
||||||
|
transactional session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.application.import_service import LibraryImportService
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.infrastructure.db import session_scope
|
||||||
|
from app.infrastructure.db.repositories import (
|
||||||
|
SqlAlchemyArtistRepository,
|
||||||
|
SqlAlchemyTrackRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.sources.registry import build_source_registry
|
||||||
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
|
from app.workers.queue import enqueue_enrich
|
||||||
|
|
||||||
|
log = get_logger("worker.import")
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_local_folder(
|
||||||
|
_ctx: dict[str, Any], *, source: str = "local", added_by: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
registry = build_source_registry(get_settings())
|
||||||
|
backend = registry.indexable(source)
|
||||||
|
actor = uuid.UUID(added_by) if added_by else None
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
service = LibraryImportService(
|
||||||
|
tracks=SqlAlchemyTrackRepository(session),
|
||||||
|
artists=SqlAlchemyArtistRepository(session),
|
||||||
|
storage=get_file_storage(),
|
||||||
|
)
|
||||||
|
summary = await service.scan_and_import(backend, added_by=actor)
|
||||||
|
|
||||||
|
# Enqueue enrichment only after the import transaction has committed above,
|
||||||
|
# so the enrich worker is guaranteed to see the new rows.
|
||||||
|
for track_id in summary.imported_ids:
|
||||||
|
await enqueue_enrich(track_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": summary.source,
|
||||||
|
"seen": summary.seen,
|
||||||
|
"imported": summary.imported,
|
||||||
|
"skipped": summary.skipped,
|
||||||
|
"failed": summary.failed,
|
||||||
|
}
|
||||||
@@ -21,8 +21,12 @@ dependencies = [
|
|||||||
# auth
|
# auth
|
||||||
"pyjwt>=2.10",
|
"pyjwt>=2.10",
|
||||||
"pwdlib[argon2]>=0.2.1",
|
"pwdlib[argon2]>=0.2.1",
|
||||||
|
# symmetric encryption for the recoverable Subsonic app-password (Fernet)
|
||||||
|
"cryptography>=44.0",
|
||||||
# outbound http (ML client, MusicBrainz, AcoustID)
|
# outbound http (ML client, MusicBrainz, AcoustID)
|
||||||
"httpx>=0.28",
|
"httpx>=0.28",
|
||||||
|
# embedded audio tag reading (enrichment tag pre-pass)
|
||||||
|
"mutagen>=1.47",
|
||||||
# S3-compatible object storage
|
# S3-compatible object storage
|
||||||
"aioboto3>=13.0",
|
"aioboto3>=13.0",
|
||||||
# logging
|
# logging
|
||||||
@@ -71,6 +75,11 @@ select = [
|
|||||||
]
|
]
|
||||||
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
|
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# Subsonic query params are camelCase by spec (artistCount, songId, …); the
|
||||||
|
# handler arg names must match the wire names exactly.
|
||||||
|
"app/api/rest/*" = ["N803"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.14"
|
python_version = "3.14"
|
||||||
strict = true
|
strict = true
|
||||||
|
|||||||
+18
-1
@@ -4,13 +4,14 @@ import datetime as dt
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
from app.domain.entities import Credentials, User
|
from app.domain.entities import Credentials, SubsonicCredentials, User
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Stored:
|
class _Stored:
|
||||||
user: User
|
user: User
|
||||||
password_hash: str
|
password_hash: str
|
||||||
|
subsonic_password_enc: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class InMemoryUserRepository:
|
class InMemoryUserRepository:
|
||||||
@@ -61,6 +62,22 @@ class InMemoryUserRepository:
|
|||||||
async def count(self) -> int:
|
async def count(self) -> int:
|
||||||
return len(self._by_id)
|
return len(self._by_id)
|
||||||
|
|
||||||
|
async def get_subsonic_credentials_by_username(
|
||||||
|
self, username: str
|
||||||
|
) -> SubsonicCredentials | None:
|
||||||
|
for stored in self._by_id.values():
|
||||||
|
if stored.user.username == username:
|
||||||
|
return SubsonicCredentials(
|
||||||
|
user=stored.user, password_enc=stored.subsonic_password_enc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
|
||||||
|
return self._by_id[user_id].subsonic_password_enc
|
||||||
|
|
||||||
|
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
|
||||||
|
self._by_id[user_id].subsonic_password_enc = password_enc
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Token:
|
class _Token:
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Unit tests for the AcoustID response parser — pure, no network."""
|
||||||
|
|
||||||
|
from app.infrastructure.metadata.acoustid import _parse_best_match
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_with_results(results: list[object]) -> dict[str, object]:
|
||||||
|
return {"status": "ok", "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_full_recording() -> None:
|
||||||
|
payload = _payload_with_results(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "acoustid-1",
|
||||||
|
"score": 0.97,
|
||||||
|
"recordings": [
|
||||||
|
{
|
||||||
|
"id": "mb-rec-1",
|
||||||
|
"title": "One More Time",
|
||||||
|
"artists": [{"id": "a1", "name": "Daft Punk"}],
|
||||||
|
"releasegroups": [{"id": "rg1", "title": "Discovery"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
match = _parse_best_match(payload)
|
||||||
|
|
||||||
|
assert match is not None
|
||||||
|
assert match.acoustid == "acoustid-1"
|
||||||
|
assert match.recording_mbid == "mb-rec-1"
|
||||||
|
assert match.title == "One More Time"
|
||||||
|
assert match.artist == "Daft Punk"
|
||||||
|
assert match.album == "Discovery"
|
||||||
|
assert match.score == 0.97
|
||||||
|
|
||||||
|
|
||||||
|
def test_picks_highest_score() -> None:
|
||||||
|
payload = _payload_with_results(
|
||||||
|
[
|
||||||
|
{"id": "low", "score": 0.40, "recordings": [{"id": "r-low", "title": "Low"}]},
|
||||||
|
{"id": "high", "score": 0.92, "recordings": [{"id": "r-high", "title": "High"}]},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
match = _parse_best_match(payload)
|
||||||
|
|
||||||
|
assert match is not None
|
||||||
|
assert match.acoustid == "high"
|
||||||
|
assert match.title == "High"
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_without_recordings_still_returns_id() -> None:
|
||||||
|
payload = _payload_with_results([{"id": "acoustid-only", "score": 0.5}])
|
||||||
|
|
||||||
|
match = _parse_best_match(payload)
|
||||||
|
|
||||||
|
assert match is not None
|
||||||
|
assert match.acoustid == "acoustid-only"
|
||||||
|
assert match.recording_mbid is None
|
||||||
|
assert match.title is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_status_returns_none() -> None:
|
||||||
|
assert _parse_best_match({"status": "error", "error": {"message": "bad"}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_results_returns_none() -> None:
|
||||||
|
assert _parse_best_match(_payload_with_results([])) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_dict_payload_returns_none() -> None:
|
||||||
|
assert _parse_best_match("nonsense") is None
|
||||||
|
assert _parse_best_match(None) is None
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Unit tests for LibraryImportService — DB-free, in-memory fakes."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.application.import_service import LibraryImportService
|
||||||
|
from app.domain.entities import Artist, Track
|
||||||
|
from app.domain.sources import SourceFile, SourceInfo
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
class FakeArtistRepo:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._by_name: dict[str, Artist] = {}
|
||||||
|
|
||||||
|
async def get_or_create(self, name: str) -> Artist:
|
||||||
|
if name not in self._by_name:
|
||||||
|
now = dt.datetime.now(dt.UTC)
|
||||||
|
self._by_name[name] = Artist(id=uuid.uuid4(), name=name, created_at=now, updated_at=now)
|
||||||
|
return self._by_name[name]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTrackRepo:
|
||||||
|
def __init__(self, *, fail_on: set[str] | None = None) -> None:
|
||||||
|
self.by_source: dict[tuple[str, str], Track] = {}
|
||||||
|
self.added: list[Track] = []
|
||||||
|
self._fail_on = fail_on or set()
|
||||||
|
|
||||||
|
async def get_by_source(self, source: str, source_id: str) -> Track | None:
|
||||||
|
return self.by_source.get((source, source_id))
|
||||||
|
|
||||||
|
async def add(self, **kw: object) -> Track:
|
||||||
|
source_id = str(kw["source_id"])
|
||||||
|
if source_id in self._fail_on:
|
||||||
|
raise RuntimeError("simulated add failure")
|
||||||
|
now = dt.datetime.now(dt.UTC)
|
||||||
|
track = Track(
|
||||||
|
id=uuid.UUID(str(kw["id"])) if not isinstance(kw["id"], uuid.UUID) else kw["id"],
|
||||||
|
title=str(kw["title"]),
|
||||||
|
artist_id=kw["artist_id"], # type: ignore[arg-type]
|
||||||
|
album_id=None,
|
||||||
|
storage_uri=str(kw["storage_uri"]),
|
||||||
|
file_format=str(kw["file_format"]),
|
||||||
|
file_size=int(kw["file_size"]), # type: ignore[call-overload]
|
||||||
|
source=str(kw["source"]),
|
||||||
|
source_id=source_id,
|
||||||
|
duration_seconds=None,
|
||||||
|
genre=None,
|
||||||
|
year=None,
|
||||||
|
metadata_status=str(kw["metadata_status"]),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
self.by_source[(track.source, track.source_id)] = track
|
||||||
|
self.added.append(track)
|
||||||
|
return track
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStorage:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.saved: dict[str, Path] = {}
|
||||||
|
self.deleted: list[str] = []
|
||||||
|
|
||||||
|
async def save_file(self, key: str, src_path: Path) -> int:
|
||||||
|
self.saved[key] = src_path
|
||||||
|
return 1
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
self.deleted.append(key)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSource:
|
||||||
|
name = "local"
|
||||||
|
|
||||||
|
def __init__(self, files: list[SourceFile]) -> None:
|
||||||
|
self._files = files
|
||||||
|
|
||||||
|
def info(self) -> SourceInfo:
|
||||||
|
return SourceInfo(name=self.name, label="Local", kind="indexable", available=True)
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def scan(self) -> Iterator[SourceFile]:
|
||||||
|
yield from self._files
|
||||||
|
|
||||||
|
|
||||||
|
def _file(source_id: str) -> SourceFile:
|
||||||
|
return SourceFile(
|
||||||
|
source_id=source_id,
|
||||||
|
path=Path("/music") / source_id,
|
||||||
|
suggested_title=Path(source_id).stem,
|
||||||
|
file_format="mp3",
|
||||||
|
file_size=123,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service(tracks: FakeTrackRepo, storage: FakeStorage) -> LibraryImportService:
|
||||||
|
return LibraryImportService(tracks=tracks, artists=FakeArtistRepo(), storage=storage) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_imports_new_files() -> None:
|
||||||
|
tracks, storage = FakeTrackRepo(), FakeStorage()
|
||||||
|
source = FakeSource([_file("a.mp3"), _file("b/c.mp3")])
|
||||||
|
|
||||||
|
summary = await _service(tracks, storage).scan_and_import(source, added_by=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert (summary.seen, summary.imported, summary.skipped, summary.failed) == (2, 2, 0, 0)
|
||||||
|
assert len(tracks.added) == 2
|
||||||
|
assert len(storage.saved) == 2
|
||||||
|
assert all(t.metadata_status == "pending" for t in tracks.added)
|
||||||
|
assert all(t.source == "local" for t in tracks.added)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dedup_skips_already_imported() -> None:
|
||||||
|
tracks, storage = FakeTrackRepo(), FakeStorage()
|
||||||
|
now = dt.datetime.now(dt.UTC)
|
||||||
|
tracks.by_source[("local", "a.mp3")] = Track(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
title="a",
|
||||||
|
artist_id=uuid.uuid4(),
|
||||||
|
album_id=None,
|
||||||
|
storage_uri="k",
|
||||||
|
file_format="mp3",
|
||||||
|
file_size=1,
|
||||||
|
source="local",
|
||||||
|
source_id="a.mp3",
|
||||||
|
duration_seconds=None,
|
||||||
|
genre=None,
|
||||||
|
year=None,
|
||||||
|
metadata_status="pending",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
source = FakeSource([_file("a.mp3"), _file("new.mp3")])
|
||||||
|
|
||||||
|
summary = await _service(tracks, storage).scan_and_import(source, added_by=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert (summary.imported, summary.skipped) == (1, 1)
|
||||||
|
assert len(storage.saved) == 1 # only the new file copied
|
||||||
|
|
||||||
|
|
||||||
|
async def test_per_file_failure_is_isolated_and_rolls_back_storage() -> None:
|
||||||
|
tracks = FakeTrackRepo(fail_on={"bad.mp3"})
|
||||||
|
storage = FakeStorage()
|
||||||
|
source = FakeSource([_file("good.mp3"), _file("bad.mp3")])
|
||||||
|
|
||||||
|
summary = await _service(tracks, storage).scan_and_import(source, added_by=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert (summary.seen, summary.imported, summary.failed) == (2, 1, 1)
|
||||||
|
# The failed import's copied file was cleaned up; the good one stays.
|
||||||
|
assert len(storage.deleted) == 1
|
||||||
|
assert len(tracks.added) == 1
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Unit tests for the local-folder source + registry (no DB, no network)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.infrastructure.sources.local_folder import LocalFolderSource
|
||||||
|
from app.infrastructure.sources.registry import build_source_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides: object) -> Settings:
|
||||||
|
return Settings(**overrides) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_discovers_audio_recursively(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "a.mp3").write_bytes(b"x")
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
(tmp_path / "sub" / "b.flac").write_bytes(b"yy")
|
||||||
|
(tmp_path / "notes.txt").write_bytes(b"ignore me") # non-audio → skipped
|
||||||
|
|
||||||
|
files = list(LocalFolderSource(tmp_path).scan())
|
||||||
|
by_id = {f.source_id: f for f in files}
|
||||||
|
|
||||||
|
assert set(by_id) == {"a.mp3", "sub/b.flac"}
|
||||||
|
assert by_id["a.mp3"].file_format == "mp3"
|
||||||
|
assert by_id["a.mp3"].suggested_title == "a"
|
||||||
|
assert by_id["sub/b.flac"].file_format == "flac"
|
||||||
|
assert by_id["sub/b.flac"].file_size == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_id_is_stable_relative_path(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "x.opus").write_bytes(b"z")
|
||||||
|
[only] = list(LocalFolderSource(tmp_path).scan())
|
||||||
|
assert only.source_id == "x.opus"
|
||||||
|
assert only.path == tmp_path / "x.opus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_available_false_when_missing(tmp_path: Path) -> None:
|
||||||
|
source = LocalFolderSource(tmp_path / "nope")
|
||||||
|
assert source.is_available() is False
|
||||||
|
assert list(source.scan()) == [] # scanning an unavailable source yields nothing
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_reports_kind_and_availability(tmp_path: Path) -> None:
|
||||||
|
info = LocalFolderSource(tmp_path).info()
|
||||||
|
assert info.name == "local"
|
||||||
|
assert info.kind == "indexable"
|
||||||
|
assert info.available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_registers_local_when_path_set(tmp_path: Path) -> None:
|
||||||
|
registry = build_source_registry(_settings(local_media_import_path=tmp_path))
|
||||||
|
names = {info.name for info in registry.infos()}
|
||||||
|
assert names == {"local"}
|
||||||
|
assert registry.indexable("local").is_available() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_empty_when_path_unset() -> None:
|
||||||
|
registry = build_source_registry(_settings(local_media_import_path=None))
|
||||||
|
assert registry.infos() == []
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Unit tests for MetadataEnrichmentService — DB-free, in-memory fakes.
|
||||||
|
|
||||||
|
Covers the §6.2 orchestration contract: tag-first merge, AcoustID fallback,
|
||||||
|
artist/album resolution, status transitions, and the hard invariants
|
||||||
|
(``manual`` untouched, graceful degradation, idempotent gap-fill).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.application.metadata_service import MetadataEnrichmentService
|
||||||
|
from app.domain.entities import Artist, Track
|
||||||
|
from app.domain.entities.album import Album
|
||||||
|
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
_UNKNOWN = "Unknown Artist"
|
||||||
|
|
||||||
|
|
||||||
|
def _track(*, metadata_status: str = "pending", title: str = "raw-stem") -> Track:
|
||||||
|
now = dt.datetime.now(dt.UTC)
|
||||||
|
return Track(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
title=title,
|
||||||
|
artist_id=uuid.uuid4(), # the "Unknown Artist" id
|
||||||
|
album_id=None,
|
||||||
|
storage_uri="tracks/aa/song.mp3",
|
||||||
|
file_format="mp3",
|
||||||
|
file_size=123,
|
||||||
|
source="upload",
|
||||||
|
source_id="deadbeef",
|
||||||
|
duration_seconds=None,
|
||||||
|
genre=None,
|
||||||
|
year=None,
|
||||||
|
metadata_status=metadata_status,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTrackRepo:
|
||||||
|
def __init__(self, track: Track | None) -> None:
|
||||||
|
self._track = track
|
||||||
|
self.applied: dict[str, object] | None = None
|
||||||
|
|
||||||
|
async def get_by_id(self, track_id: uuid.UUID) -> Track | None:
|
||||||
|
return self._track
|
||||||
|
|
||||||
|
async def apply_enrichment(self, track_id: uuid.UUID, **kw: object) -> Track:
|
||||||
|
self.applied = kw
|
||||||
|
return self._track # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeArtistRepo:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.created: list[str] = []
|
||||||
|
|
||||||
|
async def get_or_create(self, name: str) -> Artist:
|
||||||
|
self.created.append(name)
|
||||||
|
now = dt.datetime.now(dt.UTC)
|
||||||
|
return Artist(id=uuid.uuid4(), name=name, created_at=now, updated_at=now)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAlbumRepo:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.created: list[tuple[str, uuid.UUID]] = []
|
||||||
|
|
||||||
|
async def get_or_create(
|
||||||
|
self, *, title: str, artist_id: uuid.UUID, year: int | None, musicbrainz_id: str | None
|
||||||
|
) -> Album:
|
||||||
|
self.created.append((title, artist_id))
|
||||||
|
now = dt.datetime.now(dt.UTC)
|
||||||
|
return Album(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
title=title,
|
||||||
|
artist_id=artist_id,
|
||||||
|
year=year,
|
||||||
|
cover_path=None,
|
||||||
|
musicbrainz_id=musicbrainz_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStorage:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def as_local_path(self, key: str) -> AsyncIterator[Path]:
|
||||||
|
yield Path("/tmp") / key
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTagReader:
|
||||||
|
def __init__(self, tags: AudioTags | None) -> None:
|
||||||
|
self._tags = tags
|
||||||
|
|
||||||
|
async def read(self, path: Path) -> AudioTags | None:
|
||||||
|
return self._tags
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFingerprinter:
|
||||||
|
def __init__(self, fp: Fingerprint | None, *, available: bool = True) -> None:
|
||||||
|
self._fp = fp
|
||||||
|
self._available = available
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
async def calculate(self, path: Path) -> Fingerprint | None:
|
||||||
|
return self._fp
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAcoustId:
|
||||||
|
def __init__(self, match: RecordingMatch | None, *, available: bool = True) -> None:
|
||||||
|
self._match = match
|
||||||
|
self._available = available
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
|
||||||
|
self.calls += 1
|
||||||
|
return self._match
|
||||||
|
|
||||||
|
|
||||||
|
def _service(
|
||||||
|
*,
|
||||||
|
track: Track | None,
|
||||||
|
tags: AudioTags | None = None,
|
||||||
|
fp: Fingerprint | None = None,
|
||||||
|
match: RecordingMatch | None = None,
|
||||||
|
fp_available: bool = True,
|
||||||
|
acoustid_available: bool = True,
|
||||||
|
) -> tuple[MetadataEnrichmentService, FakeTrackRepo, FakeArtistRepo, FakeAlbumRepo, FakeAcoustId]:
|
||||||
|
tracks = FakeTrackRepo(track)
|
||||||
|
artists = FakeArtistRepo()
|
||||||
|
albums = FakeAlbumRepo()
|
||||||
|
acoustid = FakeAcoustId(match, available=acoustid_available)
|
||||||
|
service = MetadataEnrichmentService(
|
||||||
|
tracks=tracks, # type: ignore[arg-type]
|
||||||
|
artists=artists, # type: ignore[arg-type]
|
||||||
|
albums=albums, # type: ignore[arg-type]
|
||||||
|
storage=FakeStorage(), # type: ignore[arg-type]
|
||||||
|
tag_reader=FakeTagReader(tags), # type: ignore[arg-type]
|
||||||
|
fingerprinter=FakeFingerprinter(fp, available=fp_available), # type: ignore[arg-type]
|
||||||
|
acoustid=acoustid, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
return service, tracks, artists, albums, acoustid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tags_only_enriches_and_relinks_artist_and_album() -> None:
|
||||||
|
track = _track()
|
||||||
|
tags = AudioTags(
|
||||||
|
title="Real Title",
|
||||||
|
artist="Pink Floyd",
|
||||||
|
album="The Wall",
|
||||||
|
genre="Rock",
|
||||||
|
year=1979,
|
||||||
|
track_number=1,
|
||||||
|
duration_seconds=222,
|
||||||
|
)
|
||||||
|
service, tracks, artists, albums, acoustid = _service(track=track, tags=tags)
|
||||||
|
|
||||||
|
result = await service.enrich(track.id)
|
||||||
|
|
||||||
|
assert result.status == "enriched"
|
||||||
|
assert acoustid.calls == 0 # no fingerprint → no lookup needed
|
||||||
|
assert "Pink Floyd" in artists.created
|
||||||
|
assert albums.created and albums.created[0][0] == "The Wall"
|
||||||
|
applied = tracks.applied
|
||||||
|
assert applied is not None
|
||||||
|
assert applied["title"] == "Real Title"
|
||||||
|
assert applied["genre"] == "Rock"
|
||||||
|
assert applied["year"] == 1979
|
||||||
|
assert applied["track_number"] == 1
|
||||||
|
assert applied["duration_seconds"] == 222
|
||||||
|
assert applied["metadata_status"] == "enriched"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_track_is_never_touched() -> None:
|
||||||
|
track = _track(metadata_status="manual")
|
||||||
|
service, tracks, _, _, _ = _service(track=track, tags=AudioTags(artist="X"))
|
||||||
|
|
||||||
|
result = await service.enrich(track.id)
|
||||||
|
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert tracks.applied is None # nothing written
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_track_is_a_clean_noop() -> None:
|
||||||
|
service, tracks, _, _, _ = _service(track=None)
|
||||||
|
|
||||||
|
result = await service.enrich(uuid.uuid4())
|
||||||
|
|
||||||
|
assert result.status == "skipped"
|
||||||
|
assert tracks.applied is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nothing_found_marks_failed() -> None:
|
||||||
|
track = _track()
|
||||||
|
# No tags, no fingerprint → no identity at all.
|
||||||
|
service, tracks, artists, albums, _acoustid = _service(track=track, tags=None, fp=None)
|
||||||
|
|
||||||
|
result = await service.enrich(track.id)
|
||||||
|
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert artists.created == [] # artist stays the original unknown
|
||||||
|
assert albums.created == []
|
||||||
|
applied = tracks.applied
|
||||||
|
assert applied is not None
|
||||||
|
assert applied["artist_id"] == track.artist_id # fallback kept
|
||||||
|
assert applied["metadata_status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_acoustid_path_fills_when_tags_absent() -> None:
|
||||||
|
track = _track()
|
||||||
|
fp = Fingerprint(fingerprint="AQAAxyz", duration_seconds=200)
|
||||||
|
match = RecordingMatch(
|
||||||
|
acoustid="acoustid-uuid",
|
||||||
|
score=0.95,
|
||||||
|
recording_mbid="mb-recording-id",
|
||||||
|
title="Identified Title",
|
||||||
|
artist="Daft Punk",
|
||||||
|
album="Discovery",
|
||||||
|
)
|
||||||
|
service, tracks, artists, _albums, acoustid = _service(
|
||||||
|
track=track, tags=None, fp=fp, match=match
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.enrich(track.id)
|
||||||
|
|
||||||
|
assert result.status == "enriched"
|
||||||
|
assert result.matched_mbid == "mb-recording-id"
|
||||||
|
assert acoustid.calls == 1
|
||||||
|
applied = tracks.applied
|
||||||
|
assert applied is not None
|
||||||
|
assert applied["title"] == "Identified Title"
|
||||||
|
assert applied["musicbrainz_id"] == "mb-recording-id"
|
||||||
|
assert applied["acoustid_fingerprint"] == "acoustid-uuid"
|
||||||
|
assert "Daft Punk" in artists.created
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tags_win_over_acoustid_for_overlapping_fields() -> None:
|
||||||
|
track = _track()
|
||||||
|
fp = Fingerprint(fingerprint="AQAA", duration_seconds=200)
|
||||||
|
tags = AudioTags(title="Tagged Title", artist="Tagged Artist")
|
||||||
|
match = RecordingMatch(
|
||||||
|
acoustid="aid",
|
||||||
|
score=0.9,
|
||||||
|
recording_mbid="mbid",
|
||||||
|
title="AcoustID Title",
|
||||||
|
artist="AcoustID Artist",
|
||||||
|
)
|
||||||
|
service, tracks, artists, _albums, _acoustid = _service(
|
||||||
|
track=track, tags=tags, fp=fp, match=match
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.enrich(track.id)
|
||||||
|
|
||||||
|
applied = tracks.applied
|
||||||
|
assert applied is not None
|
||||||
|
assert applied["title"] == "Tagged Title" # tag preferred
|
||||||
|
assert "Tagged Artist" in artists.created
|
||||||
|
# but the MBID from AcoustID is still captured
|
||||||
|
assert applied["musicbrainz_id"] == "mbid"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fingerprint_skipped_when_acoustid_unavailable() -> None:
|
||||||
|
track = _track()
|
||||||
|
fp = Fingerprint(fingerprint="AQAA", duration_seconds=200)
|
||||||
|
service, _tracks, _artists, _albums, acoustid = _service(
|
||||||
|
track=track, tags=AudioTags(artist="Tagged"), fp=fp, acoustid_available=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.enrich(track.id)
|
||||||
|
|
||||||
|
# tags still enrich, but no AcoustID call is attempted
|
||||||
|
assert acoustid.calls == 0
|
||||||
|
assert result.status == "enriched"
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Integration tests for sources: enumeration + the real import path.
|
||||||
|
|
||||||
|
Requires a reachable Postgres; skips otherwise. The scan worker task is invoked
|
||||||
|
directly (no Redis needed) so the full DB + storage import path is covered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
|
||||||
|
from app.infrastructure.db.repositories import (
|
||||||
|
SqlAlchemyRefreshTokenRepository,
|
||||||
|
SqlAlchemyUserRepository,
|
||||||
|
)
|
||||||
|
from asgi_lifespan import LifespanManager
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
_db_reachable_cache: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_reachable() -> bool:
|
||||||
|
global _db_reachable_cache
|
||||||
|
if _db_reachable_cache is not None:
|
||||||
|
return _db_reachable_cache
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(3):
|
||||||
|
async with get_engine().connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
_db_reachable_cache = True
|
||||||
|
except Exception:
|
||||||
|
_db_reachable_cache = False
|
||||||
|
return _db_reachable_cache
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]:
|
||||||
|
if not await _db_reachable():
|
||||||
|
pytest.skip("Postgres not reachable — integration test skipped.")
|
||||||
|
|
||||||
|
media = tmp_path / "media"
|
||||||
|
music = tmp_path / "music"
|
||||||
|
media.mkdir()
|
||||||
|
music.mkdir()
|
||||||
|
# Two audio files (+ a non-audio file that must be ignored) in a subfolder.
|
||||||
|
(music / "one.mp3").write_bytes(b"first track bytes" * 8)
|
||||||
|
(music / "artist").mkdir()
|
||||||
|
(music / "artist" / "two.flac").write_bytes(b"second track bytes" * 8)
|
||||||
|
(music / "cover.txt").write_bytes(b"not audio")
|
||||||
|
|
||||||
|
os.environ["MEDIA_PATH"] = str(media)
|
||||||
|
os.environ["LOCAL_MEDIA_IMPORT_PATH"] = str(music)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
import app.infrastructure.storage.provider as _storage_provider
|
||||||
|
|
||||||
|
_storage_provider._storage = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_engine().begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
from app.application.user_service import UserService
|
||||||
|
from app.core.security import Argon2PasswordHasher
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
await UserService(
|
||||||
|
users=SqlAlchemyUserRepository(session),
|
||||||
|
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
|
||||||
|
hasher=Argon2PasswordHasher(),
|
||||||
|
).create_user(username="admin", password="adminpass1", is_superuser=True)
|
||||||
|
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
async with LifespanManager(app):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
async with get_engine().begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await dispose_engine()
|
||||||
|
finally:
|
||||||
|
_storage_provider._storage = None
|
||||||
|
os.environ.pop("MEDIA_PATH", None)
|
||||||
|
os.environ.pop("LOCAL_MEDIA_IMPORT_PATH", None)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(api: AsyncClient) -> str:
|
||||||
|
resp = await api.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "admin", "password": "adminpass1"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return str(resp.json()["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_sources_includes_local(api: AsyncClient) -> None:
|
||||||
|
token = await _login(api)
|
||||||
|
resp = await api.get("/api/v1/sources", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
sources = {s["name"]: s for s in resp.json()}
|
||||||
|
assert "local" in sources
|
||||||
|
assert sources["local"]["available"] is True
|
||||||
|
assert sources["local"]["kind"] == "indexable"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_local_import_creates_streamable_tracks(api: AsyncClient) -> None:
|
||||||
|
token = await _login(api)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# Run the worker task directly (bypasses Redis); it imports against the DB.
|
||||||
|
from app.workers.tasks.import_task import scan_local_folder
|
||||||
|
|
||||||
|
summary = await scan_local_folder({}, source="local", added_by=None)
|
||||||
|
assert summary["seen"] == 2
|
||||||
|
assert summary["imported"] == 2
|
||||||
|
assert summary["failed"] == 0
|
||||||
|
|
||||||
|
# A second run is idempotent — everything already indexed.
|
||||||
|
again = await scan_local_folder({}, source="local", added_by=None)
|
||||||
|
assert again["imported"] == 0
|
||||||
|
assert again["skipped"] == 2
|
||||||
|
|
||||||
|
listing = await api.get("/api/v1/tracks", headers=headers)
|
||||||
|
assert listing.status_code == 200
|
||||||
|
items = listing.json()["items"]
|
||||||
|
assert len(items) == 2
|
||||||
|
titles = {t["title"] for t in items}
|
||||||
|
assert titles == {"one", "two"}
|
||||||
|
|
||||||
|
# And the imported file actually streams back.
|
||||||
|
track_id = items[0]["id"]
|
||||||
|
stream = await api.get(f"/api/v1/stream/{track_id}", headers=headers)
|
||||||
|
assert stream.status_code == 200
|
||||||
|
assert len(stream.content) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scan_requires_admin(api: AsyncClient) -> None:
|
||||||
|
# The scan endpoint enqueues to Redis; here we only assert it's admin-gated.
|
||||||
|
resp = await api.post("/api/v1/sources/local/scan")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Integration tests for the Subsonic /rest layer (happy path per endpoint group).
|
||||||
|
|
||||||
|
Requires a reachable Postgres; skips otherwise (mirrors test_upload_stream_api).
|
||||||
|
Drives the real ASGI app: seeds a user + a track, mints a Subsonic app-password
|
||||||
|
via the native API, then exercises /rest with real query-string auth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
|
||||||
|
from app.infrastructure.db.repositories import (
|
||||||
|
SqlAlchemyRefreshTokenRepository,
|
||||||
|
SqlAlchemyUserRepository,
|
||||||
|
)
|
||||||
|
from asgi_lifespan import LifespanManager
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
_db_reachable_cache: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_reachable() -> bool:
|
||||||
|
global _db_reachable_cache
|
||||||
|
if _db_reachable_cache is not None:
|
||||||
|
return _db_reachable_cache
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(3):
|
||||||
|
async with get_engine().connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
_db_reachable_cache = True
|
||||||
|
except Exception:
|
||||||
|
_db_reachable_cache = False
|
||||||
|
return _db_reachable_cache
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]:
|
||||||
|
if not await _db_reachable():
|
||||||
|
pytest.skip("Postgres not reachable — integration test skipped.")
|
||||||
|
|
||||||
|
os.environ["MEDIA_PATH"] = str(tmp_path)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
import app.infrastructure.storage.provider as _storage_provider
|
||||||
|
|
||||||
|
_storage_provider._storage = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_engine().begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
from app.application.user_service import UserService
|
||||||
|
from app.core.security import Argon2PasswordHasher
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
await UserService(
|
||||||
|
users=SqlAlchemyUserRepository(session),
|
||||||
|
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
|
||||||
|
hasher=Argon2PasswordHasher(),
|
||||||
|
).create_user(username="testuser", password="testpass1", is_superuser=False)
|
||||||
|
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
async with LifespanManager(app):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
async with get_engine().begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await dispose_engine()
|
||||||
|
finally:
|
||||||
|
_storage_provider._storage = None
|
||||||
|
os.environ.pop("MEDIA_PATH", None)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(api: AsyncClient) -> str:
|
||||||
|
resp = await api.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "testuser", "password": "testpass1"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return str(resp.json()["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _subsonic_password(api: AsyncClient, token: str) -> str:
|
||||||
|
resp = await api.get(
|
||||||
|
"/api/v1/users/me/subsonic-password", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return str(resp.json()["password"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_track(api: AsyncClient, token: str) -> str:
|
||||||
|
resp = await api.post(
|
||||||
|
"/api/v1/upload",
|
||||||
|
files={"file": ("song.mp3", b"audio bytes for subsonic" * 20, "audio/mpeg")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return str(resp.json()["track_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_params(password: str) -> dict[str, str]:
|
||||||
|
# Legacy plaintext password auth (p=) keeps the test simple; t+s is covered
|
||||||
|
# by the auth-service unit tests.
|
||||||
|
return {"u": "testuser", "p": password, "c": "pytest", "v": "1.16.1", "f": "json"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(api: AsyncClient) -> tuple[dict[str, str], str]:
|
||||||
|
token = await _login(api)
|
||||||
|
password = await _subsonic_password(api, token)
|
||||||
|
track_id = await _seed_track(api, token)
|
||||||
|
return _auth_params(password), track_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ping_ok(api: AsyncClient) -> None:
|
||||||
|
params, _ = await _setup(api)
|
||||||
|
resp = await api.get("/rest/ping", params=params)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["subsonic-response"]["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ping_bad_credentials_returns_code_40(api: AsyncClient) -> None:
|
||||||
|
await _setup(api)
|
||||||
|
resp = await api.get(
|
||||||
|
"/rest/ping",
|
||||||
|
params={"u": "testuser", "p": "wrong", "c": "pytest", "v": "1.16.1", "f": "json"},
|
||||||
|
)
|
||||||
|
# Subsonic errors are HTTP 200 with the failure inside the envelope.
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()["subsonic-response"]
|
||||||
|
assert body["status"] == "failed"
|
||||||
|
assert body["error"]["code"] == 40
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ping_xml_default(api: AsyncClient) -> None:
|
||||||
|
params, _ = await _setup(api)
|
||||||
|
xml_params = {k: v for k, v in params.items() if k != "f"}
|
||||||
|
resp = await api.get("/rest/ping", params=xml_params)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("application/xml")
|
||||||
|
root = ET.fromstring(resp.content)
|
||||||
|
assert root.attrib["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_artists(api: AsyncClient) -> None:
|
||||||
|
params, _ = await _setup(api)
|
||||||
|
resp = await api.get("/rest/getArtists", params=params)
|
||||||
|
body = resp.json()["subsonic-response"]
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert "artists" in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_album_list2(api: AsyncClient) -> None:
|
||||||
|
params, _ = await _setup(api)
|
||||||
|
resp = await api.get("/rest/getAlbumList2", params={**params, "type": "newest"})
|
||||||
|
body = resp.json()["subsonic-response"]
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert "albumList2" in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search3_finds_song(api: AsyncClient) -> None:
|
||||||
|
params, track_id = await _setup(api)
|
||||||
|
resp = await api.get("/rest/search3", params={**params, "query": "song"})
|
||||||
|
result = resp.json()["subsonic-response"]["searchResult3"]
|
||||||
|
song_ids = [s["id"] for s in result.get("song", [])]
|
||||||
|
assert f"tr-{track_id}" in song_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_song(api: AsyncClient) -> None:
|
||||||
|
params, track_id = await _setup(api)
|
||||||
|
resp = await api.get("/rest/getSong", params={**params, "id": f"tr-{track_id}"})
|
||||||
|
song = resp.json()["subsonic-response"]["song"]
|
||||||
|
assert song["id"] == f"tr-{track_id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_returns_audio(api: AsyncClient) -> None:
|
||||||
|
params, track_id = await _setup(api)
|
||||||
|
resp = await api.get("/rest/stream", params={**params, "id": f"tr-{track_id}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("audio/")
|
||||||
|
assert resp.content == b"audio bytes for subsonic" * 20
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_cover_art_placeholder(api: AsyncClient) -> None:
|
||||||
|
params, track_id = await _setup(api)
|
||||||
|
resp = await api.get("/rest/getCoverArt", params={**params, "id": f"tr-{track_id}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_playlist_lifecycle(api: AsyncClient) -> None:
|
||||||
|
params, track_id = await _setup(api)
|
||||||
|
|
||||||
|
created = await api.get(
|
||||||
|
"/rest/createPlaylist", params={**params, "name": "Roadtrip", "songId": f"tr-{track_id}"}
|
||||||
|
)
|
||||||
|
playlist = created.json()["subsonic-response"]["playlist"]
|
||||||
|
assert playlist["name"] == "Roadtrip"
|
||||||
|
assert playlist["songCount"] == 1
|
||||||
|
playlist_id = playlist["id"]
|
||||||
|
|
||||||
|
listed = await api.get("/rest/getPlaylists", params=params)
|
||||||
|
names = [p["name"] for p in listed.json()["subsonic-response"]["playlists"]["playlist"]]
|
||||||
|
assert "Roadtrip" in names
|
||||||
|
|
||||||
|
deleted = await api.get("/rest/deletePlaylist", params={**params, "id": playlist_id})
|
||||||
|
assert deleted.json()["subsonic-response"]["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_star_and_scrobble(api: AsyncClient) -> None:
|
||||||
|
params, track_id = await _setup(api)
|
||||||
|
star = await api.get("/rest/star", params={**params, "id": f"tr-{track_id}"})
|
||||||
|
assert star.json()["subsonic-response"]["status"] == "ok"
|
||||||
|
|
||||||
|
scrobble = await api.get(
|
||||||
|
"/rest/scrobble", params={**params, "id": f"tr-{track_id}", "submission": "true"}
|
||||||
|
)
|
||||||
|
assert scrobble.json()["subsonic-response"]["status"] == "ok"
|
||||||
|
|
||||||
|
# The like landed in the append-only log → it surfaces via the native API.
|
||||||
|
token = await _login(api)
|
||||||
|
likes = await api.get("/api/v1/likes", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert any(item["id"] == track_id for item in likes.json()["items"])
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Unit tests for SubsonicAuthService — verification + app-password lifecycle.
|
||||||
|
|
||||||
|
DB-free: uses the in-memory user repository and a real cipher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||||
|
from app.core.security import SubsonicPasswordCipher
|
||||||
|
from app.domain.errors import AuthenticationError, ValidationError
|
||||||
|
|
||||||
|
from tests.fakes import InMemoryUserRepository
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
_KNOWN_PASSWORD = "s3cret-app-password"
|
||||||
|
|
||||||
|
|
||||||
|
def _md5(value: str) -> str:
|
||||||
|
return hashlib.md5(value.encode(), usedforsecurity=False).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _service_with_user(*, password: str | None = _KNOWN_PASSWORD, active: bool = True):
|
||||||
|
users = InMemoryUserRepository()
|
||||||
|
cipher = SubsonicPasswordCipher("test-key")
|
||||||
|
user = await users.add(username="alice", password_hash="x", is_superuser=False)
|
||||||
|
if not active:
|
||||||
|
await users.set_active(user.id, False)
|
||||||
|
if password is not None:
|
||||||
|
await users.set_subsonic_password_enc(user.id, cipher.encrypt(password))
|
||||||
|
service = SubsonicAuthService(users=users, cipher=cipher)
|
||||||
|
return service, user
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_token_salt_success() -> None:
|
||||||
|
service, user = await _service_with_user()
|
||||||
|
salt = "abcdef"
|
||||||
|
token = _md5(_KNOWN_PASSWORD + salt)
|
||||||
|
result = await service.authenticate(username="alice", token=token, salt=salt, password=None)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_plain_password_success() -> None:
|
||||||
|
service, user = await _service_with_user()
|
||||||
|
result = await service.authenticate(
|
||||||
|
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_enc_password_success() -> None:
|
||||||
|
service, user = await _service_with_user()
|
||||||
|
enc = "enc:" + _KNOWN_PASSWORD.encode().hex()
|
||||||
|
result = await service.authenticate(username="alice", token=None, salt=None, password=enc)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_wrong_token_fails() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="alice", token=_md5("wrong" + "abc"), salt="abc", password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_wrong_password_fails() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(username="alice", token=None, salt=None, password="nope")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_unknown_user_fails() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="ghost", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_inactive_user_fails() -> None:
|
||||||
|
service, _ = await _service_with_user(active=False)
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_no_password_set_fails() -> None:
|
||||||
|
service, _ = await _service_with_user(password=None)
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_missing_username_is_validation_error() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await service.authenticate(username=None, token=None, salt=None, password=_KNOWN_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_missing_credentials_is_validation_error() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await service.authenticate(username="alice", token=None, salt=None, password=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rotate_then_authenticate() -> None:
|
||||||
|
users = InMemoryUserRepository()
|
||||||
|
cipher = SubsonicPasswordCipher("test-key")
|
||||||
|
user = await users.add(username="bob", password_hash="x", is_superuser=False)
|
||||||
|
service = SubsonicAuthService(users=users, cipher=cipher)
|
||||||
|
|
||||||
|
password = await service.rotate(user.id)
|
||||||
|
result = await service.authenticate(username="bob", token=None, salt=None, password=password)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reveal_generates_then_is_stable() -> None:
|
||||||
|
users = InMemoryUserRepository()
|
||||||
|
cipher = SubsonicPasswordCipher("test-key")
|
||||||
|
user = await users.add(username="cara", password_hash="x", is_superuser=False)
|
||||||
|
service = SubsonicAuthService(users=users, cipher=cipher)
|
||||||
|
|
||||||
|
first = await service.reveal(user.id)
|
||||||
|
second = await service.reveal(user.id)
|
||||||
|
assert first == second # lazily generated once, then stable
|
||||||
|
|
||||||
|
rotated = await service.rotate(user.id)
|
||||||
|
assert rotated != first
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Unit tests for the Subsonic response envelope (XML + JSON shapes)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from app.api.rest.envelope import (
|
||||||
|
SUBSONIC_API_VERSION,
|
||||||
|
subsonic_error,
|
||||||
|
subsonic_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_root(body: bytes) -> ET.Element:
|
||||||
|
return ET.fromstring(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _local(tag: str) -> str:
|
||||||
|
return tag.rsplit("}", 1)[-1] # strip namespace
|
||||||
|
|
||||||
|
|
||||||
|
def test_ok_xml_shape() -> None:
|
||||||
|
resp = subsonic_response({"license": {"valid": True}}, fmt="xml")
|
||||||
|
assert resp.media_type.startswith("application/xml")
|
||||||
|
root = _xml_root(resp.body)
|
||||||
|
assert _local(root.tag) == "subsonic-response"
|
||||||
|
assert root.attrib["status"] == "ok"
|
||||||
|
assert root.attrib["version"] == SUBSONIC_API_VERSION
|
||||||
|
assert root.attrib["type"] == "mcma"
|
||||||
|
child = root[0]
|
||||||
|
assert _local(child.tag) == "license"
|
||||||
|
assert child.attrib["valid"] == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ok_json_shape() -> None:
|
||||||
|
resp = subsonic_response({"license": {"valid": True}}, fmt="json")
|
||||||
|
assert resp.media_type.startswith("application/json")
|
||||||
|
payload = json.loads(resp.body)["subsonic-response"]
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
assert payload["version"] == SUBSONIC_API_VERSION
|
||||||
|
assert payload["type"] == "mcma"
|
||||||
|
assert payload["license"] == {"valid": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_xml_shape() -> None:
|
||||||
|
resp = subsonic_error(40, "Wrong username or password.", fmt="xml")
|
||||||
|
root = _xml_root(resp.body)
|
||||||
|
assert root.attrib["status"] == "failed"
|
||||||
|
error = root[0]
|
||||||
|
assert _local(error.tag) == "error"
|
||||||
|
assert error.attrib["code"] == "40"
|
||||||
|
assert error.attrib["message"] == "Wrong username or password."
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_json_shape() -> None:
|
||||||
|
resp = subsonic_error(70, "Not found.", fmt="json")
|
||||||
|
payload = json.loads(resp.body)["subsonic-response"]
|
||||||
|
assert payload["status"] == "failed"
|
||||||
|
assert payload["error"] == {"code": 70, "message": "Not found."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_format_is_xml() -> None:
|
||||||
|
resp = subsonic_response(fmt=None)
|
||||||
|
assert resp.media_type.startswith("application/xml")
|
||||||
|
assert _xml_root(resp.body).attrib["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_renders_repeated_elements() -> None:
|
||||||
|
payload = {"genres": {"genre": [{"value": "Rock"}, {"value": "Jazz"}]}}
|
||||||
|
root = _xml_root(subsonic_response(payload, fmt="xml").body)
|
||||||
|
genres = root[0]
|
||||||
|
values = [g.text for g in genres]
|
||||||
|
assert values == ["Rock", "Jazz"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Unit tests for Subsonic crypto + id helpers (no DB, no network)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.api.rest import ids
|
||||||
|
from app.api.rest.ids import IdKind
|
||||||
|
from app.core.security import SubsonicPasswordCipher, generate_subsonic_password
|
||||||
|
from app.domain.errors import AuthenticationError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_subsonic_password_is_long_and_unique() -> None:
|
||||||
|
a = generate_subsonic_password()
|
||||||
|
b = generate_subsonic_password()
|
||||||
|
assert a != b
|
||||||
|
assert len(a) >= 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_cipher_roundtrip() -> None:
|
||||||
|
cipher = SubsonicPasswordCipher("a-secret-key")
|
||||||
|
plaintext = generate_subsonic_password()
|
||||||
|
token = cipher.encrypt(plaintext)
|
||||||
|
assert token != plaintext
|
||||||
|
assert cipher.decrypt(token) == plaintext
|
||||||
|
|
||||||
|
|
||||||
|
def test_cipher_token_then_md5_matches() -> None:
|
||||||
|
"""The decrypted app-password must reproduce a client's t=md5(password+salt)."""
|
||||||
|
cipher = SubsonicPasswordCipher("a-secret-key")
|
||||||
|
password = generate_subsonic_password()
|
||||||
|
enc = cipher.encrypt(password)
|
||||||
|
|
||||||
|
salt = "c19b2d"
|
||||||
|
decrypted = cipher.decrypt(enc)
|
||||||
|
expected = hashlib.md5((decrypted + salt).encode(), usedforsecurity=False).hexdigest()
|
||||||
|
client_token = hashlib.md5((password + salt).encode(), usedforsecurity=False).hexdigest()
|
||||||
|
assert expected == client_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_cipher_wrong_key_fails() -> None:
|
||||||
|
token = SubsonicPasswordCipher("key-one").encrypt("hunter2")
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
SubsonicPasswordCipher("key-two").decrypt(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_encode_decode_roundtrip() -> None:
|
||||||
|
value = uuid.uuid4()
|
||||||
|
assert ids.decode_track(ids.encode_track(value)) == value
|
||||||
|
assert ids.decode_album(ids.encode_album(value)) == value
|
||||||
|
assert ids.decode_artist(ids.encode_artist(value)) == value
|
||||||
|
assert ids.decode_playlist(ids.encode_playlist(value)) == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_parse_returns_kind() -> None:
|
||||||
|
value = uuid.uuid4()
|
||||||
|
kind, parsed = ids.parse(ids.encode_album(value))
|
||||||
|
assert kind is IdKind.ALBUM
|
||||||
|
assert parsed == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_wrong_prefix_rejected() -> None:
|
||||||
|
track = ids.encode_track(uuid.uuid4())
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
ids.decode_album(track)
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_malformed_rejected() -> None:
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
ids.parse("not-a-real-id")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Unit tests for the mutagen tag-parsing helpers — pure, no files."""
|
||||||
|
|
||||||
|
from app.infrastructure.metadata.tags import _first, _parse_track_number, _parse_year
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_takes_head_of_list() -> None:
|
||||||
|
assert _first(["Pink Floyd", "other"]) == "Pink Floyd"
|
||||||
|
assert _first("Solo") == "Solo"
|
||||||
|
assert _first([]) is None
|
||||||
|
assert _first(None) is None
|
||||||
|
assert _first([" "]) is None # whitespace-only → None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_year_extracts_four_digits() -> None:
|
||||||
|
assert _parse_year(["1979"]) == 1979
|
||||||
|
assert _parse_year(["1979-01-02"]) == 1979
|
||||||
|
assert _parse_year("2021-12") == 2021
|
||||||
|
assert _parse_year(["no year"]) is None
|
||||||
|
assert _parse_year(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_track_number_handles_slash_form() -> None:
|
||||||
|
assert _parse_track_number(["3/12"]) == 3
|
||||||
|
assert _parse_track_number(["7"]) == 7
|
||||||
|
assert _parse_track_number("1/10") == 1
|
||||||
|
assert _parse_track_number(["A1"]) is None
|
||||||
|
assert _parse_track_number(None) is None
|
||||||
Reference in New Issue
Block a user