One "video" kind meant every video library behaved the same, so a film shelf tried to parse episode numbers and a TV library could not be told apart from it. The kinds are now a declaration rather than a special case. Five video kinds, all owning the same containers and differing only in how their files become items: movies flat tv series/season/episode adult flat hentai series/season/episode video series/season/episode (kept: existing libraries already use it) The scanner dispatches on library.GroupModeFor(lib.Kind) instead of comparing the kind to "video", so a new episodic kind is a registry entry and nothing in the scanner changes. The decision worth recording, because it could have gone the other way: media_items.kind stays STRUCTURAL. Every video library stores 'video' on its rows, not its own kind. media_items.kind answers "is this a playable video", which is what the playback, catalog and Jellyfin paths actually ask, and migration 0013 already committed to that rule when it decided an episode would stay kind='video'. Storing the library kind instead would have meant teaching every query that says kind='video' about five new values - catalog's episode and next-episode reads, Jellyfin's episodes-only filter, the item-type mapping, the web player's routing - and every kind added later would have had to do it again. So the two jobs are split: the ROW kind says what a thing is, the LIBRARY kind says how it is classified, and the library kind is what routes metadata. The enricher now reads the library to choose providers, because a movies library and an adult library hold identical rows and must not query the same catalogue. The adult kinds have no metadata provider. There is no mainstream catalogue for that content - the ecosystem uses StashDB or local NFO files - so they scan and browse and stay unenriched until someone registers one. That is stated in the registry rather than left as a surprise. Verified end-to-end: the same tree under a movies library stays flat, under a tv library builds series/season/episodes, and every row in both is stored as kind='video'. The UI needs no change - it reads /api/v1/media-types, so the new kinds appeared in the picker by themselves. Red-green: dispatching on the literal kind fails the tv and hentai cases; storing the library kind on rows fails the structural-kind assertion.
247 lines
9.5 KiB
Go
247 lines
9.5 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"
|
|
// 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)
|
|
}
|