// 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" // GroupByEpisode builds a series -> season -> episode tree from directory // and file names. Correct for television, and for anything else that is // episodic rather than a flat shelf of files. // // This is what separates a movies library from a TV one: both own the same // containers, and they differ only in how their files become items. Keeping // the difference here rather than in the scanner is what lets a new kind be // a declaration instead of a special case. GroupByEpisode GroupMode = "episode" ) // 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 // ItemKind is the kind stamped on the media_items rows this library creates. // Empty means the library's own kind, which is right for every media type // whose rows are what the library is. // // Every video kind stores "video" instead. media_items.kind is a structural // fact about a row — "this is a playable video" — and that is the question // the playback, catalog and Jellyfin paths all actually ask. The library's // kind is the admin's classification, and it is what routes metadata: a // movies library and an adult library hold identical rows but must not query // the same catalogue. // // Keeping the two apart is what lets a new video kind be a registry entry. // Storing the library kind on the row instead would mean every query that // says kind='video' had to learn about it. ItemKind string } // videoExts are the containers every video-ish kind owns. Those kinds differ in // how their files become items, never in what they can hold, so the list lives // in one place and each kind says only how it groups. var videoExts = []string{".mkv", ".mp4", ".webm", ".avi", ".mov"} // videoItemKind is the kind every video library stamps on its rows. See // MediaType.ItemKind for why it is not the library's own kind. const videoItemKind = "video" // 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. // // The video kinds are separate so a library can hold one thing and behave like // it: a film shelf should not try to parse episode numbers, and a TV library // should not scatter its episodes across the root. Nothing about the extensions // distinguishes them, so the split is entirely in Group. 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}, // Films: a flat shelf. "Show S01E02.mkv" here stays a single item, because a // movies library has no seasons to file it under. {Kind: "movies", Name: "Movies", Extensions: videoExts, Group: GroupByFile, ItemKind: videoItemKind}, // Television: the series -> season -> episode tree. {Kind: "tv", Name: "TV Shows", Extensions: videoExts, Group: GroupByEpisode, ItemKind: videoItemKind}, // The original kind, kept because libraries already exist with it. It parses // episodes and leaves anything that is not one as a film, which is the right // behaviour for a shelf holding both. {Kind: "video", Name: "Video (mixed)", Extensions: videoExts, Group: GroupByEpisode, ItemKind: videoItemKind}, // Adult content, split from the rest because people keep it separate and // because the two shapes are genuinely different: scenes are a flat shelf, // animated series are episodic. // // Neither has a metadata provider. There is no mainstream catalogue for this // and the ecosystem uses StashDB or local NFO files instead, so these kinds // scan and browse but stay unenriched until someone registers a provider for // them. That is the honest state, not an oversight. {Kind: "adult", Name: "Adult", Extensions: videoExts, Group: GroupByFile, ItemKind: videoItemKind}, {Kind: "hentai", Name: "Hentai", Extensions: videoExts, Group: GroupByEpisode, ItemKind: videoItemKind}, } 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 } // ItemKindFor returns the kind stamped on the media_items rows a library kind // creates. It is the library's own kind unless the type declares otherwise, // which every video kind does — see MediaType.ItemKind. // // An unregistered kind returns itself, so a row created before a kind was // registered is still described by what is on it. func ItemKindFor(kind string) string { typesMu.RLock() defer typesMu.RUnlock() if t, ok := types[kind]; ok && t.ItemKind != "" { return t.ItemKind } return kind } // 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) }