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

215 lines
5.7 KiB
Go

package dto
import (
"path/filepath"
"strconv"
"strings"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/stream"
)
// Vellum has two id spaces — media_items and media_files — and Subsonic has
// one opaque string id, so every id this layer emits is prefixed to say what it
// names. The prefix also lets stream/getCoverArt resolve an id without a
// separate lookup table.
//
// al-<itemID> an album / directory-grouped item
// mf-<fileID> a song, i.e. one physical media_files row
// ar-<personID> an artist, from people/item_people
// fd-<libID> a music folder (a whole audio library)
// d-<libID>:<relpath> a browsable directory within a library
const (
PrefixAlbum = "al-"
PrefixFile = "mf-"
PrefixArtist = "ar-"
PrefixFolder = "fd-"
PrefixDir = "d-"
)
// AlbumID renders an album id.
func AlbumID(itemID int64) string { return PrefixAlbum + strconv.FormatInt(itemID, 10) }
// FileID renders a song id.
func FileID(fileID int64) string { return PrefixFile + strconv.FormatInt(fileID, 10) }
// ArtistID renders an artist id.
func ArtistID(personID int64) string { return PrefixArtist + strconv.FormatInt(personID, 10) }
// FolderID renders a music folder id.
func FolderID(libID int64) string { return PrefixFolder + strconv.FormatInt(libID, 10) }
// DirID renders a browsable directory id.
func DirID(libID int64, rel string) string {
return PrefixDir + strconv.FormatInt(libID, 10) + ":" + rel
}
// ParseID splits a prefixed id, returning the numeric part.
func ParseID(id, prefix string) (int64, bool) {
rest, ok := strings.CutPrefix(id, prefix)
if !ok {
return 0, false
}
n, err := strconv.ParseInt(rest, 10, 64)
if err != nil || n <= 0 {
return 0, false
}
return n, true
}
// ParseDirID splits "d-<libID>:<relpath>". An empty relpath is the library root.
func ParseDirID(id string) (libID int64, rel string, ok bool) {
rest, ok := strings.CutPrefix(id, PrefixDir)
if !ok {
return 0, "", false
}
lib, rel, found := strings.Cut(rest, ":")
if !found {
return 0, "", false
}
n, err := strconv.ParseInt(lib, 10, 64)
if err != nil || n <= 0 {
return 0, "", false
}
return n, rel, true
}
// ── entities ───────────────────────────────────────────────────────────
// AlbumInfo is one album: a directory-grouped media_items row of an audio kind.
type AlbumInfo struct {
ID int64
LibraryID int64
Kind string
Path string
Title string
Year int
AddedAt string
ArtistID int64
Artist string
SongCount int
DurationMS int64
HasCover bool
}
// Track is one song: a media_files row together with its album's context.
type Track struct {
FileID int64
ItemID int64
LibraryID int64
Kind string
AlbumPath string
AlbumTitle string
Year int
AddedAt string
RelPath string
Size int64
DurationMS int64
Container string
ArtistID int64
Artist string
Ordinal int
HasCover bool
}
// SongNode renders a track as the Subsonic Child/Song element. getSong returns
// it bare; getAlbum nests it under <album>.
func SongNode(t Track) *Node {
ct := stream.ContentType(t.RelPath)
suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".")
n := El("song",
At("id", FileID(t.FileID)),
At("parent", AlbumID(t.ItemID)),
At("isDir", false),
At("title", library.CleanTitle(filepath.Base(t.RelPath))),
At("album", t.AlbumTitle),
At("albumId", AlbumID(t.ItemID)),
At("artist", t.Artist),
At("type", "music"),
At("isVideo", false),
)
if t.ArtistID > 0 {
n.Attrs = append(n.Attrs, At("artistId", ArtistID(t.ArtistID)))
}
if t.HasCover {
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(t.ItemID)))
}
if t.Ordinal > 0 {
n.Attrs = append(n.Attrs, At("track", int64(t.Ordinal)))
}
if t.Year > 0 {
n.Attrs = append(n.Attrs, At("year", int64(t.Year)))
}
n.Attrs = append(n.Attrs,
At("size", t.Size),
At("contentType", ct),
At("suffix", suffix),
At("duration", t.DurationMS/1000),
At("path", t.RelPath),
)
if t.AddedAt != "" {
n.Attrs = append(n.Attrs, At("created", t.AddedAt))
}
return n
}
// AlbumNode renders an album as the Subsonic AlbumID3 element. Extra legacy
// fields (title, album, isDir) are included because older clients read them and
// they cost nothing.
func AlbumNode(a AlbumInfo) *Node {
parent := FolderID(a.LibraryID)
if a.ArtistID > 0 {
parent = ArtistID(a.ArtistID)
}
n := El("album",
At("id", AlbumID(a.ID)),
At("parent", parent),
At("name", a.Title),
At("title", a.Title),
At("album", a.Title),
At("isDir", true),
At("artist", a.Artist),
At("songCount", int64(a.SongCount)),
At("duration", a.DurationMS/1000),
At("playCount", int64(0)),
)
if a.ArtistID > 0 {
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
}
if a.HasCover {
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
}
if a.Year > 0 {
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
}
if a.AddedAt != "" {
n.Attrs = append(n.Attrs, At("created", a.AddedAt))
}
return n
}
// ChildNode renders an album or directory entry for getMusicDirectory /
// getIndexes, where the element is a generic Child rather than AlbumID3.
func ChildNode(a AlbumInfo) *Node {
n := El("child",
At("id", AlbumID(a.ID)),
At("parent", FolderID(a.LibraryID)),
At("isDir", true),
At("title", a.Title),
At("album", a.Title),
At("artist", a.Artist),
At("isVideo", false),
)
if a.ArtistID > 0 {
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
}
if a.HasCover {
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
}
if a.Year > 0 {
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
}
return n
}