Separate video libraries by kind: movies, TV, adult, hentai

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.
This commit is contained in:
mithral 2026-10-12 11:20:51 -04:00
parent 2c424ae67c
commit 1fcc7344cb
6 changed files with 228 additions and 12 deletions

View file

@ -27,6 +27,15 @@ const (
// GroupByDirectory makes one item per directory, with its files as tracks. // GroupByDirectory makes one item per directory, with its files as tracks.
// Correct for audiobooks (a folder of chapters) and albums. // Correct for audiobooks (a folder of chapters) and albums.
GroupByDirectory GroupMode = "directory" 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. // MediaType declares one kind of library and the file extensions it owns.
@ -39,11 +48,40 @@ type MediaType struct {
Extensions []string Extensions []string
// Group controls whether files collapse into one item per directory. // Group controls whether files collapse into one item per directory.
Group GroupMode 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 // 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 // may add more at runtime with RegisterType; these exist so the core is
// useful before any module is wired up. // 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{ var builtinTypes = []MediaType{
{Kind: "ebooks", Name: "Ebooks", Extensions: []string{".epub", ".mobi", ".azw3", ".fb2"}, Group: GroupByFile}, {Kind: "ebooks", Name: "Ebooks", Extensions: []string{".epub", ".mobi", ".azw3", ".fb2"}, Group: GroupByFile},
{Kind: "comics", Name: "Comics", Extensions: []string{".cbz", ".cbr", ".pdf"}, 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", ".a26", ".a78", ".lnx", ".vec", ".col", ".int",
".psx", ".ps1", ".cue", ".chd", ".pbp", ".psx", ".ps1", ".cue", ".chd", ".pbp",
}, Group: GroupByFile}, }, 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 ( var (
@ -138,6 +195,21 @@ func GroupModeFor(kind string) GroupMode {
return GroupByFile 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 // 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 // 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 // name against this set, which is why it lives in the registry rather than in

View file

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

View file

@ -106,7 +106,16 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
return nil 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 { if len(provs) == 0 {
return nil 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 // 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 // hint every provider that matches titles can use. Providers that cannot
// ignore it. // 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 { for _, p := range provs {
if e.triedRecently(ctx, itemID, p.Name()) { if e.triedRecently(ctx, itemID, p.Name()) {
continue continue

View file

@ -40,7 +40,7 @@ func NewTMDB(apiKey string) *TMDB {
func (p *TMDB) Name() string { return "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 // 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 // television share the envelope but not the field names, so the few that differ

View file

@ -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, // buildGroups turns found files into items. Directory-grouped kinds (audiobooks,
// music) collapse a folder of tracks into one item; everything else is one item // 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 // per file — except an episode-grouped kind, where a file that parses as an
// grows a series and a season above it. // 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 { 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) 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) groups := make(map[string]group)
for _, rel := range sortedCandidateKeys(found) { 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 := 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) g.files = append(g.files, c)
groups[key] = g 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 // 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. // 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 { 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) groups := make(map[string]group)
add := func(g 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)) season, episode, isEpisode := parseEpisode(path.Base(rel), path.Base(dir))
if !isEpisode { if !isEpisode {
g := groups[rel] 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) g.files = append(g.files, c)
groups[rel] = g groups[rel] = g
continue continue
@ -496,7 +509,7 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g
// after the library. // after the library.
if dir == "." { if dir == "." {
g := groups[rel] 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.season, g.episode = season, episode
g.files = append(g.files, c) g.files = append(g.files, c)
groups[rel] = g groups[rel] = g
@ -524,7 +537,7 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g
}) })
g := groups[rel] 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.parentKey, g.season, g.episode = seasonKey, season, episode
g.files = append(g.files, c) g.files = append(g.files, c)
groups[rel] = g groups[rel] = g

View file

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