├── PlexCollections.py ├── PlexMovieswithCollections.py ├── PlexXMLPull.py ├── JellyfinNFOCreator.py └── README.md /PlexCollections.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | 3 | # Parse the XML file 4 | tree = ET.parse('metadata.xml') 5 | root = tree.getroot() 6 | 7 | # Initialize a set to store unique collection names 8 | collections = set() 9 | 10 | # Iterate over all 'Collection' elements 11 | for collection in root.findall(".//Collection"): 12 | # Get the 'tag' attribute (which contains the collection name) 13 | collection_name = collection.get('tag') 14 | if collection_name: 15 | collections.add(collection_name) 16 | 17 | # Print all collection names 18 | for collection in collections: 19 | print(collection) 20 | -------------------------------------------------------------------------------- /PlexMovieswithCollections.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | from collections import defaultdict 3 | 4 | # Parse the XML file 5 | tree = ET.parse('metadata.xml') 6 | root = tree.getroot() 7 | 8 | # Initialize a dictionary to group movies by collection name 9 | collections_grouped = defaultdict(list) 10 | 11 | # Iterate over all 'Video' elements (representing movies) 12 | for video in root.findall(".//Video"): 13 | # Get the movie title from the 'title' attribute 14 | movie_title = video.get('title') 15 | 16 | if movie_title: 17 | # Check if the movie has a 'Collection' tag (looking for the 'tag' attribute) 18 | collection_elements = video.findall(".//Collection") 19 | 20 | # For each collection, get the 'tag' attribute (collection name) 21 | for collection in collection_elements: 22 | collection_name = collection.get('tag') 23 | if collection_name: 24 | # Add the movie title to the list of movies for this collection 25 | collections_grouped[collection_name].append(movie_title) 26 | 27 | # Print movies grouped by collection name 28 | if collections_grouped: 29 | print("\nMovies grouped by Collection:") 30 | for collection_name, movies in collections_grouped.items(): 31 | print(f"\nCollection: {collection_name}") 32 | for movie in movies: 33 | print(f" {movie}") 34 | else: 35 | print("No movies with a collection tag found.") 36 | -------------------------------------------------------------------------------- /PlexXMLPull.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | from datetime import datetime, timezone 3 | 4 | # Parse the XML file 5 | tree = ET.parse('metadata.xml') 6 | root = tree.getroot() 7 | 8 | # Track if we're on the first movie to control spacing 9 | first = True 10 | 11 | # Iterate over all 'Video' elements (representing movies) 12 | for video in root.findall(".//Video"): 13 | # Extract movie details from attributes 14 | movie_title = video.get('title') 15 | title_sort = video.get('titleSort') 16 | original_title = video.get('originalTitle') 17 | added_at = video.get('addedAt') 18 | view_count = video.get('viewCount') 19 | last_viewed_at = video.get('lastViewedAt') 20 | 21 | # Format addedAt date 22 | if added_at: 23 | added_at_date = datetime.fromtimestamp(int(added_at), timezone.utc).strftime('%Y-%m-%d %H:%M:%S') 24 | else: 25 | added_at_date = None 26 | 27 | # Format lastViewedAt date 28 | if last_viewed_at: 29 | last_viewed_date = datetime.fromtimestamp(int(last_viewed_at), timezone.utc).strftime('%Y-%m-%d %H:%M:%S') 30 | else: 31 | last_viewed_date = None 32 | 33 | # Get the file path for the movie from the 'Part' element (if present) 34 | part_element = video.find(".//Part") 35 | file_path = part_element.get('file') if part_element is not None else "No file path" 36 | 37 | # Extract the collection tags (if present) 38 | collection_elements = video.findall(".//Collection") 39 | collection_tags = [collection.get('tag') for collection in collection_elements] 40 | collections = ", ".join(collection_tags) if collection_tags else None 41 | 42 | # Add blank line before movie entry, except for the first one 43 | if not first: 44 | print() 45 | first = False 46 | 47 | # Print extracted movie information 48 | print(f"Movie Title: {movie_title}") 49 | 50 | if title_sort: 51 | print(f" TitleSort: {title_sort}") 52 | if original_title: 53 | print(f" OriginalTitle: {original_title}") 54 | if added_at_date: 55 | print(f" AddedAt: {added_at_date}") 56 | if last_viewed_date: 57 | print(f" LastViewedAt: {last_viewed_date}") 58 | if view_count: 59 | print(f" ViewCount: {view_count}") 60 | if collections: 61 | print(f" Collections: {collections}") 62 | 63 | print(f" File Path: {file_path}") 64 | -------------------------------------------------------------------------------- /JellyfinNFOCreator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from pathlib import Path 4 | from xml.etree.ElementTree import Element, SubElement, ElementTree 5 | 6 | INPUT_FILE = 'PlexXMLPull.txt' # your .txt file 7 | 8 | def parse_movies(file_path): 9 | with open(file_path, 'r', encoding='utf-8', errors='replace') as f: 10 | content = f.read() 11 | 12 | movie_blocks = re.split(r'\n(?=Movie Title:)', content.strip()) 13 | movies = [] 14 | 15 | for block in movie_blocks: 16 | lines = block.strip().split('\n') 17 | data = {'Collections': []} 18 | for line in lines: 19 | if line.startswith('Movie Title:'): 20 | data['title'] = line.split(':', 1)[1].strip() 21 | elif ':' in line: 22 | key, value = line.strip().split(':', 1) 23 | key = key.strip() 24 | value = value.strip() 25 | if key == 'Collections': 26 | data['Collections'].append(value) 27 | else: 28 | data[key] = value 29 | movies.append(data) 30 | 31 | return movies 32 | 33 | 34 | def create_nfo(movie): 35 | root = Element('movie') 36 | 37 | SubElement(root, 'title').text = movie.get('title', '') 38 | if 'OriginalTitle' in movie: 39 | SubElement(root, 'originaltitle').text = movie['OriginalTitle'] 40 | if 'TitleSort' in movie: 41 | SubElement(root, 'sorttitle').text = movie['TitleSort'] 42 | if 'AddedAt' in movie: 43 | SubElement(root, 'dateadded').text = movie['AddedAt'] 44 | if 'LastViewedAt' in movie: 45 | SubElement(root, 'lastplayed').text = movie['LastViewedAt'] 46 | if 'ViewCount' in movie: 47 | SubElement(root, 'playcount').text = movie['ViewCount'] 48 | 49 | if movie.get('Collections'): 50 | for collection in movie['Collections']: 51 | set_element = SubElement(root, 'set') 52 | SubElement(set_element, 'name').text = collection # Properly using inside 53 | 54 | return root 55 | 56 | 57 | def write_nfos(movies): 58 | for movie in movies: 59 | file_path = movie.get('File Path') 60 | if not file_path: 61 | print(f"Skipping {movie.get('title')} - no file path.") 62 | continue 63 | 64 | nfo_path = Path(file_path).with_suffix('.nfo') 65 | nfo_dir = nfo_path.parent 66 | 67 | if not nfo_dir.exists(): 68 | print(f"Directory does not exist: {nfo_dir}") 69 | continue 70 | 71 | xml_root = create_nfo(movie) 72 | tree = ElementTree(xml_root) 73 | tree.write(nfo_path, encoding='utf-8', xml_declaration=True) 74 | print(f"Wrote NFO: {nfo_path}") 75 | 76 | 77 | if __name__ == '__main__': 78 | movies = parse_movies(INPUT_FILE) 79 | write_nfos(movies) 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plex XML To Jellyfin .NFO Files 2 | 3 | I, probably like you reading this, wanted to convert my Plex library to Jellyfin without losing all of my years or hard work setting up Titles, Sort Titles, Original Titles, Added At Dates, Last Viewed At Dates, View Counts, and Collections. 4 | 5 | This collection of scripts will help you do just that as easily as possible. 6 | 7 | Please note that I am by no means a developer, just an idiot on the internet with access to ChatGPT and a little know how. I tried to segment this as much as possible to try and prevent issues but use at your own risk. Someone smarter could probably do all of this in one script but I liked breaking it up into segments. 8 | 9 | It probably would have been faster for me to just do this manually instead of try and script it but I knew others like me probably wanted this too. I couldn't find anything simple that would do what I wanted. I hope this works for you. 10 | 11 | # Steps 12 | ## 1. Get your Plex XML Data 13 | First create a folder that we will be using for now on. I used C:\temp 14 | 15 | We need to know the library IDs for all libraries you'd like to export. Open CMD and run the following. Be sure to replace with your IP and Plex Token. 16 | 17 | `cd c:\temp` 18 | 19 | `curl -o libraries.xml "http://YOURPLEXIPANDPORT/library/sections?X-Plex-Token=YOURPLEXTOKEN` 20 | 21 | Now you can open up the libraries.xml file and see the libraries and their numbers, if you only have one movie library its typically ID 1. Look for a snippet like below. 22 | 23 | `key="1"` 24 | 25 | Next we can run the following command in CMD to pull all metadata for all items in the selected library by ID. Be sure to replace with your IP and Plex Token also replace the number after sections/ with the library ID you'd like to pull metadata for. 26 | 27 | `curl -o metadata.xml "http://YOURPLEXIPANDPORT/library/sections/1/all?X-Plex-Token=YOURPLEXTOKEN` 28 | 29 | You will now have a file named metadata.xml in your C:\temp folder. You can review this now to make sure everything looks good. 30 | 31 | 32 | ## Optional: Convert XML to List of Collections 33 | This was the original goal before I decided to take this a step further. This will simply pull a list of collections from the Plex metadata.xml file and export it as a .txt. You will need python installed on your PC. 34 | 35 | Save the PlexCollections.py script to your C:\temp and run the following in CMD. 36 | 37 | `py PlexCollections.py > PlexCollections.txt` 38 | 39 | From here you could use this list to manually create the collections in Jellyfin. 40 | 41 | 42 | ## Optional: Convert XML to List of Collections with Movies that are in each Collection. 43 | Again, this was another in between step that seems like it could be useful for someone. This will pull a list of collections and the movies that belong to each collection from the Plex metadata.xml file and export it as a .txt. You will need python installed on your PC. 44 | 45 | Save the PlexMovieswithCollections.py script to your C:\temp and run the following in CMD. 46 | 47 | `py PlexMovieswithCollections.py > PlexMovieswithCollections.txt` 48 | 49 | Again you could use this list to manually create the collections in Jellyfin. 50 | 51 | 52 | ## 2. Convert XML to List of Movies with Relevant Data 53 | This script will take the metadata.xml and export a .txt list of movies with their Title, Sort Title, Original Title, Added At Date, Last Viewed At Date, View Count, Collections and File Path as applicable. Again this step is a bit repetitive as you could likely go straight from the xml to .NFO files but I liked to use this as a check to make sure everything looks good. I would also recommend doing a trial run by editing this file and having only one movie in the list to test the next step. This does not work for TV Shows. 54 | 55 | Save the PlexXMLPull.py script to your C:\temp and run the following in CMD. 56 | 57 | `py PlexXMLPull.py > PlexXMLPull.txt` 58 | 59 | You should now have a file named PlexXMLPull.txt with a list of all your movies and their data. 60 | 61 | 62 | ## 3. Convert Movie List and Data to .NFO Files and Store them in their Respective Folders. 63 | This script will take the PlexXMLPull.txt file and convert the data to individual .NFO files and store them in the Path listed in the PlexXMLPull.txt. I would recommend editing your PlexXMLPull.txt to only have one movie to test before running against all movies. Also depending on where your movies are stored and how your are running Plex/Jellyfin you may need to edit the file paths. For me I run Plex/Jellyfin in docker and they see the movie path as /data/movies but in reality on my Windows machine this is Z:/Media/Movies I had to do a find and replace on /data/movies to replace it to Z:/Media/Movies I didn't want to hardcode this as everyone's path is different. 64 | 65 | Note: you may get a few "Directory does not exist" errors for folder names with special characters like Alien3 or Joker: Folie à Deux. Check the output in CMD, I just fixed these manually myself within Jellyfin. 66 | 67 | Save the JellyfinNFOCreator.py script to your C:\temp and run the following in CMD 68 | 69 | `py JellyfinNFOCreator.py` 70 | 71 | You should now have a MOVIENAME.NFO file in every folder directory. In Jellyfin, if you scan your libraries and select replace all metadata it should use the .NFO files and your movies should be just as they were in Plex. I will admit that Collections didn't work great for me and I still had to do a decent amount of manual cleanup. I believe you also still need to have the TMDb Box Sets Plugin installed to use any collections in Jellyfin, or at least get any metadata for them. 72 | 73 | After this initial bulk "upload" I would suggest going into settings and selecting Manage Library for each Library and under Metadata Savers section check Nfo. Now scan library and replace all metadata. This will create a new .nfo file called movie.nfo for each Movie. Now from this point on any changes you make Jellyfin will keep those new movie.nfo files updated. 74 | 75 | 76 | ## 4. Repeat For All Other Needed Libraries 77 | You will need to go back to the beginning and use the library ID for the other libraries you want. Again, this does not work with TV Shows. You will then get a new metadata.xml file and run your python scripts again. 78 | 79 | # Troubleshooting 80 | I had some issues with accessing a network drive to store the files in. Running the below command in CMD helped figure out which drives were accessible or not and troubleshooting accordingly. I also found using regular CMD and not running it as ADMIN was best. YMMV 81 | 82 | `net use` 83 | 84 | 85 | # Final Note 86 | I do not plan on maintaining this and updating this with every change to Plex or Jellyfin, if someone else proposes changes I will try and add them. 87 | --------------------------------------------------------------------------------