2026-07-06 18:52:44 -04:00
|
|
|
package scanner
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-08 23:18:07 +08:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-07-06 18:52:44 -04:00
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestClassifyExtraPathMovieLibrary(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
path string
|
|
|
|
|
wantKind models.ExtraKind
|
|
|
|
|
wantDir string
|
|
|
|
|
wantOK bool
|
|
|
|
|
}{
|
|
|
|
|
{"/movies/Heat (1995)/Trailers/teaser.mkv", models.ExtraKindTrailer, "/movies/Heat (1995)/Trailers", true},
|
|
|
|
|
{"/movies/Heat (1995)/Behind The Scenes/doc.mkv", models.ExtraKindBehindTheScenes, "/movies/Heat (1995)/Behind The Scenes", true},
|
|
|
|
|
{"/movies/Heat (1995)/Extras/Making Of.mkv", models.ExtraKindOther, "/movies/Heat (1995)/Extras", true},
|
2026-07-08 23:18:07 +08:00
|
|
|
// "Other" is part of the Jellyfin/Plex extras convention.
|
|
|
|
|
{"/movies/Heat (1995)/Other/making-of.mkv", models.ExtraKindOther, "/movies/Heat (1995)/Other", true},
|
2026-07-06 18:52:44 -04:00
|
|
|
// Nested one level below a supplemental dir still classifies.
|
|
|
|
|
{"/movies/Heat (1995)/Extras/Sub/clip.mkv", models.ExtraKindOther, "/movies/Heat (1995)/Extras", true},
|
2026-07-08 23:18:07 +08:00
|
|
|
// Title folders own their extras at any depth below the root.
|
|
|
|
|
{"/movies/Collection/Ronin (1998)/Other/interview.mkv", models.ExtraKindOther, "/movies/Collection/Ronin (1998)/Other", true},
|
2026-07-06 18:52:44 -04:00
|
|
|
// Suffix classification with no supplemental dir.
|
|
|
|
|
{"/movies/Heat (1995)/Heat (1995)-trailer.mkv", models.ExtraKindTrailer, "", true},
|
|
|
|
|
// Plain movie files are not extras.
|
|
|
|
|
{"/movies/Heat (1995)/Heat (1995).mkv", "", "", false},
|
2026-07-08 23:18:07 +08:00
|
|
|
{"/movies/Collection/Ronin (1998)/Ronin (1998).mkv", "", "", false},
|
2026-07-06 18:52:44 -04:00
|
|
|
// Ancestor lookup is depth-bounded: a library living under a dir
|
|
|
|
|
// named "Extras" must not classify everything.
|
|
|
|
|
{"/data/Extras/Movies/Heat (1995)/Heat (1995).mkv", "", "", false},
|
2026-07-08 23:18:07 +08:00
|
|
|
// A content-scope folder carrying a convention label ("other",
|
|
|
|
|
// "shorts", "extras", ...) owns no media of its own, so titles
|
|
|
|
|
// beneath it stay primary and must not be misclassified as extras
|
|
|
|
|
// (regression for the /movies/other re-probe/defer storm) — at the
|
|
|
|
|
// library root or nested any depth below it. "others" is additionally
|
|
|
|
|
// absent from the convention vocabulary entirely.
|
|
|
|
|
{"/movies/other/Heat (1995)/Heat (1995).mkv", "", "", false},
|
|
|
|
|
{"/movies/others/Heat (1995)/Heat (1995).mkv", "", "", false},
|
|
|
|
|
{"/movies/shorts/Heat (1995)/Heat (1995).mkv", "", "", false},
|
|
|
|
|
{"/movies/4K/other/Alien (1979)/Alien (1979).mkv", "", "", false},
|
|
|
|
|
// Chained convention names at library scope hold no title either:
|
|
|
|
|
// loose clips there stay primary instead of deferring forever.
|
|
|
|
|
{"/movies/extras/behind the scenes/clip.mkv", "", "", false},
|
|
|
|
|
// Loose files directly under a scope-level convention dir are primary
|
|
|
|
|
// too — unless the filename itself carries a convention suffix.
|
|
|
|
|
{"/movies/other/stray file.mkv", "", "", false},
|
2026-07-06 18:52:44 -04:00
|
|
|
}
|
2026-07-08 23:18:07 +08:00
|
|
|
paths := make([]string, 0, len(cases))
|
2026-07-06 18:52:44 -04:00
|
|
|
for _, tc := range cases {
|
2026-07-08 23:18:07 +08:00
|
|
|
paths = append(paths, tc.path)
|
|
|
|
|
}
|
|
|
|
|
classifier := newExtrasClassifier("movies", []string{"/movies"}, paths)
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
candidate, ok := classifier.classify(tc.path)
|
2026-07-06 18:52:44 -04:00
|
|
|
if ok != tc.wantOK {
|
2026-07-08 23:18:07 +08:00
|
|
|
t.Errorf("classify(%q) ok = %v, want %v", tc.path, ok, tc.wantOK)
|
2026-07-06 18:52:44 -04:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if candidate.Kind != tc.wantKind || candidate.SupplementalDir != tc.wantDir {
|
2026-07-08 23:18:07 +08:00
|
|
|
t.Errorf("classify(%q) = (%q, %q), want (%q, %q)",
|
2026-07-06 18:52:44 -04:00
|
|
|
tc.path, candidate.Kind, candidate.SupplementalDir, tc.wantKind, tc.wantDir)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 23:18:07 +08:00
|
|
|
func TestClassifyExtraPathSeriesLibrary(t *testing.T) {
|
|
|
|
|
paths := []string{
|
|
|
|
|
"/tv/Show/Season 01/Show S01E01.mkv",
|
|
|
|
|
"/tv/Show/Extras/Show S00E01 Special.mkv",
|
|
|
|
|
"/tv/Show/Trailers/season-preview.mkv",
|
|
|
|
|
"/tv/other/Flat Show/pilot.mkv",
|
|
|
|
|
}
|
|
|
|
|
classifier := newExtrasClassifier("series", []string{"/tv"}, paths)
|
|
|
|
|
|
2026-07-06 18:52:44 -04:00
|
|
|
// Documented behavior: an episode-tokened file under Extras/ in a series
|
|
|
|
|
// library maps to season 0, so it must NOT classify as an extra.
|
2026-07-08 23:18:07 +08:00
|
|
|
if _, ok := classifier.classify("/tv/Show/Extras/Show S00E01 Special.mkv"); ok {
|
2026-07-06 18:52:44 -04:00
|
|
|
t.Fatal("SxxExx file under Extras/ must remain a season-0 episode, not an extra")
|
|
|
|
|
}
|
2026-07-08 23:18:07 +08:00
|
|
|
// A non-tokened file under a show-level supplemental dir IS an extra;
|
|
|
|
|
// the show folder owns it through its season-level episodes.
|
|
|
|
|
candidate, ok := classifier.classify("/tv/Show/Trailers/season-preview.mkv")
|
2026-07-06 18:52:44 -04:00
|
|
|
if !ok || candidate.Kind != models.ExtraKindTrailer {
|
2026-07-08 23:18:07 +08:00
|
|
|
t.Fatalf("show trailer dir should classify, got ok=%v kind=%q", ok, candidate.Kind)
|
|
|
|
|
}
|
|
|
|
|
// A scope folder named "other" holding show folders stays primary.
|
|
|
|
|
if _, ok := classifier.classify("/tv/other/Flat Show/pilot.mkv"); ok {
|
|
|
|
|
t.Fatal("show under a scope-level other/ must remain primary")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClassifyExtraPathWatchMode(t *testing.T) {
|
|
|
|
|
// Watch-event scans have no walked path list; title ownership is probed
|
|
|
|
|
// from the filesystem.
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
title := filepath.Join(root, "Heat (1995)")
|
|
|
|
|
other := filepath.Join(title, "Other")
|
|
|
|
|
scopeOther := filepath.Join(root, "other", "Alien (1979)")
|
|
|
|
|
for _, dir := range []string{other, scopeOther} {
|
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, file := range []string{
|
|
|
|
|
filepath.Join(title, "Heat (1995).mkv"),
|
|
|
|
|
filepath.Join(other, "making-of.mkv"),
|
|
|
|
|
filepath.Join(scopeOther, "Alien (1979).mkv"),
|
|
|
|
|
} {
|
|
|
|
|
if err := os.WriteFile(file, nil, 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
classifier := newWatchExtrasClassifier("movies", []string{root})
|
|
|
|
|
candidate, ok := classifier.classify(filepath.Join(other, "making-of.mkv"))
|
|
|
|
|
if !ok || candidate.Kind != models.ExtraKindOther {
|
|
|
|
|
t.Fatalf("convention dir beside the movie file should classify, got ok=%v kind=%q", ok, candidate.Kind)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := classifier.classify(filepath.Join(scopeOther, "Alien (1979).mkv")); ok {
|
|
|
|
|
t.Fatal("title under a scope-level other/ must remain primary in watch mode")
|
2026-07-06 18:52:44 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPartitionExtraPaths(t *testing.T) {
|
|
|
|
|
paths := []string{
|
|
|
|
|
"/movies/Heat (1995)/Heat (1995).mkv",
|
|
|
|
|
"/movies/Heat (1995)/Trailers/tease.mkv",
|
|
|
|
|
"/movies/Heat (1995)/Heat (1995)-featurette.mkv",
|
|
|
|
|
}
|
2026-07-08 23:18:07 +08:00
|
|
|
primary, extras := partitionExtraPaths(paths, "movies", []string{"/movies"})
|
2026-07-06 18:52:44 -04:00
|
|
|
if len(primary) != 1 || primary[0] != paths[0] {
|
|
|
|
|
t.Fatalf("primary = %v, want just the main feature", primary)
|
|
|
|
|
}
|
|
|
|
|
if len(extras) != 2 {
|
|
|
|
|
t.Fatalf("extras = %d entries, want 2", len(extras))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMovieSupplementalDirsNoLongerSkipExtras(t *testing.T) {
|
|
|
|
|
// The walk must still hard-skip noise dirs...
|
|
|
|
|
for _, dir := range []string{"/m/Movie/Sample", "/m/Movie/Subs"} {
|
|
|
|
|
if !shouldSkipMovieSupplementalDir(dir) {
|
|
|
|
|
t.Errorf("expected %q to remain skipped", dir)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// ...but extras-shaped dirs are walked now (classified downstream).
|
|
|
|
|
for _, dir := range []string{"/m/Movie/Trailers", "/m/Movie/Extras", "/m/Movie/Behind The Scenes"} {
|
|
|
|
|
if shouldSkipMovieSupplementalDir(dir) {
|
|
|
|
|
t.Errorf("expected %q to be walked for extras classification", dir)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|