Last active
May 14, 2026 21:32
-
-
Save garlandkr/256fa5b691dca1960e8c441c5fc2074f to your computer and use it in GitHub Desktop.
Update Jellyfin with metadata from NFO files.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | |
| """) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Revision 6: Sanitize filenames into lowercase