Vellum/internal/subsonic/handlers.go
mithral 6da194045b Waves 9-10: Jellyfin and Subsonic clients can browse and play
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.
2026-10-11 15:05:43 -04:00

623 lines
20 KiB
Go

package subsonic
import (
"database/sql"
"errors"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/progress"
"github.com/mithral/vellum/internal/stream"
)
// ignoredArticles is advertised in getIndexes/getArtists. It is the list the
// Subsonic schema documents; the indexer strips a leading article so "The
// Beatles" files under B.
const ignoredArticles = "The El La Los Las Le Les"
// Handlers serves the Subsonic-compatible subset.
type Handlers struct {
db *sql.DB
log *slog.Logger
// auth is kept for parity with the other HTTP layers and for the cookie
// credentials endpoint. Subsonic requests authenticate themselves from
// query/form parameters, because the middleware only knows session cookies.
auth *auth.Authenticator
}
// NewHandlers builds the Subsonic HTTP layer. It takes no cache directory:
// cover bytes are served from the assets table the scanner already populated,
// rather than triggering a fresh extraction.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the routes. See the package comment for why /rest/ lives at
// the root and why it installs its own 404 rather than relying on the SPA
// fallback in internal/httpapi/static.go.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /rest/{method}", h.dispatch)
mux.HandleFunc("POST /rest/{method}", h.dispatch)
// Subtree fallback for /rest/ itself and multi-segment paths. Without it an
// unknown /rest/... falls through to "/" and static.go's isAPIPath — which
// does not recognise /rest/ — would serve the SPA shell as text/html.
mux.HandleFunc("/rest/", h.handleUnknownRest)
// Client configuration. This one IS cookie-authenticated: the user is in
// the web UI, not a Subsonic client.
mux.Handle("GET /api/v1/subsonic/credentials", h.auth.Require(h.handleCredentials))
}
func (h *Handlers) handleUnknownRest(w http.ResponseWriter, r *http.Request) {
h.fail(w, r, errGeneric, "unknown Subsonic method")
}
// dispatch authenticates and routes one /rest/<method>.view call. Every method
// shares the same credential scheme, so authentication happens once, here.
func (h *Handlers) dispatch(w http.ResponseWriter, r *http.Request) {
method := strings.ToLower(strings.TrimSuffix(r.PathValue("method"), ".view"))
u, ok := h.authenticate(r)
if !ok {
// The single most common integration failure: a bad credential must be
// an envelope with code 40, not an HTTP 401. Clients read the envelope.
h.fail(w, r, errWrongCredentials, "Wrong username or password")
return
}
switch method {
case "ping":
h.handlePing(w, r)
case "getlicense":
h.handleGetLicense(w, r)
case "getmusicfolders":
h.handleGetMusicFolders(w, r, u)
case "getindexes":
h.handleGetIndexes(w, r, u)
case "getartists":
h.handleGetArtists(w, r, u)
case "getartist":
h.handleGetArtist(w, r, u)
case "getalbum":
h.handleGetAlbum(w, r, u)
case "getmusicdirectory":
h.handleGetMusicDirectory(w, r, u)
case "getsong":
h.handleGetSong(w, r, u)
case "search3":
h.handleSearch3(w, r, u)
case "stream":
h.handleStream(w, r, u)
case "getcoverart":
h.handleGetCoverArt(w, r, u)
case "getalbumlist2":
h.handleGetAlbumList2(w, r, u)
case "getrandomsongs":
h.handleGetRandomSongs(w, r, u)
case "scrobble":
h.handleScrobble(w, r, u)
case "getuser":
h.handleGetUser(w, r, u)
case "getplaylists":
h.handleGetPlaylists(w, r)
default:
h.fail(w, r, errGeneric, "unknown method: "+method)
}
}
// ── system ─────────────────────────────────────────────────────────────
func (h *Handlers) handlePing(w http.ResponseWriter, r *http.Request) {
h.write(w, r, envelope("ok"))
}
func (h *Handlers) handleGetLicense(w http.ResponseWriter, r *http.Request) {
root := envelope("ok")
// Vellum is not Subsonic and has no licence or trial. Clients only check
// `valid`; a permanent valid licence is the honest answer.
root.add(el("license", at("valid", true)))
h.write(w, r, root)
}
// ── browsing ───────────────────────────────────────────────────────────
func (h *Handlers) handleGetMusicFolders(w http.ResponseWriter, r *http.Request, u auth.User) {
libs, err := h.audioLibraries(r.Context(), u)
if err != nil {
h.internal(w, r, "music folders", err)
return
}
folders := el("musicFolders").arrays("musicFolder")
for _, lib := range libs {
folders.add(el("musicFolder", at("id", lib.id), at("name", lib.name)))
}
root := envelope("ok")
root.add(folders)
h.write(w, r, root)
}
func (h *Handlers) handleGetIndexes(w http.ResponseWriter, r *http.Request, u auth.User) {
artists, err := h.artists(r.Context(), u)
if err != nil {
h.internal(w, r, "indexes", err)
return
}
indexes := el("indexes", at("ignoredArticles", ignoredArticles)).arrays("index")
addIndexes(indexes, artists, false)
root := envelope("ok")
root.add(indexes)
h.write(w, r, root)
}
func (h *Handlers) handleGetArtists(w http.ResponseWriter, r *http.Request, u auth.User) {
artists, err := h.artists(r.Context(), u)
if err != nil {
h.internal(w, r, "artists", err)
return
}
node := el("artists", at("ignoredArticles", ignoredArticles)).arrays("index")
addIndexes(node, artists, true)
root := envelope("ok")
root.add(node)
h.write(w, r, root)
}
func (h *Handlers) handleGetArtist(w http.ResponseWriter, r *http.Request, u auth.User) {
personID, ok := parseID(r.FormValue("id"), prefixArtist)
if !ok {
// The synthetic "unknown artist" bucket uses ar-0.
if r.FormValue("id") != prefixArtist+"0" {
h.fail(w, r, errDataNotFound, "artist not found")
return
}
personID = 0
}
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "artist", err)
return
}
albums, err := h.albumsByArtist(r.Context(), libs, personID)
if err != nil {
h.internal(w, r, "artist albums", err)
return
}
if len(albums) == 0 {
h.fail(w, r, errDataNotFound, "artist not found")
return
}
name, _ := h.artistName(r.Context(), personID)
artist := el("artist",
at("id", artistID(personID)),
at("name", name),
at("albumCount", int64(len(albums))),
).arrays("album")
if cover := firstCover(albums); cover != 0 {
artist.attrs = append(artist.attrs, at("coverArt", albumID(cover)))
}
for _, a := range albums {
artist.add(albumNode(a))
}
root := envelope("ok")
root.add(artist)
h.write(w, r, root)
}
func (h *Handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request, u auth.User) {
itemID, ok := parseID(r.FormValue("id"), prefixAlbum)
if !ok {
h.fail(w, r, errDataNotFound, "album not found")
return
}
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
if err != nil {
h.internal(w, r, "album", err)
return
}
if !ok {
h.fail(w, r, errDataNotFound, "album not found")
return
}
tracks, err := h.tracksForItem(r.Context(), itemID)
if err != nil {
h.internal(w, r, "album songs", err)
return
}
node := albumNode(a).arrays("song")
for _, t := range tracks {
node.add(songNode(t))
}
root := envelope("ok")
root.add(node)
h.write(w, r, root)
}
func (h *Handlers) handleGetSong(w http.ResponseWriter, r *http.Request, u auth.User) {
fileIDVal, ok := parseID(r.FormValue("id"), prefixFile)
if !ok {
h.fail(w, r, errDataNotFound, "song not found")
return
}
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
if err != nil {
h.internal(w, r, "song", err)
return
}
if !ok {
h.fail(w, r, errDataNotFound, "song not found")
return
}
root := envelope("ok")
root.add(songNode(t))
h.write(w, r, root)
}
// handleGetMusicDirectory browses one level of the tree. Vellum does not store
// a parent_id for audio (the scanner groups by directory), so the hierarchy is
// derived from media_items.path: a directory id names a path prefix, and its
// children are the items whose parent directory is that prefix plus the next
// path segment of every deeper item.
func (h *Handlers) handleGetMusicDirectory(w http.ResponseWriter, r *http.Request, u auth.User) {
id := r.FormValue("id")
switch {
case strings.HasPrefix(id, prefixFolder):
libID, ok := parseID(id, prefixFolder)
if !ok {
h.fail(w, r, errDataNotFound, "directory not found")
return
}
h.directoryForLibrary(w, r, u, libID)
case strings.HasPrefix(id, prefixDir):
libID, rel, ok := parseDirID(id)
if !ok {
h.fail(w, r, errDataNotFound, "directory not found")
return
}
h.directoryForPath(w, r, u, libID, rel)
case strings.HasPrefix(id, prefixAlbum):
itemID, ok := parseID(id, prefixAlbum)
if !ok {
h.fail(w, r, errDataNotFound, "directory not found")
return
}
h.directoryForAlbum(w, r, u, itemID)
case strings.HasPrefix(id, prefixArtist):
personID, ok := parseID(id, prefixArtist)
if !ok {
h.fail(w, r, errDataNotFound, "directory not found")
return
}
h.directoryForArtist(w, r, u, personID)
default:
h.fail(w, r, errDataNotFound, "directory not found")
}
}
// ── search ─────────────────────────────────────────────────────────────
// handleSearch3 is the endpoint clients lean on hardest. It is answered with
// plain LIKE queries scoped to the caller's visible audio libraries rather than
// the FTS index, because the FTS index is over media_items (albums), while a
// Subsonic song is a media_files row and a Subsonic artist is a person. An
// empty query returns everything, which OpenSubsonic requires for offline sync.
func (h *Handlers) handleSearch3(w http.ResponseWriter, r *http.Request, u auth.User) {
q := r.FormValue("query")
artistCount, artistOffset := countOffset(r, "artistCount", "artistOffset")
albumCount, albumOffset := countOffset(r, "albumCount", "albumOffset")
songCount, songOffset := countOffset(r, "songCount", "songOffset")
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "search", err)
return
}
artists, err := h.searchArtists(r.Context(), libs, q, artistCount, artistOffset)
if err != nil {
h.internal(w, r, "search artists", err)
return
}
albums, err := h.searchAlbums(r.Context(), libs, q, albumCount, albumOffset)
if err != nil {
h.internal(w, r, "search albums", err)
return
}
songs, err := h.searchSongs(r.Context(), libs, q, songCount, songOffset)
if err != nil {
h.internal(w, r, "search songs", err)
return
}
result := el("searchResult3").arrays("artist", "album", "song")
for _, a := range artists {
artist := el("artist", at("id", artistID(a.id)), at("name", a.name), at("albumCount", int64(a.albumCount)))
if a.coverAlbum != 0 {
artist.attrs = append(artist.attrs, at("coverArt", albumID(a.coverAlbum)))
}
result.add(artist)
}
for _, a := range albums {
result.add(albumNode(a))
}
for _, s := range songs {
result.add(songNode(s))
}
root := envelope("ok")
root.add(result)
h.write(w, r, root)
}
// ── lists ──────────────────────────────────────────────────────────────
func (h *Handlers) handleGetAlbumList2(w http.ResponseWriter, r *http.Request, u auth.User) {
kind := strings.ToLower(r.FormValue("type"))
size := intParam(r, "size", 10, 500)
offset := intParam(r, "offset", 0, 1_000_000)
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "album list", err)
return
}
// Only newest and random are required; the alphabetical sorts are cheap and
// are what a client pages through, so they are included. Types this server
// cannot answer meaningfully (highest, frequent, starred, byGenre) fall back
// to newest rather than returning an error, so a client is never stuck.
order := "i.added_at DESC"
where := ""
var args []any
switch kind {
case "random":
order = "RANDOM()"
case "alphabeticalbyname":
order = "i.sort_title"
case "alphabeticalbyartist":
order = "artist_name, i.sort_title"
case "byyear":
from := intParam(r, "fromYear", 0, 9999)
to := intParam(r, "toYear", 0, 9999)
where = " AND COALESCE(i.year,0) >= ? AND COALESCE(i.year,0) <= ?"
args = append(args, from, to)
default: // newest, recent, and every unsupported type
order = "i.added_at DESC"
}
albums, err := h.albumsWhere(r.Context(), libs, where, args, order, size, offset)
if err != nil {
h.internal(w, r, "album list", err)
return
}
list := el("albumList2").arrays("album")
for _, a := range albums {
list.add(albumNode(a))
}
root := envelope("ok")
root.add(list)
h.write(w, r, root)
}
func (h *Handlers) handleGetRandomSongs(w http.ResponseWriter, r *http.Request, u auth.User) {
size := intParam(r, "size", 10, 500)
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "random songs", err)
return
}
songs, err := h.songsWhere(r.Context(), libs, "", nil, "RANDOM()", size, 0)
if err != nil {
h.internal(w, r, "random songs", err)
return
}
node := el("randomSongs").arrays("song")
for _, s := range songs {
node.add(songNode(s))
}
root := envelope("ok")
root.add(node)
h.write(w, r, root)
}
// ── media ──────────────────────────────────────────────────────────────
// handleStream serves the original file. Vellum never transcodes: a request for
// any format other than raw (or none) is refused with a clear message rather
// than silently handing back the original under a different label. Range
// requests are handled by stream.ServeFile via http.ServeContent, so seeking
// works.
func (h *Handlers) handleStream(w http.ResponseWriter, r *http.Request, u auth.User) {
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
if format != "" && format != "raw" {
h.fail(w, r, errGeneric, "transcoding is not supported by this server; request format=raw")
return
}
// maxBitRate and estimateContentLength are accepted and honestly ignored:
// there is no transcoder to apply either to. They are not rejected because
// clients send them unconditionally.
abs, ok, err := h.resolveAudioFile(r.Context(), u, r.FormValue("id"))
if err != nil {
h.internal(w, r, "stream", err)
return
}
if !ok {
h.fail(w, r, errDataNotFound, "song not found")
return
}
if err := stream.ServeFile(w, r, abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
h.fail(w, r, errDataNotFound, "file is not available")
return
}
h.log.Error("subsonic: stream file", "err", err)
}
}
func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u auth.User) {
itemID, ok, err := h.coverItemID(r.Context(), u, r.FormValue("id"))
if err != nil {
h.internal(w, r, "cover", err)
return
}
if !ok {
h.fail(w, r, errDataNotFound, "cover art not found")
return
}
var coverPath string
err = h.db.QueryRowContext(r.Context(),
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, itemID).Scan(&coverPath)
if errors.Is(err, sql.ErrNoRows) {
h.fail(w, r, errDataNotFound, "cover art not found")
return
}
if err != nil {
h.internal(w, r, "cover", err)
return
}
if _, statErr := os.Stat(coverPath); statErr != nil {
h.fail(w, r, errDataNotFound, "cover art not available")
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
if err := stream.ServeFile(w, r, coverPath); err != nil {
h.log.Error("subsonic: serve cover", "err", err)
}
}
// ── playback reporting ─────────────────────────────────────────────────
// handleScrobble writes the client's playback position through the existing
// progress store. Subsonic's scrobble carries a wall-clock time rather than a
// position, but several clients additionally send `position` in milliseconds;
// when it is present it is honoured so the web app can resume where the client
// left off. A "now playing" notification (submission=false) records the start.
func (h *Handlers) handleScrobble(w http.ResponseWriter, r *http.Request, u auth.User) {
ids := r.Form["id"]
if len(ids) == 0 {
h.fail(w, r, errMissingParameter, "Required parameter is missing")
return
}
submission := strings.ToLower(r.FormValue("submission")) != "false"
for _, id := range ids {
fileIDVal, ok := parseID(id, prefixFile)
if !ok {
continue
}
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
if err != nil {
h.internal(w, r, "scrobble", err)
return
}
if !ok {
continue
}
position := int64(0)
if v := r.FormValue("position"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
position = n
}
}
p := progress.Progress{
ItemID: t.ItemID,
FileID: t.FileID,
Kind: t.Kind,
Position: position,
Duration: t.DurationMS,
}
if t.DurationMS > 0 {
p.Percent = float64(position) / float64(t.DurationMS)
}
if submission && t.DurationMS > 0 && position >= t.DurationMS {
p.Finished = true
}
if _, err := progress.Put(r.Context(), h.db, u.ID, p); err != nil && !errors.Is(err, progress.ErrNoFile) {
h.internal(w, r, "scrobble progress", err)
return
}
}
h.write(w, r, envelope("ok"))
}
// ── user ───────────────────────────────────────────────────────────────
func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth.User) {
target := strings.TrimSpace(r.FormValue("username"))
// The parameter is required by the spec; defaulting to the caller is the
// behaviour clients expect when they omit it.
if target != "" && !strings.EqualFold(target, u.Username) {
if !u.IsAdmin {
h.fail(w, r, errNotAuthorized, "User is not authorized for the given operation")
return
}
other, err := auth.GetUserByUsername(r.Context(), h.db, target)
if errors.Is(err, sql.ErrNoRows) {
h.fail(w, r, errDataNotFound, "user not found")
return
}
if err != nil {
h.internal(w, r, "user", err)
return
}
u = other
}
libIDs, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.internal(w, r, "user folders", err)
return
}
user := el("user",
at("username", u.Username),
at("email", ""),
at("scrobblingEnabled", true),
at("adminRole", u.IsAdmin),
at("settingsRole", u.IsAdmin),
at("downloadRole", true),
at("uploadRole", false),
at("playlistRole", true),
at("coverArtRole", true),
at("commentRole", false),
at("podcastRole", false),
at("streamRole", true),
at("jukeboxRole", false),
at("shareRole", false),
at("videoConversionRole", false),
).arrays("folder")
for _, id := range libIDs {
user.add(el("folder", at("id", id)))
}
root := envelope("ok")
root.add(user)
h.write(w, r, root)
}
// handleGetPlaylists returns an empty list. Vellum has a collections table but
// no Subsonic playlist support yet; an empty <playlists/> is what keeps a
// client from erroring on login.
func (h *Handlers) handleGetPlaylists(w http.ResponseWriter, r *http.Request) {
root := envelope("ok")
root.add(el("playlists").arrays("playlist"))
h.write(w, r, root)
}
// ── helpers ────────────────────────────────────────────────────────────
func (h *Handlers) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
h.log.Error("subsonic: "+what, "err", err)
h.fail(w, r, errGeneric, "internal server error")
}