Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.
Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
credential that is not the session cookie, and letting two agents each invent
a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
value so only its hash is kept, while a Subsonic key must be recoverable
because verifying `t` means computing md5(key + salt) server-side. token_plain
is therefore the one column in the schema holding a usable credential. It is a
generated random secret, never a user password, and it is revocable.
Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
/Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
/Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
/Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
with a DirectStreamUrl carrying the api_key. A client that disables both
direct modes is demanding a transcode and is refused with ErrorCode
"NoCompatibleStream" — rather than handed a TranscodingUrl that would never
resolve, which is how a client hangs forever.
Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.
Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.
Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
PlaybackInfo showing SupportsTranscoding=false, and a Range request to
/Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
/api/v1/version all still answer correctly.
Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
102 lines
3.9 KiB
Go
102 lines
3.9 KiB
Go
// 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)
|
|
}
|