NFO files: why your Plex metadata is wrong
If you’ve ever had Plex match a show to the wrong series – or worse, merge two different shows into one entry – you know the frustration. The fix is a small XML file called tvshow.nfo that tells Plex exactly which TVDB ID to use.
The problem
Plex’s automatic matching is pretty good, but it falls apart with shows that share names, reboots, or regional variants. Dragon Ball GT matched to the wrong TVDB ID. Animaniacs (2020) merged with the original. Hunter x Hunter showed both versions as one entry.
The NFO format
A tvshow.nfo file in the show’s root folder overrides Plex’s matching entirely. Use the uniqueid element – not tvdbid. Plex’s newer agent uses uniqueid with the type and default attributes:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tvshow>
<title>Dragon Ball GT</title>
<uniqueid type="tvdb" default="true">79275</uniqueid>
</tvshow>
Using <tvdbid> instead of <uniqueid> will appear to work but may not lock matching reliably with current Plex versions.
Bulk generation
Writing 900+ NFO files by hand wasn’t going to happen. The bulk script pulls every series from Sonarr’s API, reads the API key directly from Sonarr’s config file so there’s nothing to hardcode, and writes the NFO files:
#!/usr/bin/env python3
import os, re, json
from urllib.request import urlopen, Request
SONARR_URL = "http://localhost:8989"
APIKEY = re.search(
r"(?<=<ApiKey>)[^<]+",
open("/mnt/user/appdata/binhex-sonarr/config.xml").read()
).group()
req = Request(f"{SONARR_URL}/api/v3/series", headers={"X-Api-Key": APIKEY})
shows = json.loads(urlopen(req).read())
created = skipped = already_exists = 0
errors = []
for show in sorted(shows, key=lambda x: x["title"]):
tvdb_id = show.get("tvdbId")
title = show.get("title")
path = show.get("path", "").replace("/media", "/mnt/user/data/tv-shows")
if not tvdb_id or not path:
errors.append(f"MISSING DATA: {title}")
continue
nfo_path = os.path.join(path, "tvshow.nfo")
if os.path.exists(nfo_path):
already_exists += 1
continue
if not os.path.isdir(path):
errors.append(f"DIR NOT FOUND: {title} -> {path}")
continue
nfo_lines = [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
"<tvshow>",
f" <title>{title}</title>",
f' <uniqueid type="tvdb" default="true">{tvdb_id}</uniqueid>',
"</tvshow>",
"",
]
try:
open(nfo_path, "w", encoding="utf-8").write("\n".join(nfo_lines))
created += 1
except Exception as e:
errors.append(f"ERROR: {title} -> {e}")
Run this from the host. It skips shows that already have an NFO, so it’s safe to re-run. DIR NOT FOUND errors are expected for shows in Sonarr that haven’t downloaded anything yet – no directory, no NFO. Those get created automatically as content arrives.
Auto-generation on SeriesAdd
The bulk script is a one-time fix. For new shows, a Sonarr Custom Script connection fires a bash script on the SeriesAdd event:
#!/bin/bash
LOGFILE="/mnt/user/appdata/scripts/sonarr_nfo.log"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOGFILE"; }
if [ "$sonarr_eventtype" = "Test" ]; then log "Test event received - OK"; exit 0; fi
if [ "$sonarr_eventtype" != "SeriesAdd" ]; then exit 0; fi
TITLE="$sonarr_series_title"
TVDB_ID="$sonarr_series_tvdbid"
SERIES_PATH="$sonarr_series_path"
NFO_PATH="$SERIES_PATH/tvshow.nfo"
[ -z "$TVDB_ID" ] || [ -z "$SERIES_PATH" ] && {
log "ERROR: Missing data for $TITLE"; exit 1;
}
[ -f "$NFO_PATH" ] && { log "SKIP: $TITLE"; exit 0; }
mkdir -p "$SERIES_PATH"
printf '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<tvshow>\n <title>%s</title>\n <uniqueid type="tvdb" default="true">%s</uniqueid>\n</tvshow>\n' \
"$TITLE" "$TVDB_ID" > "$NFO_PATH" \
&& log "CREATED: $TITLE ($TVDB_ID)" \
|| { log "ERROR: Failed to write NFO for $TITLE"; exit 1; }
The script receives the series path directly from Sonarr via environment variables. No API call needed.
The Docker path gotcha
Here’s the thing that silently broke every auto-generated NFO for months: the script runs inside the Sonarr container, not on the host.
Sonarr passes $sonarr_series_path as the path it sees internally – something like /media/Show Name/. Inside the container, /media is a valid mount point. But if you substitute that path to the host equivalent (/mnt/user/data/tv-shows/Show Name/) before writing, you’re writing to a path that doesn’t exist inside the container. The write fails. The script logs ERROR: Failed to write NFO with no further explanation.
The fix: don’t substitute. Use $sonarr_series_path as-is. From inside the container, Sonarr’s path is already valid.
The bulk script runs on the host, so it does need the host path substitution. The auto-generation script runs inside the container, so it doesn’t. Two scripts, two different path contexts.
Results
935 shows in Sonarr. 910 NFOs on disk after the initial bulk run and backfill. The remaining 26 are shows with no downloaded content yet – they’ll get their NFO when the first episode arrives.