Skip to content

Instantly share code, notes, and snippets.

@garlandkr
Last active May 14, 2026 21:32
Show Gist options
  • Select an option

  • Save garlandkr/256fa5b691dca1960e8c441c5fc2074f to your computer and use it in GitHub Desktop.

Select an option

Save garlandkr/256fa5b691dca1960e8c441c5fc2074f to your computer and use it in GitHub Desktop.
Update Jellyfin with metadata from NFO files.
import sqlite3
import os
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
import argparse
parser = argparse.ArgumentParser(
description='Fix Jellyfin episode metadata by reading directly from NFO files.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
examples:
fix episodes where the NFO was written in the last 1 day:
sudo python3 fix_episode_names.py --date 1 --datefile nfo
fix episodes where the MKV was added in the last 7 days:
sudo python3 fix_episode_names.py --date 7 --datefile mkv
fix all episodes regardless of date:
sudo python3 fix_episode_names.py --date all
fix all episodes using MKV date:
sudo python3 fix_episode_names.py --date all --datefile mkv
notes:
- stop Jellyfin before running: sudo systemctl stop jellyfin
- start Jellyfin after running: sudo systemctl start jellyfin
- fields updated: title, original title, plot, content rating,
community rating, premiere date, runtime, studios,
season number, episode number
"""
)
parser.add_argument(
'--date',
required=True,
metavar='DAYS|all',
help='age filter: number of days (e.g. 1, 10) or "all" for no filter'
)
parser.add_argument(
'--datefile',
choices=['mkv', 'nfo'],
default='nfo',
metavar='mkv|nfo',
help='which file to use for date comparison: mkv or nfo (default: nfo)'
)
args = parser.parse_args()
if args.date.lower() == 'all':
cutoff = None
print(f"Mode: all episodes")
else:
try:
days = int(args.date)
cutoff = datetime.now() - timedelta(days=days)
print(f"Mode: episodes with {args.datefile.upper()} modified within the last {days} day(s) (since {cutoff.strftime('%Y-%m-%d %H:%M')})")
except ValueError:
print("Error: --date must be a number or 'all'")
exit(1)
db = sqlite3.connect('/var/lib/jellyfin/data/jellyfin.db')
cursor = db.cursor()
cursor.execute("""
SELECT Name, Path FROM BaseItems
WHERE Path LIKE '/mnt/storage/tv/%'
AND Path LIKE '%.mkv'
""")
rows = cursor.fetchall()
fixed = 0
skipped = 0
not_found = 0
filtered = 0
_dir_cache = {}
def find_nfo(mkv_path):
direct = mkv_path[:-4] + '.nfo'
if os.path.exists(direct):
return direct
dirname = os.path.dirname(mkv_path)
target = (os.path.basename(mkv_path)[:-4] + '.nfo').lower()
if dirname not in _dir_cache:
try:
_dir_cache[dirname] = os.listdir(dirname)
except OSError:
_dir_cache[dirname] = []
for entry in _dir_cache[dirname]:
if entry.lower() == target:
return os.path.join(dirname, entry)
return None
for name, path in rows:
nfo_path = find_nfo(path)
if nfo_path is None:
not_found += 1
continue
if cutoff is not None:
check_path = path if args.datefile == 'mkv' else nfo_path
if not os.path.exists(check_path):
filtered += 1
continue
mtime = datetime.fromtimestamp(os.path.getmtime(check_path))
if mtime < cutoff:
filtered += 1
continue
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
title = root.findtext('title')
orig = root.findtext('originaltitle')
plot = root.findtext('plot')
mpaa = root.findtext('mpaa')
premiered = root.findtext('premiered')
runtime = root.findtext('runtime')
season_num = root.findtext('season')
episode_num = root.findtext('episode')
show_title = root.findtext('showtitle') or ''
studios = '|'.join(
s.text.strip() for s in root.findall('studio')
if s.text and s.text.strip()
)
rating_val = root.find('ratings/rating/value')
rating = float(rating_val.text) if rating_val is not None else None
premiere_date = None
if premiered:
try:
premiere_date = datetime.strptime(premiered, '%Y-%m-%d').isoformat()
except ValueError:
pass
runtime_ticks = None
if runtime:
try:
runtime_ticks = int(runtime) * 60 * 10_000_000
except ValueError:
pass
if not title or not title.strip():
print(f"Skipped (no title): {nfo_path}")
skipped += 1
continue
s = int(season_num) if season_num else 0
e = int(episode_num) if episode_num else 0
cursor.execute("""
UPDATE BaseItems SET
Name = ?,
OriginalTitle = ?,
Overview = ?,
OfficialRating = ?,
CommunityRating = ?,
PremiereDate = ?,
RunTimeTicks = ?,
Studios = ?,
IndexNumber = ?,
ParentIndexNumber = ?
WHERE Path = ?
""", (
title.strip(),
orig.strip() if orig else None,
plot.strip() if plot else None,
mpaa.strip() if mpaa else None,
rating,
premiere_date,
runtime_ticks,
studios if studios else None,
e if e else None,
s if s else None,
path
))
if cursor.rowcount > 0:
print(f"Fixed: {show_title} - S{s:02d}E{e:02d} - {title.strip()}")
fixed += 1
else:
print(f"No row matched: {path}")
skipped += 1
except Exception as ex:
print(f"Error parsing {nfo_path}: {ex}")
skipped += 1
# clean up orphaned Season Unknown buckets
cursor.execute("""
SELECT SeriesName, Path FROM BaseItems
WHERE Name = 'Season Unknown'
AND Type = 'MediaBrowser.Controller.Entities.TV.Season'
""")
orphans = cursor.fetchall()
for series_name, orphan_path in orphans:
print(f"Removed orphaned season: {series_name or '(unknown series)'} - {orphan_path or '(no path)'}")
cursor.execute("""
DELETE FROM BaseItems
WHERE Name = 'Season Unknown'
AND Type = 'MediaBrowser.Controller.Entities.TV.Season'
""")
orphans_removed = cursor.rowcount
db.commit()
db.close()
print(f"""
Summary:
Fixed: {fixed}
Skipped: {skipped}
Missing NFO: {not_found}
Filtered by date: {filtered}
Orphaned seasons removed: {orphans_removed}
""")
@garlandkr

garlandkr commented May 14, 2026

Copy link
Copy Markdown
Author

Revision 6: Sanitize filenames into lowercase

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment