Vellum/internal/library/registry.go
mithral 1b11867efe Phase 6a: directory grouping, audiobooks and music playback
A book is a folder of chapters, not a file. The scanner gained a grouping
mode so a directory of tracks becomes one item with ordered files.

- registry: MediaType.Group (file | directory). Audiobooks and music group by
  directory; everything else stays one item per file.
- scanner: builds groups, upserts one item per group, marks items missing when
  their directory disappears, and keeps the file-level added/updated/missing
  accounting. Tracks are ordered naturally, so chapter 2 precedes chapter 10.
- natsort: one shared natural-order comparator, now used by both the scanner
  and the comics module (which had its own copy).
- media: GET /items/{id}/files/{fileID} streams one specific track. It
  verifies the file belongs to the item, so an item id cannot be paired with
  another item's file id.

No new module was needed for playback: the polymorphic progress table already
keys on file_id, so per-chapter resume fell out of the existing design. The
web player resumes the exact track and offset.

Verified: 3 chapters grouped into one book titled "Mistborn", natural order,
range request returned 206, mismatched file id 404s, per-track progress
round-trips.
2026-10-09 21:20:47 -04:00

161 lines
4.8 KiB
Go

// Package library owns library definitions and the scanner that turns files on
// disk into rows in media_items / media_files.
//
// Modules declare what file extensions they care about via RegisterType; this
// package never imports a module.
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: "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.
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)
}