Files
silo-server/internal/metadata/nfo/series_depth_test.go
91e1164090 feat(metadata): local NFO metadata and sidecar artwork (builtin chain provider) (#390)
* 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>
2026-07-16 17:55:36 -04:00

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