package subsonic import ( "context" "database/sql" "errors" "net/http" "path/filepath" "sort" "strconv" "strings" "unicode" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" ) // libraryRef is an audio library as getMusicFolders shows it. type libraryRef struct { id int64 name string } // audioLibraries returns the caller's visible music/audiobook libraries. The // web UI's video, ebook and photo libraries are not Subsonic music folders. func (h *Handlers) audioLibraries(ctx context.Context, u auth.User) ([]libraryRef, error) { ids, err := auth.VisibleLibraryIDs(ctx, h.db, u) if err != nil { return nil, err } if len(ids) == 0 { return []libraryRef{}, nil } in, args := inClause(ids) rows, err := h.db.QueryContext(ctx, ` SELECT id, name FROM libraries WHERE kind IN ('music','audiobooks') AND id IN (`+in+`) ORDER BY name`, args...) if err != nil { return nil, err } defer rows.Close() out := []libraryRef{} for rows.Next() { var l libraryRef if err := rows.Scan(&l.id, &l.name); err != nil { return nil, err } out = append(out, l) } return out, rows.Err() } func (h *Handlers) audioLibraryIDs(ctx context.Context, u auth.User) ([]int64, error) { libs, err := h.audioLibraries(ctx, u) if err != nil { return nil, err } out := make([]int64, 0, len(libs)) for _, l := range libs { out = append(out, l.id) } return out, nil } // libraryRoot returns the absolute root path of a library. func (h *Handlers) libraryRoot(ctx context.Context, libID int64) (string, error) { var root string err := h.db.QueryRowContext(ctx, `SELECT root_path FROM libraries WHERE id = ?`, libID).Scan(&root) return root, err } // ── albums ───────────────────────────────────────────────────────────── const albumSelect = ` SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at, COALESCE((SELECT ip.person_id FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0) AS artist_id, (SELECT count(*) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0) AS song_count, COALESCE((SELECT sum(f.duration_ms) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0), 0) AS duration_ms, EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover') AS has_cover, COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), '') AS artist_name FROM media_items i` // albumsWhere runs the album projection with a caller-supplied extra predicate // and order. order MUST come from a fixed set, never from user input; the // callers below pass only literals. The final projected column is aliased // artist_name so alphabeticalByArtist can order by it without a second join. func (h *Handlers) albumsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]albumInfo, error) { if limit <= 0 || limit > 500 { limit = 500 } in, args := inClause(libs) args = append(args, extraArgs...) q := albumSelect + ` WHERE i.missing = 0 AND i.kind IN ('music','audiobooks') AND i.library_id IN (` + in + `)` + extra + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := h.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() return scanAlbums(rows) } func (h *Handlers) albumsByArtist(ctx context.Context, libs []int64, personID int64) ([]albumInfo, error) { if personID == 0 { return h.albumsWhere(ctx, libs, ` AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`, nil, "i.sort_title", 500, 0) } return h.albumsWhere(ctx, libs, ` AND EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author' AND ip.person_id = ?)`, []any{personID}, "i.sort_title", 500, 0) } // loadAlbum authorizes and loads one album for a request. // // catalog.LoadFor is the one access rule: it reads the item and confirms the // caller may read its library, returning the same ErrNotFound for a missing // item and a forbidden one. The album projection below is Subsonic's own // presentation concern — song count, duration, artist credit and cover are not // part of catalog's item model — and no longer decides access. func (h *Handlers) loadAlbum(ctx context.Context, u auth.User, itemID int64) (albumInfo, bool, error) { it, err := catalog.LoadFor(ctx, h.db, u, itemID) if err != nil { if errors.Is(err, catalog.ErrNotFound) { return albumInfo{}, false, nil } return albumInfo{}, false, err } albums, err := h.albumsWhere(ctx, []int64{it.LibraryID}, ` AND i.id = ?`, []any{itemID}, "i.id", 1, 0) if err != nil { return albumInfo{}, false, err } if len(albums) == 0 { return albumInfo{}, false, nil } return albums[0], true, nil } func scanAlbums(rows *sql.Rows) ([]albumInfo, error) { out := []albumInfo{} for rows.Next() { var a albumInfo var hasCover int if err := rows.Scan(&a.ID, &a.LibraryID, &a.Kind, &a.Path, &a.Title, &a.Year, &a.AddedAt, &a.ArtistID, &a.SongCount, &a.DurationMS, &hasCover, &a.Artist); err != nil { return nil, err } a.HasCover = hasCover == 1 out = append(out, a) } return out, rows.Err() } // ── tracks ───────────────────────────────────────────────────────────── const trackSelect = ` SELECT f.id, f.item_id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at, f.path, f.size, f.duration_ms, f.container, COALESCE((SELECT ip.person_id FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0), COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), ''), EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover') FROM media_files f JOIN media_items i ON i.id = f.item_id` func (h *Handlers) tracksForItem(ctx context.Context, itemID int64) ([]track, error) { rows, err := h.db.QueryContext(ctx, trackSelect+` WHERE f.item_id = ? AND f.missing = 0 AND i.missing = 0 ORDER BY f.path`, itemID) if err != nil { return nil, err } defer rows.Close() tracks, err := scanTracks(rows) if err != nil { return nil, err } for i := range tracks { tracks[i].Ordinal = i + 1 } return tracks, nil } // loadTrack authorizes and loads one song (a media_files row) for a request. // // The song projection above is Subsonic's own presentation shape — album // context, artist credit, cover — so it stays here. Access is not its job: // catalog.LoadFor is the one access rule, returning the same ErrNotFound for a // missing item and a forbidden one so a 404 cannot leak existence. func (h *Handlers) loadTrack(ctx context.Context, u auth.User, fileIDVal int64) (track, bool, error) { rows, err := h.db.QueryContext(ctx, trackSelect+` WHERE f.id = ? AND f.missing = 0 AND i.missing = 0`, fileIDVal) if err != nil { return track{}, false, err } defer rows.Close() tracks, err := scanTracks(rows) if err != nil { return track{}, false, err } if len(tracks) == 0 { return track{}, false, nil } if _, err := catalog.LoadFor(ctx, h.db, u, tracks[0].ItemID); err != nil { if errors.Is(err, catalog.ErrNotFound) { return track{}, false, nil } return track{}, false, err } return tracks[0], true, nil } func (h *Handlers) songsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]track, error) { if limit <= 0 || limit > 500 { limit = 500 } in, args := inClause(libs) args = append(args, extraArgs...) q := trackSelect + ` WHERE i.missing = 0 AND f.missing = 0 AND i.kind IN ('music','audiobooks') AND i.library_id IN (` + in + `)` + extra + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := h.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() return scanTracks(rows) } func scanTracks(rows *sql.Rows) ([]track, error) { out := []track{} for rows.Next() { var t track var hasCover int if err := rows.Scan(&t.FileID, &t.ItemID, &t.LibraryID, &t.Kind, &t.AlbumPath, &t.AlbumTitle, &t.Year, &t.AddedAt, &t.RelPath, &t.Size, &t.DurationMS, &t.Container, &t.ArtistID, &t.Artist, &hasCover); err != nil { return nil, err } t.HasCover = hasCover == 1 out = append(out, t) } return out, rows.Err() } // ── artists ──────────────────────────────────────────────────────────── type artistInfo struct { id int64 name string albumCount int coverAlbum int64 } // artists returns every artist with at least one visible audio album, plus a // synthetic "Unknown Artist" bucket when some albums carry no author credit. func (h *Handlers) artists(ctx context.Context, u auth.User) ([]artistInfo, error) { libs, err := h.audioLibraryIDs(ctx, u) if err != nil { return nil, err } return h.searchArtists(ctx, libs, "", 500, 0) } func (h *Handlers) searchArtists(ctx context.Context, libs []int64, q string, limit, offset int) ([]artistInfo, error) { if limit <= 0 || limit > 500 { limit = 500 } in, args := inClause(libs) args = append(args, q, q, limit, offset) rows, err := h.db.QueryContext(ctx, ` SELECT p.id, p.name, count(DISTINCT i.id), COALESCE((SELECT a.item_id FROM item_people ip2 JOIN media_items i2 ON i2.id = ip2.item_id JOIN assets a ON a.item_id = i2.id AND a.role = 'cover' WHERE ip2.person_id = p.id AND ip2.role = 'author' AND i2.missing = 0 ORDER BY i2.id LIMIT 1), 0) FROM people p JOIN item_people ip ON ip.person_id = p.id JOIN media_items i ON i.id = ip.item_id WHERE ip.role = 'author' AND i.missing = 0 AND i.kind IN ('music','audiobooks') AND i.library_id IN (`+in+`) AND (? = '' OR p.name LIKE '%' || ? || '%') GROUP BY p.id, p.name ORDER BY p.name LIMIT ? OFFSET ?`, args...) if err != nil { return nil, err } defer rows.Close() out := []artistInfo{} for rows.Next() { var a artistInfo if err := rows.Scan(&a.id, &a.name, &a.albumCount, &a.coverAlbum); err != nil { return nil, err } out = append(out, a) } if err := rows.Err(); err != nil { return nil, err } // Include the uncredited bucket only when there is something in it, and // only on the unfiltered listing (a name search should not surface it). if q == "" { n, err := h.uncreditedAlbumCount(ctx, libs) if err != nil { return nil, err } if n > 0 { out = append(out, artistInfo{id: 0, name: "Unknown Artist", albumCount: n}) } } return out, nil } func (h *Handlers) uncreditedAlbumCount(ctx context.Context, libs []int64) (int, error) { in, args := inClause(libs) var n int err := h.db.QueryRowContext(ctx, ` SELECT count(*) FROM media_items i WHERE i.missing = 0 AND i.kind IN ('music','audiobooks') AND i.library_id IN (`+in+`) AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`, args...).Scan(&n) return n, err } func (h *Handlers) artistName(ctx context.Context, personID int64) (string, error) { if personID == 0 { return "Unknown Artist", nil } var name string err := h.db.QueryRowContext(ctx, `SELECT name FROM people WHERE id = ?`, personID).Scan(&name) if err == sql.ErrNoRows { return "Unknown Artist", nil } return name, err } // searchAlbums filters the album projection by title or credited artist. func (h *Handlers) searchAlbums(ctx context.Context, libs []int64, q string, limit, offset int) ([]albumInfo, error) { extra := ` AND (? = '' OR i.title LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.item_id = i.id AND ip.role = 'author' AND p.name LIKE '%' || ? || '%'))` return h.albumsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title", limit, offset) } func (h *Handlers) searchSongs(ctx context.Context, libs []int64, q string, limit, offset int) ([]track, error) { extra := ` AND (? = '' OR f.path LIKE '%' || ? || '%' OR i.title LIKE '%' || ? || '%')` return h.songsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title, f.path", limit, offset) } // addIndexes groups artists by their index letter and appends children // to parent. withCount controls whether the ArtistID3 albumCount is emitted // (getArtists) or the leaner Child shape is used (getIndexes). func addIndexes(parent *node, artists []artistInfo, withCount bool) { sorted := append([]artistInfo(nil), artists...) sort.Slice(sorted, func(i, j int) bool { li, lj := indexLetter(sorted[i].name), indexLetter(sorted[j].name) if li != lj { return li < lj } return sorted[i].name < sorted[j].name }) var current *node currentLetter := "" for _, a := range sorted { letter := indexLetter(a.name) if current == nil || letter != currentLetter { current = el("index", at("name", letter)).arrays("artist") parent.add(current) currentLetter = letter } artist := el("artist", at("id", artistID(a.id)), at("name", a.name)) if withCount { artist.attrs = append(artist.attrs, at("albumCount", int64(a.albumCount))) } if a.coverAlbum != 0 { artist.attrs = append(artist.attrs, at("coverArt", albumID(a.coverAlbum))) } current.add(artist) } } // indexLetter returns the letter an artist files under, skipping a leading // ignored article ("The Beatles" -> B) and folding anything non-alphabetic // into "#". func indexLetter(name string) string { name = strings.TrimSpace(name) lower := strings.ToLower(name) for _, article := range strings.Fields(ignoredArticles) { prefix := strings.ToLower(article) + " " if strings.HasPrefix(lower, prefix) { name = name[len(prefix):] break } } for _, r := range name { if unicode.IsLetter(r) { return strings.ToUpper(string(r)) } break } return "#" } func firstCover(albums []albumInfo) int64 { for _, a := range albums { if a.HasCover { return a.ID } } return 0 } // ── id resolution ────────────────────────────────────────────────────── // resolveAudioFile turns a Subsonic song/album id into an absolute path on // disk, after the library-access chokepoint. func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string) (string, bool, error) { var ( rel string lib int64 ) switch { case strings.HasPrefix(id, prefixFile): fileIDVal, ok := parseID(id, prefixFile) if !ok { return "", false, nil } t, ok, err := h.loadTrack(ctx, u, fileIDVal) if err != nil || !ok { return "", false, err } rel, lib = t.RelPath, t.LibraryID case strings.HasPrefix(id, prefixAlbum): itemID, ok := parseID(id, prefixAlbum) if !ok { return "", false, nil } a, ok, err := h.loadAlbum(ctx, u, itemID) if err != nil || !ok { return "", false, err } tracks, err := h.tracksForItem(ctx, itemID) if err != nil || len(tracks) == 0 { return "", false, err } rel, lib = tracks[0].RelPath, a.LibraryID default: return "", false, nil } root, err := h.libraryRoot(ctx, lib) if err != nil { return "", false, err } return filepath.Join(root, filepath.FromSlash(rel)), true, nil } // coverItemID maps a coverArt id to the media_items row that owns the cover. // Artist covers are not produced by any extractor, so an ar- id resolves to // nothing rather than an arbitrary album. func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int64, bool, error) { switch { case strings.HasPrefix(id, prefixAlbum): itemID, ok := parseID(id, prefixAlbum) if !ok { return 0, false, nil } if _, ok, err := h.loadAlbum(ctx, u, itemID); err != nil || !ok { return 0, false, err } return itemID, true, nil case strings.HasPrefix(id, prefixFile): fileIDVal, ok := parseID(id, prefixFile) if !ok { return 0, false, nil } t, ok, err := h.loadTrack(ctx, u, fileIDVal) if err != nil || !ok { return 0, false, err } return t.ItemID, true, nil default: return 0, false, nil } } // ── small helpers ────────────────────────────────────────────────────── // inClause builds a placeholder list for an IN (...) predicate. An empty set // yields "NULL", which matches nothing — never "all", which is the whole point // of the access-control model. func inClause(ids []int64) (string, []any) { if len(ids) == 0 { return "NULL", nil } ph := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { ph[i] = "?" args[i] = id } return strings.Join(ph, ","), args } func intParam(r *http.Request, name string, def, max int) int { v := r.FormValue(name) if v == "" { return def } n, err := strconv.Atoi(v) if err != nil || n < 0 { return def } if n > max { return max } return n } func countOffset(r *http.Request, countName, offsetName string) (int, int) { return intParam(r, countName, 20, 500), intParam(r, offsetName, 0, 1_000_000) }