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.
175 lines
5.7 KiB
Go
175 lines
5.7 KiB
Go
// Package library owns library definitions — create, list, delete — and the
|
|
// media-kind registry that says which file extensions each kind of library owns.
|
|
//
|
|
// The registry lives here, not in internal/scanner, because it is not the
|
|
// scanner's business: modules register their types, the admin HTTP surface
|
|
// serves them, and the metadata enricher reads the grouping mode. Only one of
|
|
// those three is the scanner, so the registry belongs in the lower layer that
|
|
// all three already depend on. The scanner imports it from here; nothing here
|
|
// imports the scanner.
|
|
package library
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// GroupMode says how files become items.
|
|
type GroupMode string
|
|
|
|
const (
|
|
// GroupByFile makes one item per file. Correct for epubs, comics, photos.
|
|
GroupByFile GroupMode = "file"
|
|
// GroupByDirectory makes one item per directory, with its files as tracks.
|
|
// Correct for audiobooks (a folder of chapters) and albums.
|
|
GroupByDirectory GroupMode = "directory"
|
|
)
|
|
|
|
// MediaType declares one kind of library and the file extensions it owns.
|
|
type MediaType struct {
|
|
// Kind is the value stored in libraries.kind and media_items.kind.
|
|
Kind string
|
|
// Name is the human label shown in the UI.
|
|
Name string
|
|
// Extensions are lowercase, with a leading dot (".epub").
|
|
Extensions []string
|
|
// Group controls whether files collapse into one item per directory.
|
|
Group GroupMode
|
|
}
|
|
|
|
// builtinTypes are the media types Vellum understands out of the box. Modules
|
|
// may add more at runtime with RegisterType; these exist so the core is
|
|
// useful before any module is wired up.
|
|
var builtinTypes = []MediaType{
|
|
{Kind: "ebooks", Name: "Ebooks", Extensions: []string{".epub", ".mobi", ".azw3", ".fb2"}, Group: GroupByFile},
|
|
{Kind: "comics", Name: "Comics", Extensions: []string{".cbz", ".cbr", ".pdf"}, Group: GroupByFile},
|
|
{Kind: "audiobooks", Name: "Audiobooks", Extensions: []string{".m4b", ".mp3", ".m4a", ".opus", ".flac", ".ogg"}, Group: GroupByDirectory},
|
|
{Kind: "music", Name: "Music", Extensions: []string{".mp3", ".flac", ".m4a", ".opus", ".ogg", ".wav"}, Group: GroupByDirectory},
|
|
{Kind: "photos", Name: "Photos", Extensions: []string{".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif", ".avif"}, Group: GroupByFile},
|
|
{Kind: "games", Name: "Games", Extensions: []string{
|
|
".nes", ".fds", ".snes", ".sfc", ".smc", ".gba", ".gb", ".gbc",
|
|
".n64", ".z64", ".v64", ".nds", ".3ds", ".cia",
|
|
".md", ".gen", ".smd", ".sms", ".gg", ".pce", ".ngp", ".ngc", ".ws", ".wsc",
|
|
".a26", ".a78", ".lnx", ".vec", ".col", ".int",
|
|
".psx", ".ps1", ".cue", ".chd", ".pbp",
|
|
}, Group: GroupByFile},
|
|
{Kind: "video", Name: "Video", Extensions: []string{".mkv", ".mp4", ".webm", ".avi", ".mov"}, Group: GroupByFile},
|
|
}
|
|
|
|
var (
|
|
typesMu sync.RWMutex
|
|
types = map[string]MediaType{}
|
|
extToKind = map[string]string{}
|
|
)
|
|
|
|
func init() {
|
|
for _, t := range builtinTypes {
|
|
registerTypeLocked(t)
|
|
}
|
|
}
|
|
|
|
// RegisterType adds or replaces a media type. Intended to be called from a
|
|
// module's init or wiring function.
|
|
func RegisterType(t MediaType) {
|
|
typesMu.Lock()
|
|
defer typesMu.Unlock()
|
|
registerTypeLocked(t)
|
|
}
|
|
|
|
func registerTypeLocked(t MediaType) {
|
|
t.Kind = strings.ToLower(strings.TrimSpace(t.Kind))
|
|
for i, e := range t.Extensions {
|
|
t.Extensions[i] = normalizeExt(e)
|
|
}
|
|
types[t.Kind] = t
|
|
for _, e := range t.Extensions {
|
|
// Last registration wins for a shared extension (e.g. .pdf is comics).
|
|
extToKind[e] = t.Kind
|
|
}
|
|
}
|
|
|
|
// SupportedTypes returns every registered media type, sorted by kind.
|
|
func SupportedTypes() []MediaType {
|
|
typesMu.RLock()
|
|
defer typesMu.RUnlock()
|
|
out := make([]MediaType, 0, len(types))
|
|
for _, t := range types {
|
|
out = append(out, t)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Kind < out[j].Kind })
|
|
return out
|
|
}
|
|
|
|
// KindForExtension maps a file extension to the media kind that owns it.
|
|
func KindForExtension(ext string) (string, bool) {
|
|
typesMu.RLock()
|
|
defer typesMu.RUnlock()
|
|
k, ok := extToKind[normalizeExt(ext)]
|
|
return k, ok
|
|
}
|
|
|
|
var (
|
|
// ErrUnknownKind means the requested media kind is not registered.
|
|
ErrUnknownKind = errors.New("library: unknown media kind")
|
|
)
|
|
|
|
// ValidateKind reports whether kind is a registered media type.
|
|
func ValidateKind(kind string) error {
|
|
typesMu.RLock()
|
|
defer typesMu.RUnlock()
|
|
k := strings.ToLower(strings.TrimSpace(kind))
|
|
if _, ok := types[k]; !ok {
|
|
return fmt.Errorf("%w %q", ErrUnknownKind, kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GroupModeFor returns how a library kind turns files into items.
|
|
func GroupModeFor(kind string) GroupMode {
|
|
typesMu.RLock()
|
|
defer typesMu.RUnlock()
|
|
if t, ok := types[kind]; ok && t.Group != "" {
|
|
return t.Group
|
|
}
|
|
return GroupByFile
|
|
}
|
|
|
|
// ExtensionsFor returns the extension set owned by a library kind, each entry
|
|
// lowercase with a leading dot. The scanner walks a tree by testing every file
|
|
// name against this set, which is why it lives in the registry rather than in
|
|
// the scanner: the answer is a property of the kind, not of the scan.
|
|
func ExtensionsFor(kind string) map[string]bool {
|
|
typesMu.RLock()
|
|
defer typesMu.RUnlock()
|
|
t, ok := types[kind]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
set := make(map[string]bool, len(t.Extensions))
|
|
for _, e := range t.Extensions {
|
|
set[e] = true
|
|
}
|
|
return set
|
|
}
|
|
|
|
func normalizeExt(ext string) string {
|
|
ext = strings.ToLower(strings.TrimSpace(ext))
|
|
if ext != "" && !strings.HasPrefix(ext, ".") {
|
|
ext = "." + ext
|
|
}
|
|
return ext
|
|
}
|
|
|
|
// CleanTitle turns a filename into a human title: strips the extension,
|
|
// collapses separators, and trims noise.
|
|
func CleanTitle(filename string) string {
|
|
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
|
|
base = strings.ReplaceAll(base, "_", " ")
|
|
base = strings.ReplaceAll(base, ".", " ")
|
|
base = strings.Join(strings.Fields(base), " ")
|
|
return strings.TrimSpace(base)
|
|
}
|