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

8.1 KiB

title, description, summary, tags, audience, last_reviewed, related
title description summary tags audience last_reviewed related
Local NFO Metadata How Silo reads Kodi/Jellyfin-style NFO sidecar files and merges them with online metadata providers. Supported NFO fields, merge and refresh semantics, and the naming-supplies-structure contract for curated libraries.
silo
docs
wiki
libraries
metadata
nfo
operator
end-user
2026-07-12
../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">`
<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). 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