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.
624 lines
20 KiB
Go
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")
|
|
}
|