Skip to content

Instantly share code, notes, and snippets.

@JonnyWong16
Last active August 29, 2026 22:05
Show Gist options
  • Select an option

  • Save JonnyWong16/9640557cf459896a8b7e1da8863b0485 to your computer and use it in GitHub Desktop.

Select an option

Save JonnyWong16/9640557cf459896a8b7e1da8863b0485 to your computer and use it in GitHub Desktop.
Saves posters, art, logos, square art, and themes from Plex to same folder as the media files.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
'''
Description: Saves posters, art, logos, square art, and themes from Plex to same folder as the media files.
Author: /u/SwiftPanda16
Requires: plexapi, tqdm (optional)
Usage:
* Save posters for an entire library:
python save_resources.py --library "TV Shows" --poster
* Save art for an entire library:
python save_resources.py --library "Music" --art
* Save posters, art, logos, and square art for an entire library:
python save_resources.py --library "Movies" --poster --art --logo --squareArt
* Save posters and art for a specific media type in a library:
python save_resources.py --library "TV Shows" --libtype season --poster --art
* Save themes for an entire library:
python save_resources.py --library "TV Shows" --theme
* Save posters for a specific item:
python save_resources.py --rating_key 1234 --poster
* Save art for a specific item:
python save_resources.py --rating_key 1234 --art
* Save posters, art, logos, and square art for a specific item:
python save_resources.py --rating_key 1234 --poster --art --logo --squareArt
* Overwrite existing images (add --overwrite flag):
python save_resources.py --library "Movies" --poster --overwrite
'''
import argparse
from pathlib import Path
from plexapi.server import PlexServer
from plexapi.utils import download
PLEX_URL = 'http://localhost:32400'
PLEX_TOKEN = 'XXXXXXXXXXXXXXXXXXXX'
# Specify the mapped docker folder paths {host: container}. Leave blank {} if non-docker.
MAPPED_FOLDERS = {
'/mnt/movies': '/movies',
'/mnt/tvshows': '/tv',
}
_MAPPED_FOLDERS = {Path(host): Path(container) for host, container in MAPPED_FOLDERS.items()}
def map_path(file_path):
for host, container in _MAPPED_FOLDERS.items():
if container in file_path.parents:
return host / file_path.relative_to(container)
return file_path
def save_library(
library,
libtype=None,
poster=False,
art=False,
logo=False,
squareArt=False,
theme=False,
overwrite=False
):
for item in library.all(libtype=libtype, includeGuids=False):
save_item(
item,
poster=poster,
art=art,
logo=logo,
squareArt=squareArt,
theme=theme,
overwrite=overwrite
)
def save_item(
item,
poster=False,
art=False,
logo=False,
squareArt=False,
theme=False,
overwrite=False
):
if hasattr(item, 'locations'):
file_path = Path(item.locations[0])
else:
file_path = Path(next(iter(item)).locations[0])
save_path = map_path(file_path)
if save_path.is_file():
save_path = save_path.parent
print(f"{item.title}{f' ({item.year})' if hasattr(item, 'year') else ''}")
if poster:
if item.type == 'season':
filename = f'season{item.seasonNumber:02d}.jpg'
else:
filename = 'poster.jpg'
download_resource('poster', item.posterUrl, save_path, filename, overwrite=overwrite)
if art:
download_resource('art', item.artUrl, save_path, 'art.jpg', overwrite=overwrite)
if logo:
download_resource('logo', item.logoUrl, save_path, 'logo.png', overwrite=overwrite)
if squareArt:
download_resource('squareArt', item.squareArtUrl, save_path, 'squareArt.jpg', overwrite=overwrite)
if theme:
download_resource('theme', item.themeUrl, save_path, 'theme.mp3', overwrite=overwrite)
def download_resource(resource, resource_url, save_path, filename, overwrite=False):
full_path = save_path / filename
if not overwrite and full_path.exists():
print(f" └─ {resource.capitalize()} already exists at {full_path}. Skipping.")
return
if not resource_url:
print(f" └─ No {resource} set. Skipping.")
return
print(f" └─ Downloading {full_path}")
try:
download(
url=resource_url,
token=plex._token,
filename=filename,
savepath=save_path,
showstatus=True # Requires `tqdm` package
)
except Exception as e:
print(f" └─ Failed to download {resource}: {e}")
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--rating_key', type=int)
parser.add_argument('--library')
parser.add_argument('--libtype', choices=['movie', 'show', 'season', 'artist', 'album'])
parser.add_argument('--poster', action='store_true')
parser.add_argument('--art', action='store_true')
parser.add_argument('--logo', action='store_true')
parser.add_argument('--squareArt', action='store_true')
parser.add_argument('--theme', action='store_true')
parser.add_argument('--overwrite', action='store_true')
opts = parser.parse_args()
plex = PlexServer(PLEX_URL, PLEX_TOKEN)
if opts.rating_key:
item = plex.fetchItem(opts.rating_key)
save_item(
item,
poster=opts.poster,
art=opts.art,
logo=opts.logo,
squareArt=opts.squareArt,
theme=opts.theme,
overwrite=opts.overwrite
)
elif opts.library:
library = plex.library.section(opts.library)
save_library(
library,
libtype=opts.libtype,
poster=opts.poster,
art=opts.art,
logo=opts.logo,
squareArt=opts.squareArt,
theme=opts.theme,
overwrite=opts.overwrite
)
else:
print("No --rating_key or --library specified. Exiting.")
@parkatoast

