Vellum/internal/subsonic/node.go
mithral 6da194045b Waves 9-10: Jellyfin and Subsonic clients can browse and play
Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.

Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
  credential that is not the session cookie, and letting two agents each invent
  a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
  value so only its hash is kept, while a Subsonic key must be recoverable
  because verifying `t` means computing md5(key + salt) server-side. token_plain
  is therefore the one column in the schema holding a usable credential. It is a
  generated random secret, never a user password, and it is revocable.

Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
  /Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
  /Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
  /Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
  Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
  matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
  SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
  with a DirectStreamUrl carrying the api_key. A client that disables both
  direct modes is demanding a transcode and is refused with ErrorCode
  "NoCompatibleStream" — rather than handed a TranscodingUrl that would never
  resolve, which is how a client hangs forever.

Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
  getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
  getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
  plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
  returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
  HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
  single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.

Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.

Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
  PlaybackInfo showing SupportsTranscoding=false, and a Range request to
  /Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
  returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
  admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
  /api/v1/version all still answer correctly.

Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
2026-10-11 15:05:43 -04:00

182 lines
5.2 KiB
Go

package subsonic
import (
"bytes"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"strconv"
)
// attr is one attribute of an element. The value is typed — string, int64 or
// bool — rather than a string so the JSON form can emit a real number or
// boolean where the protocol expects one, while XML always stringifies it.
type attr struct {
name string
value any
}
// node is one element in a response tree. Attributes and children are kept in
// insertion order, which XML needs (attribute order is cosmetic but stable
// output is easier to test) and which lets JSON grouping stay deterministic.
//
// The tree exists instead of Go structs because Subsonic's XML and JSON forms
// are not the same shape: XML uses attributes, while JSON flattens attributes
// and child elements into one object and turns repeated child names into
// arrays. A single tree with two serializers keeps the two in lockstep.
type node struct {
name string
attrs []attr
kids []*node
// arrayKids names child elements to emit as JSON arrays even when there is
// exactly one (or none). Whether "song" is an object or an array depends on
// the parent: getSong returns one object, but album/searchResult3 contain
// arrays. That is a property of the container, so it is recorded here rather
// than inferred from the element name.
arrayKids map[string]bool
}
// el starts an element.
func el(name string, attrs ...attr) *node {
return &node{name: name, attrs: attrs}
}
// arrays marks child names that should always be JSON arrays under this node,
// and that should appear as [] when there are none, so a client that indexes
// the array never sees a missing key.
func (n *node) arrays(names ...string) *node {
if n.arrayKids == nil {
n.arrayKids = make(map[string]bool, len(names))
}
for _, name := range names {
n.arrayKids[name] = true
}
return n
}
// at is one attribute.
func at(name string, value any) attr { return attr{name: name, value: value} }
// add appends child elements and returns the parent, so trees can be built
// inline.
func (n *node) add(kids ...*node) *node {
n.kids = append(n.kids, kids...)
return n
}
// ── JSON ───────────────────────────────────────────────────────────────
// nodeJSON converts a node into the JSON value Subsonic clients expect. A
// "folder" leaf whose only attribute is id is flattened to the bare id, which
// is how getuser's folder list is specified ([1, 3], not [{"id":1}]).
func nodeJSON(n *node) any {
if n.name == "folder" && len(n.attrs) == 1 && len(n.kids) == 0 && n.attrs[0].name == "id" {
return n.attrs[0].value
}
m := make(map[string]any, len(n.attrs)+len(n.kids))
for _, a := range n.attrs {
if a.name == "xmlns" {
continue
}
m[a.name] = a.value
}
order := make([]string, 0, len(n.kids))
groups := make(map[string][]*node, len(n.kids))
for _, k := range n.kids {
if _, ok := groups[k.name]; !ok {
order = append(order, k.name)
}
groups[k.name] = append(groups[k.name], k)
}
for _, name := range order {
kids := groups[name]
if len(kids) == 1 && !n.arrayKids[name] {
m[name] = nodeJSON(kids[0])
continue
}
arr := make([]any, 0, len(kids))
for _, k := range kids {
arr = append(arr, nodeJSON(k))
}
m[name] = arr
}
for name := range n.arrayKids {
if _, ok := m[name]; !ok {
m[name] = []any{}
}
}
return m
}
// writeJSONResponse writes the JSON envelope. HTML escaping is disabled so a
// path containing "&" or "<" stays readable rather than becoming \u0026.
func writeJSONResponse(w http.ResponseWriter, root *node) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(map[string]any{"subsonic-response": nodeJSON(root)})
}
// ── XML ────────────────────────────────────────────────────────────────
// writeXMLResponse writes the XML envelope, including the namespace the XSD
// declares.
func writeXMLResponse(w http.ResponseWriter, root *node) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
_, _ = io.WriteString(w, xml.Header)
_ = writeXML(w, root)
}
func writeXML(w io.Writer, n *node) error {
var b bytes.Buffer
b.WriteByte('<')
b.WriteString(n.name)
for _, a := range n.attrs {
b.WriteByte(' ')
b.WriteString(a.name)
b.WriteString(`="`)
_ = xml.EscapeText(&b, []byte(xmlString(a.value)))
b.WriteByte('"')
}
if len(n.kids) == 0 {
b.WriteString("/>")
_, err := w.Write(b.Bytes())
return err
}
b.WriteByte('>')
if _, err := w.Write(b.Bytes()); err != nil {
return err
}
for _, k := range n.kids {
if err := writeXML(w, k); err != nil {
return err
}
}
_, err := io.WriteString(w, "</"+n.name+">")
return err
}
// xmlString renders a typed attribute value for XML. Booleans are lower-case,
// which is what the Subsonic schema uses.
func xmlString(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "true"
}
return "false"
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
default:
return ""
}
}