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.
264 lines
6.6 KiB
Go
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)
|
|
}
|