diff --git a/internal/subsonic/envelope.go b/internal/subsonic/envelope.go new file mode 100644 index 00000000..3e2024c4 --- /dev/null +++ b/internal/subsonic/envelope.go @@ -0,0 +1,98 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" +) + +// APIVersion is the Subsonic REST version we advertise. 1.16.1 is the last +// "classic" level; OpenSubsonic extensions are surfaced via the openSubsonic +// flag and the type/serverVersion fields. +const ( + APIVersion = "1.16.1" + ServerType = "minstrel" + XMLNS = "http://subsonic.org/restapi" +) + +// ServerVersion identifies this build. Overwritten by cmd/minstrel at startup. +var ServerVersion = "dev" + +// Envelope holds the attributes present on every Subsonic response. Concrete +// response types embed it and add endpoint-specific fields with json+xml tags. +type Envelope struct { + XMLName xml.Name `json:"-" xml:"subsonic-response"` + XMLNS string `json:"-" xml:"xmlns,attr"` + Status string `json:"status" xml:"status,attr"` + Version string `json:"version" xml:"version,attr"` + Type string `json:"type" xml:"type,attr"` + ServerVersion string `json:"serverVersion" xml:"serverVersion,attr"` + OpenSubsonic bool `json:"openSubsonic" xml:"openSubsonic,attr"` + Error *Error `json:"error,omitempty" xml:"error,omitempty"` +} + +// Error is the body of a failed response. +type Error struct { + XMLName xml.Name `json:"-" xml:"error"` + Code int `json:"code" xml:"code,attr"` + Message string `json:"message" xml:"message,attr"` +} + +// NewEnvelope returns a populated header for the given status ("ok"|"failed"). +func NewEnvelope(status string) Envelope { + return Envelope{ + XMLNS: XMLNS, + Status: status, + Version: APIVersion, + Type: ServerType, + ServerVersion: ServerVersion, + OpenSubsonic: true, + } +} + +// Write renders body in the format requested by the ?f= query parameter. +// body must embed Envelope. Unknown formats fall back to JSON. +func Write(w http.ResponseWriter, r *http.Request, body any) { + switch strings.ToLower(r.URL.Query().Get("f")) { + case "xml": + writeXML(w, body) + case "jsonp": + writeJSONP(w, body, r.URL.Query().Get("callback")) + default: + writeJSON(w, body) + } +} + +// WriteFail emits an envelope-only failed response with the given error code. +func WriteFail(w http.ResponseWriter, r *http.Request, code int, message string) { + env := NewEnvelope("failed") + env.Error = &Error{Code: code, Message: message} + Write(w, r, env) +} + +func writeJSON(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"subsonic-response": body}) +} + +func writeJSONP(w http.ResponseWriter, body any, callback string) { + if callback == "" { + // Per spec the client must supply callback; fall back to plain JSON + // rather than producing invalid JavaScript. + writeJSON(w, body) + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + fmt.Fprintf(w, "%s(", callback) + _ = json.NewEncoder(w).Encode(map[string]any{"subsonic-response": body}) + _, _ = io.WriteString(w, ")") +} + +func writeXML(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + _, _ = io.WriteString(w, xml.Header) + _ = xml.NewEncoder(w).Encode(body) +}