* feat(metadata): register builtin NFO provider and broaden parsing Phases A and B of the #216 local-NFO work, implemented test-first. Registration & hint-first identity (Phase A): - Migration seeds a reserved kind='builtin' silo.builtin installation and an 'nfo' metadata capability (default_enabled=false, priority 1 for movie/series) with a partial unique index and documented Down. - In-process builtin provider registry (internal/metadata/builtin.go); buildProviders returns the registered provider for builtin rows. - Guard rails keep the reserved row out of every plugin surface (user plugin-settings, installations list, image resolvers, preload, auto-update, store Delete, mutation handlers -> 409); silo.builtin is a reserved manifest id. - Startup sync materializes legacy content_level='' chains per level, then appends builtin capabilities disabled via AppendProviderToAllChains (idempotent); resolveEnabledProvidersBy priority now respects default_enabled=false. - NFO uniqueids seed the trusted-hint machinery via IdentityHintProvider with per-mode conflict policy (stored IDs win on scheduled refresh, NFO wins on manual refresh, Identify skips NFO); ID-less candidates are excluded from provider-priority tie-breaks and nfo never counts as corroboration. - Web chain-editor empty-state gate is now server-derived so builtin providers are reachable on plugin-less servers. Parser breadth & sidecar hardening (Phase B): - Parser covers the practical Kodi/Jellyfin field set for <movie> and <tvshow>: original title, tagline, runtime, dates, content rating, genres/studios/countries/tags, multi-source ratings with scale normalization, cast with roles/order, director/credits. Empty collections stay nil so merge early-returns apply. - findNFO parses candidates and falls through on read/parse failure or root-type mismatch, so a stray movie.nfo cannot shadow tvshow.nfo; GetMetadata gains the same ContentType guard Search has. - New FieldReleaseDates lock gates Year/ReleaseDate/First+LastAirDate in merge (Go) and the edit-metadata dialog (web), closing the gap where a manual refresh re-applied NFO dates over admin corrections. - Merge-contract tests pin NFO fill semantics, genres whole-list first-provider-wins, and NFO edits propagating on manual refresh only. - Docs: new admin wiki page (supported fields, merge semantics, naming-supplies-structure contract), index bullet, sidecar wording revision, v1-scope feature-detection note. Zero behavior change while the provider is disabled (default); pinned by CI-mode and DB-gated test suites. Part of #216 AI-use disclosure: implemented with Claude Code (Fable 5) via spec-driven TDD and agent-assisted implementation. * feat(metadata): ingest local sidecar artwork and read series-depth NFO Phases C and D of the #216 local-NFO work, implemented test-first, plus the mixed-library use-case pins. Together these deliver the headline case: a series absent from every remote database (e.g. a fitness library) scans into a fully presented show -> named seasons -> titled episodes tree from NFO files and sidecar art alone. Local sidecar artwork through the S3 image cache (Phase C): - The NFO provider implements ImageProvider: poster/backdrop/logo sidecar discovery with a fixed precedence map, symlink/non-regular rejection, an 8 MiB cap, and file:// source URLs at rating 0. Generic filenames apply only via the sidecar search paths, so a shared folder.jpg in a flat multi-movie directory applies to none. - file:// becomes a live local source scheme: routed into *_source_path (never *_path), accepted by every image enqueue gate, attributed as provider "local", excluded from cached-path detection. - The image-cache processor caches local files with lexical-on-logical confinement to the library roots, open-handle reads with re-checks, the same variant widths as remote art, and stable (7-day) failure classification. Keys land under local/{contentType}/{contentID}/{hash8}/{imageType}; superseded prefixes are cleaned on re-cache and item deletion. - applyIfBetter gains a local exemption so rating-0 local art can fill matched items without being stickily displaced; ImageRequest carries additive sidecar path context. Series depth (Phase D): - SeasonsRequest/EpisodesRequest carry additive local path context (series roots, per-season directories, per-episode file paths), derived from naming at match time and reconstructed on refresh. - season.nfo supplies season name/plot; NFO season numbers are advisory (directory-derived number wins with a Warn - naming owns structure). <episodedetails> gains aired/runtime/ratings; <basename>.nfo titles episodes and <basename>-thumb.ext supplies thumbs; filename SxxEyy wins over NFO numbers. - Episode NFOs work without a season.nfo (provider seasons unioned with on-disk seasons); SynthesizeFallbackEpisodes always runs after persist so NFO-less episodes keep synthesized rows. Season/episode file:// art rides the Phase C pipeline unchanged. - Migration adds season:1/episode:1 to the builtin NFO capability's default_priority (still default_enabled=false). Mixed sports-library use case (tests only, no product change): - Pins the classification contract for one library holding movie-shaped and show-shaped content (WWE PPV events as movies next to a "WWE SmackDown" show, NASCAR/F1/FIFA with partial TVDB/TMDB data): naming decides movie-vs-series per file before any provider runs; the NFO supplies metadata/identity but never flips type (ContentType guard); the per-root Type override is the correction path. - NFO-driven type classification at scan time is recorded as an explicit deferred open question. Part of #216 AI-use disclosure: implemented with Claude Code (Fable 5) via spec-driven TDD and agent-assisted implementation. * docs(metadata): document local NFO metadata architecture Add a single as-built architecture page (docs/architecture/local-nfo-metadata.md) for the #216 local-NFO feature: the builtin registration model, hint-first identity semantics, the file:// -> S3 artwork pipeline and its deployment constraint, series depth, the mixed-library classification contract, and known limitations. This replaces the working implementation plan, the per-phase specs, and the narrow sidecar-artwork note, which were planning drafts and are left untracked; admin-facing behavior remains in the wiki. Part of #216 AI-use disclosure: planned, drafted, and consolidated with Claude Code (Fable 5) using multi-agent exploration and adversarial review. * fix(metadata): address PR review findings on NFO builtin provider Fold in the valid, low-risk fixes surfaced by automated review on #390: - imagecache: extract validateCacheRequest so CacheBytes (the local sidecar season/episode path) enforces the same episode-requires-season guard as Cache, preventing distinct episodes' art from colliding under one S3 key. - image_cache_processor: close the sidecar symlink-swap window by rejecting the opened handle unless os.SameFile matches the Lstat'd file, so a leaf swapped to a symlink can't pull an out-of-root target into the public cache. - plugins: guard the reserved builtin installation row in the store's Update, matching Delete, so its version/enabled/capabilities can never be rewritten even if a mutation slips past the HTTP layer. - cmd/silo: bound SyncBuiltinProviderChains with a 30s timeout so a stuck DB round-trip fails fast at startup instead of hanging. - metadata: panic instead of silently no-op'ing on an invalid RegisterBuiltinProvider call (init-time programmer error). - docs: correct the media-folder-and-naming NFO paragraph to state season/episode NFOs and sidecar artwork are actively read. --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
377 lines
12 KiB
Go
377 lines
12 KiB
Go
package nfo
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/metadata"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parser tables: <season>
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestParseSeasonNFO(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
xml string
|
|
wantTitle string
|
|
wantOverview string
|
|
wantSeason int
|
|
wantSeasonSet bool
|
|
}{
|
|
{
|
|
name: "full season",
|
|
xml: `<season><title>Course A: Classic</title><plot>The classic 90-day schedule.</plot><seasonnumber>1</seasonnumber></season>`,
|
|
wantTitle: "Course A: Classic",
|
|
wantOverview: "The classic 90-day schedule.",
|
|
wantSeason: 1,
|
|
wantSeasonSet: true,
|
|
},
|
|
{
|
|
name: "no seasonnumber",
|
|
xml: `<season><title>Course B</title></season>`,
|
|
wantTitle: "Course B",
|
|
wantSeasonSet: false,
|
|
},
|
|
{
|
|
name: "specials season zero",
|
|
xml: `<season><title>Bonus Workouts</title><seasonnumber>0</seasonnumber></season>`,
|
|
wantTitle: "Bonus Workouts",
|
|
wantSeason: 0,
|
|
wantSeasonSet: true,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
p, err := parseNFOData([]byte(tc.xml))
|
|
if err != nil {
|
|
t.Fatalf("parseNFOData: %v", err)
|
|
}
|
|
if p.Type != "season" {
|
|
t.Errorf("Type = %q, want season", p.Type)
|
|
}
|
|
if p.Title != tc.wantTitle {
|
|
t.Errorf("Title = %q, want %q", p.Title, tc.wantTitle)
|
|
}
|
|
if p.Overview != tc.wantOverview {
|
|
t.Errorf("Overview = %q, want %q", p.Overview, tc.wantOverview)
|
|
}
|
|
if p.SeasonSet != tc.wantSeasonSet {
|
|
t.Errorf("SeasonSet = %v, want %v", p.SeasonSet, tc.wantSeasonSet)
|
|
}
|
|
if tc.wantSeasonSet && p.Season != tc.wantSeason {
|
|
t.Errorf("Season = %d, want %d", p.Season, tc.wantSeason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parser tables: extended <episodedetails>
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestParseEpisodeNFO_ExtendedFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
xml := `<episodedetails>
|
|
<title>Chest and Back</title>
|
|
<plot>Push-ups and pull-ups.</plot>
|
|
<aired>2004-01-01</aired>
|
|
<season>1</season>
|
|
<episode>1</episode>
|
|
<runtime>53</runtime>
|
|
<ratings><rating name="imdb" max="10"><value>8.4</value></rating></ratings>
|
|
</episodedetails>`
|
|
p, err := parseNFOData([]byte(xml))
|
|
if err != nil {
|
|
t.Fatalf("parseNFOData: %v", err)
|
|
}
|
|
if p.Type != "episode" {
|
|
t.Errorf("Type = %q, want episode", p.Type)
|
|
}
|
|
if p.Title != "Chest and Back" || p.Overview != "Push-ups and pull-ups." {
|
|
t.Errorf("title/plot = %q/%q", p.Title, p.Overview)
|
|
}
|
|
if p.FirstAirDate != "2004-01-01" {
|
|
t.Errorf("FirstAirDate = %q, want 2004-01-01", p.FirstAirDate)
|
|
}
|
|
if !p.SeasonSet || p.Season != 1 || !p.EpisodeSet || p.Episode != 1 {
|
|
t.Errorf("numbers = season(%v,%d) episode(%v,%d), want set 1/1", p.SeasonSet, p.Season, p.EpisodeSet, p.Episode)
|
|
}
|
|
if p.Runtime != 53 {
|
|
t.Errorf("Runtime = %d, want 53", p.Runtime)
|
|
}
|
|
if p.RatingIMDB != 8.4 {
|
|
t.Errorf("RatingIMDB = %v, want 8.4", p.RatingIMDB)
|
|
}
|
|
if p.MultiEpisode {
|
|
t.Error("MultiEpisode = true for single-episode document")
|
|
}
|
|
}
|
|
|
|
func TestParseEpisodeNFO_LegacyRatingAndNoNumbers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p, err := parseNFOData([]byte(`<episodedetails><title>Core</title><rating>7.5</rating></episodedetails>`))
|
|
if err != nil {
|
|
t.Fatalf("parseNFOData: %v", err)
|
|
}
|
|
if p.SeasonSet || p.EpisodeSet {
|
|
t.Errorf("SeasonSet/EpisodeSet = %v/%v, want false (numbers absent)", p.SeasonSet, p.EpisodeSet)
|
|
}
|
|
if p.RatingIMDB != 7.5 {
|
|
t.Errorf("legacy RatingIMDB = %v, want 7.5", p.RatingIMDB)
|
|
}
|
|
}
|
|
|
|
// Multi-<episodedetails> documents (multi-episode files) are out of scope in
|
|
// v1: the first block wins and the parser flags the document so the provider
|
|
// can warn.
|
|
func TestParseEpisodeNFO_MultiEpisodeDocumentTakesFirst(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
xml := `<episodedetails><title>First Half</title><episode>1</episode></episodedetails>
|
|
<episodedetails><title>Second Half</title><episode>2</episode></episodedetails>`
|
|
p, err := parseNFOData([]byte(xml))
|
|
if err != nil {
|
|
t.Fatalf("parseNFOData: %v", err)
|
|
}
|
|
if p.Title != "First Half" {
|
|
t.Errorf("Title = %q, want First Half (take first)", p.Title)
|
|
}
|
|
if !p.MultiEpisode {
|
|
t.Error("MultiEpisode = false, want true")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider: GetSeasons
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func writeTestFile(t *testing.T, path, contents string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll(%s): %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%s): %v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestGetSeasons_SeasonNFOAndPoster(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
s1 := filepath.Join(root, "Season 01")
|
|
s2 := filepath.Join(root, "Season 02")
|
|
writeTestFile(t, filepath.Join(s1, "season.nfo"),
|
|
`<season><title>Course A: Classic</title><plot>Classic plan.</plot><seasonnumber>1</seasonnumber></season>`)
|
|
writeTestFile(t, filepath.Join(s1, "poster.jpg"), "img")
|
|
writeTestFile(t, filepath.Join(s2, "season.nfo"), `<season><title>Course B</title></season>`)
|
|
// Season 2 poster lives at the series root using the seasonNN-poster form.
|
|
writeTestFile(t, filepath.Join(root, "season02-poster.jpg"), "img")
|
|
|
|
p := NewProvider()
|
|
seasons, err := p.GetSeasons(context.Background(), metadata.SeasonsRequest{
|
|
ContentType: "series",
|
|
SeriesRootPaths: []string{root},
|
|
SeasonDirectoryPaths: map[int][]string{
|
|
1: {s1},
|
|
2: {s2},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetSeasons: %v", err)
|
|
}
|
|
if len(seasons) != 2 {
|
|
t.Fatalf("len(seasons) = %d, want 2 (%#v)", len(seasons), seasons)
|
|
}
|
|
if seasons[0].SeasonNumber != 1 || seasons[0].Title != "Course A: Classic" || seasons[0].Overview != "Classic plan." {
|
|
t.Errorf("season 1 = %#v", seasons[0])
|
|
}
|
|
if want := "file://" + filepath.Join(s1, "poster.jpg"); seasons[0].PosterPath != want {
|
|
t.Errorf("season 1 poster = %q, want %q", seasons[0].PosterPath, want)
|
|
}
|
|
if seasons[1].SeasonNumber != 2 || seasons[1].Title != "Course B" {
|
|
t.Errorf("season 2 = %#v", seasons[1])
|
|
}
|
|
if want := "file://" + filepath.Join(root, "season02-poster.jpg"); seasons[1].PosterPath != want {
|
|
t.Errorf("season 2 poster = %q, want %q", seasons[1].PosterPath, want)
|
|
}
|
|
}
|
|
|
|
// A mismatched <seasonnumber> is advisory: naming owns structure, so the
|
|
// directory-derived season number wins.
|
|
func TestGetSeasons_NumberConflictPrefersDirectoryNumber(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
s2 := filepath.Join(root, "Season 02")
|
|
writeTestFile(t, filepath.Join(s2, "season.nfo"),
|
|
`<season><title>Course B</title><seasonnumber>7</seasonnumber></season>`)
|
|
|
|
p := NewProvider()
|
|
seasons, err := p.GetSeasons(context.Background(), metadata.SeasonsRequest{
|
|
ContentType: "series",
|
|
SeriesRootPaths: []string{root},
|
|
SeasonDirectoryPaths: map[int][]string{2: {s2}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetSeasons: %v", err)
|
|
}
|
|
if len(seasons) != 1 || seasons[0].SeasonNumber != 2 || seasons[0].Title != "Course B" {
|
|
t.Fatalf("seasons = %#v, want season 2 titled Course B", seasons)
|
|
}
|
|
}
|
|
|
|
func TestGetSeasons_NoSidecarsReturnsNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
s1 := filepath.Join(root, "Season 01")
|
|
if err := os.MkdirAll(s1, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := NewProvider()
|
|
seasons, err := p.GetSeasons(context.Background(), metadata.SeasonsRequest{
|
|
ContentType: "series",
|
|
SeriesRootPaths: []string{root},
|
|
SeasonDirectoryPaths: map[int][]string{1: {s1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetSeasons: %v", err)
|
|
}
|
|
if len(seasons) != 0 {
|
|
t.Fatalf("seasons = %#v, want none", seasons)
|
|
}
|
|
}
|
|
|
|
func TestGetSeasons_NonSeriesContentTypeReturnsNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
s1 := filepath.Join(root, "Season 01")
|
|
writeTestFile(t, filepath.Join(s1, "season.nfo"), `<season><title>Nope</title></season>`)
|
|
p := NewProvider()
|
|
seasons, err := p.GetSeasons(context.Background(), metadata.SeasonsRequest{
|
|
ContentType: "movie",
|
|
SeasonDirectoryPaths: map[int][]string{1: {s1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetSeasons: %v", err)
|
|
}
|
|
if len(seasons) != 0 {
|
|
t.Fatalf("seasons = %#v, want none for non-series content", seasons)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider: GetEpisodes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetEpisodes_EpisodeNFOAndThumb(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
s1 := filepath.Join(root, "Season 01")
|
|
e1 := filepath.Join(s1, "P90X S01E01 - Chest and Back.mkv")
|
|
writeTestFile(t, e1, "video")
|
|
writeTestFile(t, filepath.Join(s1, "P90X S01E01 - Chest and Back.nfo"),
|
|
`<episodedetails><title>Chest and Back</title><plot>Push and pull.</plot><aired>2004-01-01</aired><season>1</season><episode>1</episode><runtime>53</runtime></episodedetails>`)
|
|
writeTestFile(t, filepath.Join(s1, "P90X S01E01 - Chest and Back-thumb.jpg"), "img")
|
|
// Episode 2 has a media file but no NFO and no thumb: the provider must
|
|
// return nothing for it (synthesized fallback stays in charge).
|
|
e2 := filepath.Join(s1, "P90X S01E02 - Plyometrics.mkv")
|
|
writeTestFile(t, e2, "video")
|
|
|
|
p := NewProvider()
|
|
episodes, err := p.GetEpisodes(context.Background(), metadata.EpisodesRequest{
|
|
SeasonNumber: 1,
|
|
SeriesRootPaths: []string{root},
|
|
EpisodeFilePaths: map[int][]string{
|
|
1: {e1},
|
|
2: {e2},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetEpisodes: %v", err)
|
|
}
|
|
if len(episodes) != 1 {
|
|
t.Fatalf("len(episodes) = %d, want 1 (%#v)", len(episodes), episodes)
|
|
}
|
|
ep := episodes[0]
|
|
if ep.SeasonNumber != 1 || ep.EpisodeNumber != 1 {
|
|
t.Errorf("numbers = S%dE%d, want S1E1", ep.SeasonNumber, ep.EpisodeNumber)
|
|
}
|
|
if ep.Title != "Chest and Back" || ep.Overview != "Push and pull." {
|
|
t.Errorf("title/plot = %q/%q", ep.Title, ep.Overview)
|
|
}
|
|
if ep.AirDate != "2004-01-01" {
|
|
t.Errorf("AirDate = %q, want 2004-01-01", ep.AirDate)
|
|
}
|
|
if ep.Runtime != 53 {
|
|
t.Errorf("Runtime = %d, want 53", ep.Runtime)
|
|
}
|
|
if want := "file://" + filepath.Join(s1, "P90X S01E01 - Chest and Back-thumb.jpg"); ep.StillPath != want {
|
|
t.Errorf("StillPath = %q, want %q", ep.StillPath, want)
|
|
}
|
|
}
|
|
|
|
// NFO numbers are advisory: the filename-parsed SxxEyy (the request key) wins
|
|
// on conflict.
|
|
func TestGetEpisodes_NumberConflictFilenameWins(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
s1 := filepath.Join(root, "Season 01")
|
|
e3 := filepath.Join(s1, "Show S01E03.mkv")
|
|
writeTestFile(t, e3, "video")
|
|
writeTestFile(t, filepath.Join(s1, "Show S01E03.nfo"),
|
|
`<episodedetails><title>Third</title><season>4</season><episode>9</episode></episodedetails>`)
|
|
|
|
p := NewProvider()
|
|
episodes, err := p.GetEpisodes(context.Background(), metadata.EpisodesRequest{
|
|
SeasonNumber: 1,
|
|
EpisodeFilePaths: map[int][]string{3: {e3}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetEpisodes: %v", err)
|
|
}
|
|
if len(episodes) != 1 || episodes[0].SeasonNumber != 1 || episodes[0].EpisodeNumber != 3 {
|
|
t.Fatalf("episodes = %#v, want S1E3 from filename", episodes)
|
|
}
|
|
if episodes[0].Title != "Third" {
|
|
t.Errorf("Title = %q, want Third", episodes[0].Title)
|
|
}
|
|
}
|
|
|
|
// A non-episode NFO next to the media file (e.g. a stray movie.nfo-shaped
|
|
// basename sidecar) must not inject data into an episode.
|
|
func TestGetEpisodes_RootTypeMismatchIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
s1 := filepath.Join(root, "Season 01")
|
|
e1 := filepath.Join(s1, "Show S01E01.mkv")
|
|
writeTestFile(t, e1, "video")
|
|
writeTestFile(t, filepath.Join(s1, "Show S01E01.nfo"), `<movie><title>Not an episode</title></movie>`)
|
|
|
|
p := NewProvider()
|
|
episodes, err := p.GetEpisodes(context.Background(), metadata.EpisodesRequest{
|
|
SeasonNumber: 1,
|
|
EpisodeFilePaths: map[int][]string{1: {e1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetEpisodes: %v", err)
|
|
}
|
|
if len(episodes) != 0 {
|
|
t.Fatalf("episodes = %#v, want none for mismatched root type", episodes)
|
|
}
|
|
}
|