// 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. // // # 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" ) 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 ) // 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), ) } // 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 *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("failed") root.add(el("error", at("code", code), at("message", msg))) h.write(w, r, root) }