// Package subsonic translates Vellum's storage into the frozen Subsonic REST // API (1.16.1) that real clients — Symfonium, Ultrasonic, play:Sub, DSub, // Substreamer, Feishin — speak to browse and stream. // // It is a translation layer, not a second media server. Every query goes // through media_items / media_files / people / item_people / assets, and every // request resolves to an auth.User through the same grant chokepoint the web // app uses (auth.VisibleLibraryIDs / auth.CanAccessLibrary). Credentials are // apitoken.KindSubsonic tokens, so revoking one in the web UI revokes the // client too. // // The wire shapes — the response envelope, the element names, the error codes, // the opaque ids — live in internal/subsonic/dto, which is the contract with // clients. This package owns the HTTP handlers and the queries. // // # Authentication // // Subsonic carries its credentials on every request, not in a cookie: u names // the user and one of p (the key directly), t=md5(key+salt) with s, or apiKey. // A bad credential is a Subsonic error envelope with code 40, never an HTTP // 401 — clients read the envelope and many treat a non-200 as a network fault. // // # No transcoding // // Vellum runs on a headless, GPU-less host. stream serves the original file // (format=raw) with full Range support so seeking works, and refuses a request // for any other target format with a clear error instead of pretending. ffmpeg // is never invoked. // // # Root mounting // // Routes live under /rest/ at the ROOT because that is where Subsonic clients // look. internal/httpapi/static.go owns "/", "/healthz" and "/api/"; "/rest/" // is a more specific ServeMux pattern, so requests route here first and the SPA // fallback is never shadowed. isAPIPath in static.go does not know /rest/, so // this package installs its own JSON 404 for the subtree rather than letting an // unknown /rest/... path fall through to the SPA shell. package subsonic import ( "net/http" "strings" "github.com/mithral/vellum/internal/subsonic/dto" ) // envelope starts a response. XML needs the namespace; nodeJSON drops it. func envelope(status string) *dto.Node { return dto.Envelope(status) } // wantsJSON reports whether the caller asked for the JSON form. The default, // per the spec, is XML. jsonp is treated as json: the wrapping callback is a // browser convenience this LAN server does not need, and returning the plain // JSON body is still parseable by a jsonp client that ignores the callback. func wantsJSON(r *http.Request) bool { switch strings.ToLower(r.FormValue("f")) { case "json", "jsonp": return true default: return false } } // write serialises a response in the requested format. func (h *Handlers) write(w http.ResponseWriter, r *http.Request, root *dto.Node) { if wantsJSON(r) { writeJSONResponse(w, root) return } writeXMLResponse(w, root) } // fail writes a Subsonic error envelope. The HTTP status stays 200 on purpose: // Subsonic clients read the envelope's status/error, and a non-200 is often // surfaced as a transport error rather than "wrong password". func (h *Handlers) fail(w http.ResponseWriter, r *http.Request, code int, msg string) { root := envelope(dto.StatusFailed) root.Add(dto.ErrorNode(code, msg)) h.write(w, r, root) }