Vellum/internal/subsonic/store.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

547 lines
18 KiB
Go

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"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// 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) ([]dto.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) ([]dto.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) (dto.AlbumInfo, bool, error) {
it, err := catalog.LoadFor(ctx, h.db, u, itemID)
if err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return dto.AlbumInfo{}, false, nil
}
return dto.AlbumInfo{}, false, err
}
albums, err := h.albumsWhere(ctx, []int64{it.LibraryID}, ` AND i.id = ?`, []any{itemID}, "i.id", 1, 0)
if err != nil {
return dto.AlbumInfo{}, false, err
}
if len(albums) == 0 {
return dto.AlbumInfo{}, false, nil
}
return albums[0], true, nil
}
func scanAlbums(rows *sql.Rows) ([]dto.AlbumInfo, error) {
out := []dto.AlbumInfo{}
for rows.Next() {
var a dto.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) ([]dto.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) (dto.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 dto.Track{}, false, err
}
defer rows.Close()
tracks, err := scanTracks(rows)
if err != nil {
return dto.Track{}, false, err
}
if len(tracks) == 0 {
return dto.Track{}, false, nil
}
if _, err := catalog.LoadFor(ctx, h.db, u, tracks[0].ItemID); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return dto.Track{}, false, nil
}
return dto.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) ([]dto.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) ([]dto.Track, error) {
out := []dto.Track{}
for rows.Next() {
var t dto.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) ([]dto.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) ([]dto.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 <index> children
// to parent. withCount controls whether the ArtistID3 albumCount is emitted
// (getArtists) or the leaner Child shape is used (getIndexes).
func addIndexes(parent *dto.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 *dto.Node
currentLetter := ""
for _, a := range sorted {
letter := indexLetter(a.name)
if current == nil || letter != currentLetter {
current = dto.El("index", dto.At("name", letter)).Arrays("artist")
parent.Add(current)
currentLetter = letter
}
artist := dto.El("artist", dto.At("id", dto.ArtistID(a.id)), dto.At("name", a.name))
if withCount {
artist.Attrs = append(artist.Attrs, dto.At("albumCount", int64(a.albumCount)))
}
if a.coverAlbum != 0 {
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.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 []dto.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, dto.PrefixFile):
fileIDVal, ok := dto.ParseID(id, dto.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, dto.PrefixAlbum):
itemID, ok := dto.ParseID(id, dto.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, dto.PrefixAlbum):
itemID, ok := dto.ParseID(id, dto.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, dto.PrefixFile):
fileIDVal, ok := dto.ParseID(id, dto.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)
}