feat(subsonic): response envelope, id scheme, and error mapping

- envelope: one serializer emitting the <subsonic-response> wrapper in XML
  (default) and JSON (f=json), carrying status/version/type/serverVersion
- ids: stable, reversible type-prefixed ids (tr-/al-/ar-/pl-) ↔ UUIDs
- errors: /rest requests render the Subsonic error envelope (always HTTP 200)
  with standard codes (10 missing param, 40 wrong creds, 50, 70 not found)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-08 18:23:30 +03:00
parent 7a17e3babd
commit b975164fc2
5 changed files with 352 additions and 4 deletions
+102
View File
@@ -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)
+75
View File
@@ -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)