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

264 lines
6.6 KiB
Go

package subsonic
import (
"context"
"net/http"
"path"
"sort"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// directoryForLibrary renders the root of one audio library.
func (h *Handlers) directoryForLibrary(w http.ResponseWriter, r *http.Request, u auth.User, libID int64) {
libs, err := h.audioLibraries(r.Context(), u)
if err != nil {
h.internal(w, r, "directory", err)
return
}
name := ""
for _, l := range libs {
if l.id == libID {
name = l.name
break
}
}
if name == "" {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.writeDirectory(w, r, u, libID, "", name)
}
// directoryForPath renders one directory inside an audio library.
func (h *Handlers) directoryForPath(w http.ResponseWriter, r *http.Request, u auth.User, libID int64, rel string) {
// Confirm the library is one the caller may read before exposing anything.
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "directory", err)
return
}
if !containsID(libs, libID) {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
rel = normalizeRel(rel)
name := path.Base(rel)
if rel == "." {
name = ""
}
h.writeDirectory(w, r, u, libID, rel, name)
}
// writeDirectory builds a <directory> element for a library path prefix.
//
// Vellum's scanner groups audio by directory and never writes parent_id, so the
// tree is reconstructed here from media_items.path: every item whose parent
// directory is the prefix is a direct album child, and every deeper item
// contributes one directory child for the next path segment.
func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth.User, libID int64, rel, name string) {
rel = normalizeRel(rel)
if name == "" {
name = path.Base(rel)
if rel == "." {
if root, err := h.libraryRoot(r.Context(), libID); err == nil {
name = path.Base(root)
}
if name == "" || name == "." || name == "/" {
name = "Music"
}
}
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, path, title FROM media_items
WHERE library_id = ? AND missing = 0 AND kind IN ('music','audiobooks')
ORDER BY path`, libID)
if err != nil {
h.internal(w, r, "directory", err)
return
}
defer rows.Close()
var (
childAlbumIDs []int64
subdirs = map[string]bool{}
)
for rows.Next() {
var (
id int64
ipath string
title string
)
if err := rows.Scan(&id, &ipath, &title); err != nil {
h.internal(w, r, "directory", err)
return
}
dir := normalizeRel(ipath)
parent := path.Dir(dir)
if parent == "" {
parent = "."
}
switch {
case parent == rel:
childAlbumIDs = append(childAlbumIDs, id)
case rel == "." && dir != ".":
subdirs[firstSegment(dir)] = true
case rel != "." && strings.HasPrefix(dir, rel+"/"):
rest := strings.TrimPrefix(dir, rel+"/")
if seg := firstSegment(rest); seg != "" {
subdirs[seg] = true
}
}
}
if err := rows.Err(); err != nil {
h.internal(w, r, "directory", err)
return
}
dirNode := dto.El("directory",
dto.At("id", dto.DirID(libID, rel)),
dto.At("name", name),
).Arrays("child")
subNames := make([]string, 0, len(subdirs))
for s := range subdirs {
subNames = append(subNames, s)
}
sort.Strings(subNames)
for _, seg := range subNames {
sub := seg
if rel != "." {
sub = rel + "/" + seg
}
dirNode.Add(dto.El("child",
dto.At("id", dto.DirID(libID, sub)),
dto.At("parent", dto.DirID(libID, rel)),
dto.At("isDir", true),
dto.At("title", path.Base(seg)),
dto.At("album", path.Base(seg)),
dto.At("artist", ""),
))
}
albums, err := h.albumsByIDs(r.Context(), libID, childAlbumIDs)
if err != nil {
h.internal(w, r, "directory albums", err)
return
}
for _, a := range albums {
dirNode.Add(dto.ChildNode(a))
}
root := envelope("ok")
root.Add(dirNode)
h.write(w, r, root)
}
// directoryForAlbum renders an album as a directory whose children are songs.
func (h *Handlers) directoryForAlbum(w http.ResponseWriter, r *http.Request, u auth.User, itemID int64) {
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
if err != nil {
h.internal(w, r, "directory", err)
return
}
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
tracks, err := h.tracksForItem(r.Context(), itemID)
if err != nil {
h.internal(w, r, "directory songs", err)
return
}
dirNode := dto.El("directory",
dto.At("id", dto.AlbumID(a.ID)),
dto.At("parent", dto.DirID(a.LibraryID, parentDirOf(a.Path))),
dto.At("name", a.Title),
).Arrays("child")
for _, t := range tracks {
child := dto.SongNode(t)
child.Name = "child"
dirNode.Add(child)
}
root := envelope("ok")
root.Add(dirNode)
h.write(w, r, root)
}
// directoryForArtist renders an artist as a directory whose children are the
// artist's albums. Legacy clients reach an artist through getMusicDirectory
// rather than getArtist.
func (h *Handlers) directoryForArtist(w http.ResponseWriter, r *http.Request, u auth.User, personID int64) {
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "directory", err)
return
}
albums, err := h.albumsByArtist(r.Context(), libs, personID)
if err != nil {
h.internal(w, r, "directory albums", err)
return
}
if len(albums) == 0 {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
name, _ := h.artistName(r.Context(), personID)
dirNode := dto.El("directory",
dto.At("id", dto.ArtistID(personID)),
dto.At("name", name),
).Arrays("child")
for _, a := range albums {
dirNode.Add(dto.ChildNode(a))
}
root := envelope("ok")
root.Add(dirNode)
h.write(w, r, root)
}
// albumsByIDs loads the album projection for a specific id set within one
// library, preserving album-title order.
func (h *Handlers) albumsByIDs(ctx context.Context, libID int64, ids []int64) ([]dto.AlbumInfo, error) {
if len(ids) == 0 {
return []dto.AlbumInfo{}, nil
}
in, args := inClause(ids)
return h.albumsWhere(ctx, []int64{libID}, " AND i.id IN ("+in+")", args, "i.sort_title", 500, 0)
}
func containsID(ids []int64, id int64) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
// normalizeRel maps the library root, spelled "" or ".", to the canonical ".".
func normalizeRel(rel string) string {
rel = strings.Trim(path.Clean("/"+rel), "/")
if rel == "" {
return "."
}
return rel
}
func firstSegment(p string) string {
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[:i]
}
return p
}
// parentDirOf returns the parent directory of a media_items path, mapping the
// root to ".".
func parentDirOf(p string) string {
p = normalizeRel(p)
return path.Dir(p)
}