Files
silo-server/docs/wiki/admin/nfo-local-metadata.md
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

178 lines
8.1 KiB
Markdown

---
title: Local NFO Metadata
description: How Silo reads Kodi/Jellyfin-style NFO sidecar files and merges them with online metadata providers.
summary: Supported NFO fields, merge and refresh semantics, and the naming-supplies-structure contract for curated libraries.
tags:
- silo
- docs
- wiki
- libraries
- metadata
- nfo
audience:
- operator
- end-user
last_reviewed: 2026-07-12
related:
- ../index.md
- media-folder-and-naming.md
---
# Local NFO Metadata
Silo ships a built-in **NFO Files** metadata provider that reads Kodi/Jellyfin-style `.nfo`
sidecar files. It runs inside the same provider chain as online providers (TMDB, TVDB, ...),
so one toggle and one priority per library control it. By default it sits at priority 1: the
NFO wins every field it declares, and online providers fill in whatever it leaves out.
Clients can detect whether a library type includes the NFO provider via
`GET /api/v1/libraries/provider-defaults`.
## Where Silo looks for NFO files
For each movie or series, Silo checks (in order):
1. `movie.nfo` / `tvshow.nfo` in the item's folder
2. `<media basename>.nfo` next to the media file (e.g. `My Movie (2021).nfo`)
For series, Silo additionally reads:
- `season.nfo` inside each season directory (`<season>` root: title, plot)
- `<episode basename>.nfo` next to each episode file (`<episodedetails>` root: title,
plot, air date, runtime, ratings)
- Season posters: `poster.jpg` (or `folder`/`cover`) inside the season directory, or
`seasonNN-poster.jpg` in the series root
- Episode thumbnails: `<episode basename>-thumb.jpg`
A sidecar only applies when its root element matches the item type: a stray `movie.nfo` in
a series folder is skipped in favor of `tvshow.nfo`, and a `tvshow.nfo` next to a movie
file is ignored. Files that fail to parse are skipped silently and the next candidate is
tried — a broken NFO never blocks matching.
Season and episode numbers declared inside an NFO (`<seasonnumber>`, `<season>`,
`<episode>`) are advisory: the directory name and the `SxxEyy` filename pattern decide
where metadata lands, and a mismatch is logged and resolved in the filename's favor.
Multi-episode NFO documents (several `<episodedetails>` blocks in one file) are not
supported; the first block is used and a warning is logged.
## Supported fields
| NFO element | Applies to | Silo field |
|---|---|---|
| `<title>` | movie, series | Title |
| `<originaltitle>` | movie, series | Original title |
| `<tagline>` | movie, series | Tagline |
| `<plot>` | movie, series | Overview |
| `<year>` | movie, series | Year (derived from the premiere date when omitted) |
| `<runtime>` | movie, series | Runtime in minutes (non-numeric values ignored) |
| `<premiered>` / `<releasedate>` | movie | Release date (`YYYY-MM-DD`) |
| `<premiered>` / `<aired>` | series | First air date (`YYYY-MM-DD`) |
| `<mpaa>` | movie, series | Content rating |
| `<genre>` (repeated) | movie, series | Genres |
| `<studio>` (repeated) | movie, series | Studios |
| `<country>` (repeated) | movie, series | Countries |
| `<tag>` (repeated) | movie, series | Keywords |
| `<ratings>` / `<rating>` | movie, series | Ratings (named sources: `imdb`, `tmdb`/`themoviedb`, `tomatometerallcritics`, `tomatometerallaudience`; a legacy bare `<rating>` fills the IMDB slot) |
| `<actor>` (`name`/`role`/`order`) | movie, series | Cast (actor `<thumb>` URLs are ignored) |
| `<director>`, `<credits>` | movie, series | Crew (directors and writers) |
| `<uniqueid type="tmdb|imdb|tvdb">` | movie, series | Trusted identity anchor for matching |
| `<title>`, `<plot>` (`season.nfo`) | season | Season name and overview |
| `<title>`, `<plot>`, `<aired>`, `<runtime>`, `<ratings>` (`<episodedetails>`) | episode | Episode title, overview, air date, runtime, ratings |
Not yet supported: `<set>` (collections) — a planned follow-up. `<userrating>` is read
but not stored (Silo has no per-user rating field). Unknown elements are ignored, so
exports from Kodi, Jellyfin, or tinyMediaManager work as-is.
## How NFO data merges with online providers
- **NFO wins what it declares.** With the NFO provider at priority 1, every field the NFO
populates beats the same field from online providers.
- **Online providers backfill the rest.** Anything the NFO leaves out (cast, artwork,
runtime, ratings, ...) is filled by the next providers in the chain, keyed by the
`<uniqueid>` values when present.
- **Genres are all-or-nothing.** The first provider that supplies any genres supplies the
whole list; later providers do not append to it. An NFO with no `<genre>` entries leaves
genres entirely to online providers.
- **Field locks are respected.** Fields locked in the Edit Metadata dialog (including
Year and release/air dates) are never overwritten by any provider, NFO included.
## Editing an NFO after the initial match
Scheduled background refreshes only fill empty fields — they intentionally do **not**
re-apply NFO changes over existing values. To pick up edits:
**Edit the NFO, then use Refresh metadata on the item.** A manual refresh re-reads the
sidecar and replaces all unlocked fields with the merged provider results, NFO first.
## Naming supplies structure, NFO supplies presentation
NFO files describe *what an item is called and about* — they do not change *how files are
grouped*. Folder layout and filenames still decide what is a series, a season, and an
episode (see [Supported Media Folder Structures and Naming](media-folder-and-naming.md)).
This makes fully local libraries with no online match work well, for example a fitness
library:
```
Fitness/
P90X/
tvshow.nfo # show title/plot (no <uniqueid> needed)
poster.jpg fanart.jpg
Season 01/
season.nfo # "Course A: Classic"
poster.jpg # season poster
P90X S01E01 - Chest and Back.mkv
P90X S01E01 - Chest and Back.nfo # episode title/plot
P90X S01E01 - Chest and Back-thumb.jpg # episode thumbnail
```
The folder and episode naming build the series/season/episode tree; the NFO sidecars
supply every name, description, and image — even when no online provider knows the
content. Episodes without an `.nfo` still appear with their generic "Episode N" title,
so partially curated libraries degrade gracefully.
## Mixed libraries (sports example)
A single library of type **Mixed** can hold movie-shaped and show-shaped content side by
side — the common case is sports, where pay-per-view events are movies and the weekly
show is a series, and most of it has no TMDB/TVDB presence:
```
WWE/
WrestleMania 41 (2025)/
WrestleMania 41 (2025).mkv
movie.nfo # event title/plot (a <uniqueid> is optional)
poster.jpg
WWE SmackDown/
tvshow.nfo
Season 27/
season.nfo
poster.jpg
WWE SmackDown S27E15.mkv
WWE SmackDown S27E15.nfo
WWE SmackDown S27E15-thumb.jpg
```
How classification works in a mixed library:
- **Naming decides the type, per file, before any metadata provider runs.** An `SxxEyy`
pattern or a `Season NN` folder routes a file to the series lane; everything else is a
movie. An NFO never flips the type: a `tvshow.nfo` sitting next to a movie-classified
file is ignored (the root element must match the item type), not applied.
- **NFOs supply metadata and identity only.** Once the type is fixed, the NFO provider
behaves exactly as in dedicated movie/series libraries — including season and episode
depth for the show-shaped content. An event whose `movie.nfo` carries a
`<uniqueid type="tmdb">` still gets full online enrichment, while ID-less events next
to it fall back to their local NFO and artwork.
- **Misclassified group?** Override its **Type** on the root in the library admin
(Auto → Movie/Series) and rescan — that is the correction path; fixing the NFO alone
will not re-route a file.
Tools that generate sports libraries (sportarr-style managers) should therefore write
standard `SxxEyy` naming for show content and standalone movie naming (`Title (Year)/`)
for events, and place NFOs/posters exactly as in the dedicated-library layouts above.
## Source References
- `internal/metadata/nfo/` — parser and provider
- `internal/metadata/merge.go` — merge semantics and field locks