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, "") 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 "" } }