parkatoast commented Apr 12, 2025

Copy link
Copy Markdown

Updated with separate --poster and --art flags. I have not tested it myself.

I've tested it and it runs as it should, no issues and it's great do be able to save both poster and art with the same command. Thank you very much!

Edit:
Only change I've made is that I've changed "filename='background.jpg'," to "filename='art.jpg'," since there is nowhere else in the script it's referred to as "background", only "art".

@guru69

guru69 commented Jul 24, 2025

Copy link
Copy Markdown

Wow this script is awesome. Took several hours on a large library, but worked beautifully. The only feature its missing is the ability to add poster.jpg to newly added shows/movies without redoing the entire library (maybe an --only-missing parameter that if exists=poster.jpg, skip).
I got around the half day wait/ full load by using a bash script to search library for folders not containing poster.jpg >> list, looping that list into a plexapi command to lookup the ratingKey >> list and looping that and calling your script with rating_key parameter. Works fine unless there are weird characters in the name (and I had to remove the year as well or not found), but would be much cleaner if your script could optionally check for an existing poster.jpg. Thank you very much for your work on this, I now have your script running daily to add posters as new items arrive.

@msw101144

Copy link
Copy Markdown

I don't understand why "Saves poster and art images from Plex to same folder as the media files." The script did run, but I really have no idea just what it did other than add to log files Now what and how would one need to change to save posters in a simple windows folder [ie]
C:\Posters??????? I want easy access to the Posters. I have some colorized versions which are hard to find poster for, and yea, there are some rather crummy colorization , and some good.

@JonnyWong16

Copy link
Copy Markdown
Author

Updated script with --logo and --squareArt options, as well as a flag to overwrite existing files (the script skips existing files by default now). Background art has been changed to art.jpg instead of background.jpg.

@KellyNewman87

Copy link
Copy Markdown

I'm receiving the error below. I've tried it on two different movies and checked to make sure they both had squareart selected:

PS C:\Users\xxxxxxxxx\Media-Scripts\save_resources> python save_resources.py --rating_key 583323 --poster --art --logo --squareArt
The Killer That Stalked New York (1950)
  └─ Downloading x:\Movies\The Killer That Stalked New York (1950)\poster.jpg
poster.jpg: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1.57M/1.57M [00:00<00:00, 31.3MB/s]
  └─ Downloading x:\Movies\The Killer That Stalked New York (1950)\art.jpg
art.jpg: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 439k/439k [00:00<00:00, 6.54MB/s]
  └─ No logo set. Skipping.
