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.
206 lines
5.5 KiB
Go
206 lines
5.5 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mithral/vellum/internal/library"
|
|
"github.com/mithral/vellum/internal/stream"
|
|
)
|
|
|
|
// Vellum has two id spaces — media_items and media_files — and Subsonic has
|
|
// one opaque string id, so every id this layer emits is prefixed to say what it
|
|
// names. The prefix also lets stream/getCoverArt resolve an id without a
|
|
// separate lookup table.
|
|
//
|
|
// al-<itemID> an album / directory-grouped item
|
|
// mf-<fileID> a song, i.e. one physical media_files row
|
|
// ar-<personID> an artist, from people/item_people
|
|
// fd-<libID> a music folder (a whole audio library)
|
|
// d-<libID>:<relpath> a browsable directory within a library
|
|
const (
|
|
prefixAlbum = "al-"
|
|
prefixFile = "mf-"
|
|
prefixArtist = "ar-"
|
|
prefixFolder = "fd-"
|
|
prefixDir = "d-"
|
|
)
|
|
|
|
func albumID(itemID int64) string { return prefixAlbum + strconv.FormatInt(itemID, 10) }
|
|
func fileID(fileID int64) string { return prefixFile + strconv.FormatInt(fileID, 10) }
|
|
func artistID(personID int64) string { return prefixArtist + strconv.FormatInt(personID, 10) }
|
|
func folderID(libID int64) string { return prefixFolder + strconv.FormatInt(libID, 10) }
|
|
func dirID(libID int64, rel string) string {
|
|
return prefixDir + strconv.FormatInt(libID, 10) + ":" + rel
|
|
}
|
|
|
|
// parseID splits a prefixed id, returning the numeric part.
|
|
func parseID(id, prefix string) (int64, bool) {
|
|
rest, ok := strings.CutPrefix(id, prefix)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
n, err := strconv.ParseInt(rest, 10, 64)
|
|
if err != nil || n <= 0 {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
// parseDirID splits "d-<libID>:<relpath>". An empty relpath is the library root.
|
|
func parseDirID(id string) (libID int64, rel string, ok bool) {
|
|
rest, ok := strings.CutPrefix(id, prefixDir)
|
|
if !ok {
|
|
return 0, "", false
|
|
}
|
|
lib, rel, found := strings.Cut(rest, ":")
|
|
if !found {
|
|
return 0, "", false
|
|
}
|
|
n, err := strconv.ParseInt(lib, 10, 64)
|
|
if err != nil || n <= 0 {
|
|
return 0, "", false
|
|
}
|
|
return n, rel, true
|
|
}
|
|
|
|
// ── entities ───────────────────────────────────────────────────────────
|
|
|
|
// albumInfo is one album: a directory-grouped media_items row of an audio kind.
|
|
type albumInfo struct {
|
|
ID int64
|
|
LibraryID int64
|
|
Kind string
|
|
Path string
|
|
Title string
|
|
Year int
|
|
AddedAt string
|
|
ArtistID int64
|
|
Artist string
|
|
SongCount int
|
|
DurationMS int64
|
|
HasCover bool
|
|
}
|
|
|
|
// track is one song: a media_files row together with its album's context.
|
|
type track struct {
|
|
FileID int64
|
|
ItemID int64
|
|
LibraryID int64
|
|
Kind string
|
|
AlbumPath string
|
|
AlbumTitle string
|
|
Year int
|
|
AddedAt string
|
|
RelPath string
|
|
Size int64
|
|
DurationMS int64
|
|
Container string
|
|
ArtistID int64
|
|
Artist string
|
|
Ordinal int
|
|
HasCover bool
|
|
}
|
|
|
|
// songNode renders a track as the Subsonic Child/Song element. getSong returns
|
|
// it bare; getAlbum nests it under <album>.
|
|
func songNode(t track) *node {
|
|
ct := stream.ContentType(t.RelPath)
|
|
suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".")
|
|
|
|
n := el("song",
|
|
at("id", fileID(t.FileID)),
|
|
at("parent", albumID(t.ItemID)),
|
|
at("isDir", false),
|
|
at("title", library.CleanTitle(filepath.Base(t.RelPath))),
|
|
at("album", t.AlbumTitle),
|
|
at("albumId", albumID(t.ItemID)),
|
|
at("artist", t.Artist),
|
|
at("type", "music"),
|
|
at("isVideo", false),
|
|
)
|
|
if t.ArtistID > 0 {
|
|
n.attrs = append(n.attrs, at("artistId", artistID(t.ArtistID)))
|
|
}
|
|
if t.HasCover {
|
|
n.attrs = append(n.attrs, at("coverArt", albumID(t.ItemID)))
|
|
}
|
|
if t.Ordinal > 0 {
|
|
n.attrs = append(n.attrs, at("track", int64(t.Ordinal)))
|
|
}
|
|
if t.Year > 0 {
|
|
n.attrs = append(n.attrs, at("year", int64(t.Year)))
|
|
}
|
|
n.attrs = append(n.attrs,
|
|
at("size", t.Size),
|
|
at("contentType", ct),
|
|
at("suffix", suffix),
|
|
at("duration", t.DurationMS/1000),
|
|
at("path", t.RelPath),
|
|
)
|
|
if t.AddedAt != "" {
|
|
n.attrs = append(n.attrs, at("created", t.AddedAt))
|
|
}
|
|
return n
|
|
}
|
|
|
|
// albumNode renders an album as the Subsonic AlbumID3 element. Extra legacy
|
|
// fields (title, album, isDir) are included because older clients read them and
|
|
// they cost nothing.
|
|
func albumNode(a albumInfo) *node {
|
|
parent := folderID(a.LibraryID)
|
|
if a.ArtistID > 0 {
|
|
parent = artistID(a.ArtistID)
|
|
}
|
|
|
|
n := el("album",
|
|
at("id", albumID(a.ID)),
|
|
at("parent", parent),
|
|
at("name", a.Title),
|
|
at("title", a.Title),
|
|
at("album", a.Title),
|
|
at("isDir", true),
|
|
at("artist", a.Artist),
|
|
at("songCount", int64(a.SongCount)),
|
|
at("duration", a.DurationMS/1000),
|
|
at("playCount", int64(0)),
|
|
)
|
|
if a.ArtistID > 0 {
|
|
n.attrs = append(n.attrs, at("artistId", artistID(a.ArtistID)))
|
|
}
|
|
if a.HasCover {
|
|
n.attrs = append(n.attrs, at("coverArt", albumID(a.ID)))
|
|
}
|
|
if a.Year > 0 {
|
|
n.attrs = append(n.attrs, at("year", int64(a.Year)))
|
|
}
|
|
if a.AddedAt != "" {
|
|
n.attrs = append(n.attrs, at("created", a.AddedAt))
|
|
}
|
|
return n
|
|
}
|
|
|
|
// childNode renders an album or directory entry for getMusicDirectory /
|
|
// getIndexes, where the element is a generic Child rather than AlbumID3.
|
|
func childNode(a albumInfo) *node {
|
|
n := el("child",
|
|
at("id", albumID(a.ID)),
|
|
at("parent", folderID(a.LibraryID)),
|
|
at("isDir", true),
|
|
at("title", a.Title),
|
|
at("album", a.Title),
|
|
at("artist", a.Artist),
|
|
at("isVideo", false),
|
|
)
|
|
if a.ArtistID > 0 {
|
|
n.attrs = append(n.attrs, at("artistId", artistID(a.ArtistID)))
|
|
}
|
|
if a.HasCover {
|
|
n.attrs = append(n.attrs, at("coverArt", albumID(a.ID)))
|
|
}
|
|
if a.Year > 0 {
|
|
n.attrs = append(n.attrs, at("year", int64(a.Year)))
|
|
}
|
|
return n
|
|
}
|