- library: CRUD with root-path resolution (absolute, symlink-free, must be readable). A library name is unique; the root is validated at creation. - registry: media types declare their extensions; the core never imports a module. RegisterType is the extension point. - scanner: walks a root, reconciles media_items/media_files in one transaction, skips unchanged files by size+mtime, and marks vanished files missing rather than deleting them (progress is the one thing here that cannot be regenerated). A single unreadable file never fails a scan. - migration 0002: `missing` flags on items and files, plus indexes. - endpoints: media-types, library list/get/create/delete, per-library items, and scan. Library reads go through the same grant chokepoint as everything else; an unauthorised library 404s rather than 403s so its existence is not leaked.
138 lines
4 KiB
Go
138 lines
4 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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"}},
|
|
{Kind: "comics", Name: "Comics", Extensions: []string{".cbz", ".cbr", ".pdf"}},
|
|
{Kind: "audiobooks", Name: "Audiobooks", Extensions: []string{".m4b", ".mp3", ".m4a", ".opus", ".flac", ".ogg"}},
|
|
{Kind: "music", Name: "Music", Extensions: []string{".mp3", ".flac", ".m4a", ".opus", ".ogg", ".wav"}},
|
|
{Kind: "photos", Name: "Photos", Extensions: []string{".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif", ".avif"}},
|
|
{Kind: "video", Name: "Video", Extensions: []string{".mkv", ".mp4", ".webm", ".avi", ".mov"}},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|