Vellum/internal/subsonic/dto/node.go
mithral 35442e4aff Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.

internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
  AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
  the package that authorizes the item; it imports home.RecordView for that one
  thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
  return different explore ids, which is the non-determinism the Explore rewrite
  exists for.

internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
  surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
  are not the scanner (modules register types, handlers serve the list, metadata
  reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
  not of the scan. The one genuinely scanner-owned helper became
  library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
  the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
  detection still short-circuits.

internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
  the database writes. The split's whole point is that a provider is testable
  WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
  BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
  because real false matches were found.

dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
  the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
  grep. That is what makes them reviewable.
- No field name, tag or error code changed.

internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.

Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.

Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.

Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
2026-10-11 16:03:10 -04:00

243 lines
7.4 KiB
Go

// 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 <error> 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, "</"+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 ""
}
}