// Package dto is the Subsonic wire contract: the response envelope, the // element tree it carries, the protocol's error codes and the opaque id scheme. // // It is deliberately separate from internal/subsonic, which owns the HTTP // handlers and the queries. A node here knows nothing about a request, a // response writer or a database row: it maps data structures into the two // serialisations Subsonic clients speak (XML attributes and flattened JSON). // // The node names and attribute names below are load-bearing. Symfonium, // Ultrasonic, play:Sub and every other client branch on them, and a rename here // fails silently on devices nobody in this repository can see. Likewise error // code 40: a client must read "wrong credentials" out of the envelope, never an // HTTP 401. package dto import ( "bytes" "encoding/json" "encoding/xml" "io" "strconv" ) const ( // ProtocolVersion is the Subsonic API version this layer implements. It is // advertised in every envelope. ProtocolVersion = "1.16.1" // ServerType identifies the server implementation in the envelope. ServerType = "vellum" ) // ServerVersion is advertised as serverVersion in the envelope. The main // binary may set it to its build version; it defaults to "dev". var ServerVersion = "dev" // Subsonic error codes. The protocol defines these; clients branch on them. const ( ErrGeneric = 0 // a generic error ErrMissingParameter = 10 // required parameter is missing ErrWrongCredentials = 40 // wrong username or password ErrNotAuthorized = 50 // user is not authorized for the operation ErrDataNotFound = 70 // the requested data was not found ) // StatusOK / StatusFailed are the two envelope `status` values. const ( StatusOK = "ok" StatusFailed = "failed" ) // 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 } // Envelope starts a response. XML needs the namespace; nodeJSON drops it. func Envelope(status string) *Node { return El("subsonic-response", At("xmlns", "http://subsonic.org/restapi"), At("status", status), At("version", ProtocolVersion), At("type", ServerType), At("serverVersion", ServerVersion), ) } // ErrorNode is the element clients read out of a failed envelope. func ErrorNode(code int, msg string) *Node { return El("error", At("code", code), At("message", msg)) } // ── 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 } // JSONBody encodes the response envelope. HTML escaping is disabled so a path // containing "&" or "<" stays readable rather than becoming &. func JSONBody(root *Node) ([]byte, error) { var b bytes.Buffer enc := json.NewEncoder(&b) enc.SetEscapeHTML(false) if err := enc.Encode(map[string]any{"subsonic-response": NodeJSON(root)}); err != nil { return nil, err } return b.Bytes(), nil } // ── XML ──────────────────────────────────────────────────────────────── // XMLBody renders the response envelope including the XSD namespace and the XML // declaration. It writes to a buffer rather than straight to the response // because the envelope's status and content type are decided by the handler, // not by the tree. func XMLBody(root *Node) []byte { var b bytes.Buffer b.WriteString(xml.Header) _ = writeXML(&b, root) return b.Bytes() } 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, "") 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 "" } }