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:
@@ -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)
|
||||
Reference in New Issue
Block a user