Traceback (most recent call last):
  File "C:\Users\xxxxxxxxx\Media-Scripts\save_resources\save_resources.py", line 159, in <module>
    save_item(
  File "C:\Users\xxxxxxxxx\Media-Scripts\save_resources\save_resources.py", line 113, in save_item
    download_resource('squareArt', item.squareArtUrl, save_path, 'squareArt.jpg', overwrite=overwrite)
                                   ^^^^^^^^^^^^^^^^^
  File "C:\Users\xxxxxxxxx\AppData\Local\Programs\Python\Python311\Lib\site-packages\plexapi\base.py", line 617, in __getattribute__
    value = super(PlexPartialObject, self).__getattribute__(attr)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'Movie' object has no attribute 'squareArtUrl'

@JonnyWong16

Copy link
Copy Markdown
Author

Your version of plexapi is out-of-date.

python -m pip install -U plexapi

@KellyNewman87

Copy link
Copy Markdown

Love the simple fixes! Thank you!!!

@KellyNewman87

Copy link
Copy Markdown

As my server is running at close to 100% CPU, while it reprocesses all the assets, I was wondering...

Is it possible to have an option to compare the size of the existing file with the one to be written and NOT overwrite if the files are the same size?

The reason is that most of the time I do have the file already, because I ran this type of script. However, I then later come across a movie or tv show and decide to change one or more of its assets. Usually, I later run this script with the --overwrite and it then overwrite the many assets, even though most of them are the same. However, Plex sees the date change and put the hurt on my CPU again for several hours.

This way I could just set a task schedule for run the script weekly or so without worrying about overloading the CPU each time.

@JonnyWong16

Copy link
Copy Markdown
Author

No, that is outside the scope of the script. You can modify the script if you want more sophisticated file comparisons.

@KellyNewman87

Copy link
Copy Markdown

Understood, thanks.

@guru69

guru69 commented Aug 29, 2026

Copy link
Copy Markdown

I had the exact same high CPU issue and didn't want to rescan huge movies library. I used AI to write some python code that scans every movie folder and creates text file with the full paths to folders that had no poster.jpg in it. The script then runs a FOR loop on the list and finds largest *.mkv, mp4 etc file in the folder, then uses plexapi to lookup the movie filename in Plex and return the Plex ratingKey that I used to call JonnyWong's script using the ratingKey argument. Instead of running his script on all movies the script is run once per missing poster (in loop) and directly targets that movie, limiting Plex changes, rescanning.

(Part 2)
If all movies in Plex are matched and have nice posters beforehand all is good. Unfortunately I had a lot of movies that had no real poster in plex, so Jonny's script dumped a poster.jpg of the generic screenshot image that plex makes when there is no poster. In a huge library its a major pain to find the movies without posters, but Jonny's script actually made it easier. Since every Plex generic screenshot is a rectangular 720 pixel wide jpg of varying (<720) heights I used a bash script that loops the movies folders and runs mediainfo on every poster.jog and spits out a list of folder paths containing a poster.jpg that is exactly 720 pixels wide and less than 720 pixels high.

I manually went through the list and matched in Plex or added my own posters so all of the missing ones were fixed.

Last step was running a bash script to loop through that same list and delete the 720 wide poster.jpg files.
Now you run your python (from up top) and Jonny's script will create poster.jpg of all the new posters you fixed.

Since the python loop w/ Jonny script only accesses missing poster.jpg I scheduled it nightly so every new movie has its own poster.jpg the next day.

Hope this helps. Thanks to Jonny's script all my posters are perfect and work across Plex, Emby and Jellyfin.

As my server is running at close to 100% CPU, while it reprocesses all the assets, I was wondering...

Is it possible to have an option to compare the size of the existing file with the one to be written and NOT overwrite if the files are the same size?

The reason is that most of the time I do have the file already, because I ran this type of script. However, I then later come across a movie or tv show and decide to change one or more of its assets. Usually, I later run this script with the --overwrite and it then overwrite the many assets, even though most of them are the same. However, Plex sees the date change and put the hurt on my CPU again for several hours.

This way I could just set a task schedule for run the script weekly or so without worrying about overloading the CPU each time.

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