// 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) }