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/.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 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") }