Vellum/internal/subsonic/handlers.go
mithral 35442e4aff Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.

internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
  AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
  the package that authorizes the item; it imports home.RecordView for that one
  thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
  return different explore ids, which is the non-determinism the Explore rewrite
  exists for.

internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
  surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
  are not the scanner (modules register types, handlers serve the list, metadata
  reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
  not of the scan. The one genuinely scanner-owned helper became
  library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
  the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
  detection still short-circuits.

internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
  the database writes. The split's whole point is that a provider is testable
  WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
  BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
  because real false matches were found.

dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
  the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
  grep. That is what makes them reviewable.
- No field name, tag or error code changed.

internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.

Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.

Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.

Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
2026-10-11 16:03:10 -04:00

624 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"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// 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, dto.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, dto.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, dto.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(dto.El("license", dto.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 := dto.El("musicFolders").Arrays("musicFolder")
for _, lib := range libs {
folders.Add(dto.El("musicFolder", dto.At("id", lib.id), dto.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 := dto.El("indexes", dto.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 := dto.El("artists", dto.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 := dto.ParseID(r.FormValue("id"), dto.PrefixArtist)
if !ok {
// The synthetic "unknown artist" bucket uses ar-0.
if r.FormValue("id") != dto.PrefixArtist+"0" {
h.fail(w, r, dto.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, dto.ErrDataNotFound, "artist not found")
return
}
name, _ := h.artistName(r.Context(), personID)
artist := dto.El("artist",
dto.At("id", dto.ArtistID(personID)),
dto.At("name", name),
dto.At("albumCount", int64(len(albums))),
).Arrays("album")
if cover := firstCover(albums); cover != 0 {
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(cover)))
}
for _, a := range albums {
artist.Add(dto.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 := dto.ParseID(r.FormValue("id"), dto.PrefixAlbum)
if !ok {
h.fail(w, r, dto.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, dto.ErrDataNotFound, "album not found")
return
}
tracks, err := h.tracksForItem(r.Context(), itemID)
if err != nil {
h.internal(w, r, "album songs", err)
return
}
node := dto.AlbumNode(a).Arrays("song")
for _, t := range tracks {
node.Add(dto.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 := dto.ParseID(r.FormValue("id"), dto.PrefixFile)
if !ok {
h.fail(w, r, dto.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, dto.ErrDataNotFound, "song not found")
return
}
root := envelope("ok")
root.Add(dto.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, dto.PrefixFolder):
libID, ok := dto.ParseID(id, dto.PrefixFolder)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForLibrary(w, r, u, libID)
case strings.HasPrefix(id, dto.PrefixDir):
libID, rel, ok := dto.ParseDirID(id)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForPath(w, r, u, libID, rel)
case strings.HasPrefix(id, dto.PrefixAlbum):
itemID, ok := dto.ParseID(id, dto.PrefixAlbum)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForAlbum(w, r, u, itemID)
case strings.HasPrefix(id, dto.PrefixArtist):
personID, ok := dto.ParseID(id, dto.PrefixArtist)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForArtist(w, r, u, personID)
default:
h.fail(w, r, dto.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 := dto.El("searchResult3").Arrays("artist", "album", "song")
for _, a := range artists {
artist := dto.El("artist", dto.At("id", dto.ArtistID(a.id)), dto.At("name", a.name), dto.At("albumCount", int64(a.albumCount)))
if a.coverAlbum != 0 {
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(a.coverAlbum)))
}
result.Add(artist)
}
for _, a := range albums {
result.Add(dto.AlbumNode(a))
}
for _, s := range songs {
result.Add(dto.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 := dto.El("albumList2").Arrays("album")
for _, a := range albums {
list.Add(dto.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 := dto.El("randomSongs").Arrays("song")
for _, s := range songs {
node.Add(dto.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, dto.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, dto.ErrDataNotFound, "song not found")
return
}
if err := stream.ServeFile(w, r, abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
h.fail(w, r, dto.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, dto.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, dto.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, dto.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, dto.ErrMissingParameter, "Required parameter is missing")
return
}
submission := strings.ToLower(r.FormValue("submission")) != "false"
for _, id := range ids {
fileIDVal, ok := dto.ParseID(id, dto.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, dto.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, dto.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 := dto.El("user",
dto.At("username", u.Username),
dto.At("email", ""),
dto.At("scrobblingEnabled", true),
dto.At("adminRole", u.IsAdmin),
dto.At("settingsRole", u.IsAdmin),
dto.At("downloadRole", true),
dto.At("uploadRole", false),
dto.At("playlistRole", true),
dto.At("coverArtRole", true),
dto.At("commentRole", false),
dto.At("podcastRole", false),
dto.At("streamRole", true),
dto.At("jukeboxRole", false),
dto.At("shareRole", false),
dto.At("videoConversionRole", false),
).Arrays("folder")
for _, id := range libIDs {
user.Add(dto.El("folder", dto.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(dto.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, dto.ErrGeneric, "internal server error")
}