diff --git a/internal/library/registry.go b/internal/library/registry.go index 003cdf9..401af1f 100644 --- a/internal/library/registry.go +++ b/internal/library/registry.go @@ -27,6 +27,15 @@ const ( // 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. @@ -39,11 +48,40 @@ type MediaType struct { 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}, @@ -57,7 +95,26 @@ var builtinTypes = []MediaType{ ".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}, + + // 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 ( @@ -138,6 +195,21 @@ func GroupModeFor(kind string) GroupMode { 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 diff --git a/internal/library/registry_test.go b/internal/library/registry_test.go new file mode 100644 index 0000000..f97f870 --- /dev/null +++ b/internal/library/registry_test.go @@ -0,0 +1,59 @@ +package library + +import "testing" + +// The video kinds share their extensions and differ only in how they group and +// which kind they store. Those two facts are what let a movies library and a TV +// library behave differently without the scanner knowing either name. +func TestVideoKindsGroupAndStoreCorrectly(t *testing.T) { + tests := []struct { + kind string + wantGroup GroupMode + wantItem string + }{ + {"movies", GroupByFile, "video"}, + {"tv", GroupByEpisode, "video"}, + {"video", GroupByEpisode, "video"}, + {"adult", GroupByFile, "video"}, + {"hentai", GroupByEpisode, "video"}, + + // Every other media type stores its own kind, which is what they all + // did before the video kinds were split. + {"music", GroupByDirectory, "music"}, + {"ebooks", GroupByFile, "ebooks"}, + {"audiobooks", GroupByDirectory, "audiobooks"}, + } + for _, tt := range tests { + if got := GroupModeFor(tt.kind); got != tt.wantGroup { + t.Errorf("GroupModeFor(%q) = %q, want %q", tt.kind, got, tt.wantGroup) + } + if got := ItemKindFor(tt.kind); got != tt.wantItem { + t.Errorf("ItemKindFor(%q) = %q, want %q", tt.kind, got, tt.wantItem) + } + } +} + +// Every video kind owns the same containers. They are separated by how their +// files become items, never by what they can hold. +func TestVideoKindsShareTheirExtensions(t *testing.T) { + want := ExtensionsFor("video") + if len(want) == 0 { + t.Fatal("the video kind owns no extensions") + } + for _, kind := range []string{"movies", "tv", "adult", "hentai"} { + got := ExtensionsFor(kind) + for ext := range want { + if !got[ext] { + t.Errorf("%s does not own %s, but every video kind should", kind, ext) + } + } + } +} + +// A kind nobody registered is still described by what is already on the row, so +// a library created before a kind existed keeps working. +func TestItemKindForUnregisteredKind(t *testing.T) { + if got := ItemKindFor("nonsense"); got != "nonsense" { + t.Errorf("ItemKindFor(nonsense) = %q, want nonsense", got) + } +} diff --git a/internal/metadata/enrich.go b/internal/metadata/enrich.go index f79cfd4..f0bfed3 100644 --- a/internal/metadata/enrich.go +++ b/internal/metadata/enrich.go @@ -106,7 +106,16 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error { return nil } - provs := e.providers(it.Kind) + // Providers are chosen by the LIBRARY's kind, not the row's. Every video kind + // stores kind='video' (see library.MediaType.ItemKind), so a movies library + // and an adult library hold identical rows; only the library says which + // catalogue is allowed to look at them. Routing on the row kind would send + // every video item to TMDB, adult content included. + kind := it.Kind + if lib, err := library.Get(ctx, e.db, it.LibraryID); err == nil { + kind = lib.Kind + } + provs := e.providers(kind) if len(provs) == 0 { return nil } @@ -114,7 +123,7 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error { // Year is what disambiguates a remake from the original, and it is the one // hint every provider that matches titles can use. Providers that cannot // ignore it. - q := providers.Query{Title: searchTitle(it.Title), Year: it.Year, Author: artistHint(it), Kind: it.Kind} + q := providers.Query{Title: searchTitle(it.Title), Year: it.Year, Author: artistHint(it), Kind: kind} for _, p := range provs { if e.triedRecently(ctx, itemID, p.Name()) { continue diff --git a/internal/providers/tmdb.go b/internal/providers/tmdb.go index ff56d3e..a9293db 100644 --- a/internal/providers/tmdb.go +++ b/internal/providers/tmdb.go @@ -40,7 +40,7 @@ func NewTMDB(apiKey string) *TMDB { func (p *TMDB) Name() string { return "tmdb" } -func (p *TMDB) Kinds() []string { return []string{"video"} } +func (p *TMDB) Kinds() []string { return []string{"video", "movies", "tv"} } // tmdbResponse is the subset of a TMDB search payload we read. Film and // television share the envelope but not the field names, so the few that differ diff --git a/internal/scanner/scan.go b/internal/scanner/scan.go index fe4fcfd..8a85713 100644 --- a/internal/scanner/scan.go +++ b/internal/scanner/scan.go @@ -305,14 +305,23 @@ func commitBatch(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) err // buildGroups turns found files into items. Directory-grouped kinds (audiobooks, // music) collapse a folder of tracks into one item; everything else is one item -// per file — except a video library, where a file that parses as an episode -// grows a series and a season above it. +// per file — except an episode-grouped kind, where a file that parses as an +// episode grows a series and a season above it. +// +// The dispatch is on the kind's declared grouping rather than on its name, so a +// new episodic kind is a registry entry and nothing here changes. Comparing the +// kind to "video" was what made a movies library indistinguishable from a TV +// one. func buildGroups(lib library.Library, found map[string]candidate) map[string]group { - if lib.Kind == "video" { + mode := library.GroupModeFor(lib.Kind) + if mode == library.GroupByEpisode { return buildTVGroups(lib, found) } - mode := library.GroupModeFor(lib.Kind) + // The row kind is not always the library kind: every video kind stores + // "video", so that "is this a playable video" stays one answer however the + // admin classified the library. See library.MediaType.ItemKind. + itemKind := library.ItemKindFor(lib.Kind) groups := make(map[string]group) for _, rel := range sortedCandidateKeys(found) { @@ -331,7 +340,7 @@ func buildGroups(lib library.Library, found map[string]candidate) map[string]gro } g := groups[key] - g.key, g.title, g.kind = key, title, lib.Kind + g.key, g.title, g.kind = key, title, itemKind g.files = append(g.files, c) groups[key] = g } @@ -460,6 +469,10 @@ func episodeTitle(name string) string { // A file that is not an episode is a film and is grouped exactly as it was // before any of this existed, so adding it cannot disturb a movie library. func buildTVGroups(lib library.Library, found map[string]candidate) map[string]group { + // Rows carry the structural kind, not the library's own: a tv library and a + // movies library both store 'video', and only the series and season rows — + // which are the same everywhere — name themselves. See ItemKind. + itemKind := library.ItemKindFor(lib.Kind) groups := make(map[string]group) add := func(g group) { @@ -485,7 +498,7 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g season, episode, isEpisode := parseEpisode(path.Base(rel), path.Base(dir)) if !isEpisode { g := groups[rel] - g.key, g.title, g.kind = rel, library.CleanTitle(rel), lib.Kind + g.key, g.title, g.kind = rel, library.CleanTitle(rel), itemKind g.files = append(g.files, c) groups[rel] = g continue @@ -496,7 +509,7 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g // after the library. if dir == "." { g := groups[rel] - g.key, g.title, g.kind = rel, episodeTitle(path.Base(rel)), lib.Kind + g.key, g.title, g.kind = rel, episodeTitle(path.Base(rel)), itemKind g.season, g.episode = season, episode g.files = append(g.files, c) groups[rel] = g @@ -524,7 +537,7 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g }) g := groups[rel] - g.key, g.title, g.kind = rel, episodeTitle(path.Base(rel)), lib.Kind + g.key, g.title, g.kind = rel, episodeTitle(path.Base(rel)), itemKind g.parentKey, g.season, g.episode = seasonKey, season, episode g.files = append(g.files, c) groups[rel] = g diff --git a/internal/scanner/scan_test.go b/internal/scanner/scan_test.go index 4110af0..42ce4d5 100644 --- a/internal/scanner/scan_test.go +++ b/internal/scanner/scan_test.go @@ -793,3 +793,66 @@ func TestParseEpisodePatterns(t *testing.T) { } } } + +// The same tree must behave differently depending on the library's declared +// grouping. That is the whole point of separating the video kinds: a film shelf +// must not try to parse episode numbers, and a TV library must not scatter its +// episodes across the root. +// +// "video" is in the table because it is the kind existing libraries already +// have, and it must not regress. +func TestVideoKindsDifferByGrouping(t *testing.T) { + for _, tc := range []struct { + kind string + wantTree bool + }{ + {"tv", true}, + {"video", true}, + {"hentai", true}, + {"movies", false}, + {"adult", false}, + } { + t.Run(tc.kind, func(t *testing.T) { + db := testDB(t) + ctx := context.Background() + root := t.TempDir() + writeFile(t, filepath.Join(root, "Show", "Season 1", "Show S01E01.mkv"), "a") + writeFile(t, filepath.Join(root, "Show", "Season 1", "Show S01E02.mkv"), "b") + + lib, err := library.Create(ctx, db, tc.kind, tc.kind, root) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := Scan(ctx, db, lib, testLogger()); err != nil { + t.Fatalf("Scan: %v", err) + } + + got := scanItems(t, db, lib.ID) + var series, seasons, files int + for _, it := range got { + switch it.kind { + case "series": + series++ + case "season": + seasons++ + default: + files++ + } + // Whatever the grouping, a video library stores 'video'. That is + // what the playback, catalog and Jellyfin paths ask about, and + // it is why the library kind is not what goes on the row. + if it.kind != "series" && it.kind != "season" && it.kind != "video" { + t.Errorf("%q has kind %q; a video library must store 'video'", it.path, it.kind) + } + } + + if tc.wantTree { + if series != 1 || seasons != 1 || files != 2 { + t.Errorf("got %d series, %d seasons, %d files; want 1/1/2", series, seasons, files) + } + } else if series != 0 || seasons != 0 || files != 2 { + t.Errorf("got %d series, %d seasons, %d files; want 0/0/2 flat items", series, seasons, files) + } + }) + } +}