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.
243 lines
7.4 KiB
Go
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 ""
|
|
}
|
|
}
|