This commit is contained in:
patrick 2019-08-25 23:17:01 -04:00
parent 1356d9c099
commit 55419e29bb
9 changed files with 667 additions and 0 deletions

0
.gitignore vendored Normal file
View file

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# Plex Auto Collections
Python 3 script/[standalone build]() that works off a configuration file to create/update Plex collection. Supports IMDB
lists as well as built in Plex filters such as actors, genres, year, studio and more. For more filters refer to the
[plexapi.video.Movie](https://python-plexapi.readthedocs.io/en/latest/modules/video.html#plexapi.video.Movie)
documentation. Not everything has been tested, so results may vary based off the filter.
When parsing IMDB lists the script will create a list of movies that are missing from Plex. If an TMDb and Radarr api-key
are supplied then the option will be presented to pass the list of movies along to Radarr.
As well as updating collections based off configuration files there is the ability to add new collections based off
filters, delete collections, search for collections and manage the collections in the configuration file.
Thanks to [/u/deva5610](https://www.reddit.com/user/deva5610) for [IMDBList2PlexCollection](https://github.com/deva5610/IMDBList2PlexCollection) which prompted
the idea for a configuration based collection manager.
Subfilters also allows for a little more granular selection of movies to add to a collection. Unlike regular filters, a
movie must match at least one value from each subfilter to be added to a collection.
# Disclaimer
I'm not a developer. In fact this my first project I've seen to "completion". I taught myself Python as I went along.
Because of this there are likely many bugs.
# Configuration
Modify the supplied config.yml.template file.
If you do not want it to have the option to submit movies that are missing from IMDB lists do not include the api-key
for TMBd or radarr. A TMDb apikey is not required for regular operation.
In order to find your Plex token follow
[this guide](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/).
Main filters allowed are actors, imdb-list as well as many attributes that can found in the [plexapi.video.Movie
documentation](https://python-plexapi.readthedocs.io/en/latest/modules/video.html#plexapi.video.Movie). In addition
subfilters for audio language, subtitle language and video-resolution have been created. Take note that the values for
each must match what Plex has including special characters in order to match.
subfilters:
video-resolution: 1080
audio-language: Français
subtitle-language: Englis
**Once complete it should look like**
collections:
Collection Name:
imdb-list: https://www.imdb.com/list/ls068177081/
actors: Seth Rogen, Aaron Paul
studio: Lionsgate
subfilters:
audio-language: English
genres: Action, Crime, Comedy
server:
library: Movies
token: ###################
url: http://192.168.1.5:32400
radarr:
url: http://192.168.1.5:7878/radarr/
token: ###########################
quality_profile_id: 4
tmdb:
apikey: ############################
# Usage
[Standalone binaries]() have been created for both Windows and Linux.
If you would like to run from Python I have only tested this fully on Python 3.7.4. Dependencies must be installed by running
pip install -r requirements.txt
If there are issues installing PyYAML 1.5.4 try
pip install -r requirements.txt --ignore-installed
Make sure that plexapi is installed from the github source in requirements.txt. The one provided by pip contains a bug
that will cause certain movies to crash the script when processing IMDB lists. To ensure that you are running the of
plexapi check utils.py contains the following:
######/plexapi/utils.py line 170
def toDatetime(value, format=None):
""" Returns a datetime object from the specified value.
Parameters:
value (str): value to return as a datetime
format (str): Format to pass strftime (optional; if value is a str).
"""
if value and value is not None:
if format:
value = datetime.strptime(value, format)
else:
value = datetime.fromtimestamp(int(value))
return value
To run the script in a terminal run
python plex_auto_collections.py
If you would like to schedule the script to run on a schedule the script can be launched to automatically and only
based off the collection and then quit by running. This applies to the standalones as well.
python plex_auto_collections.py --update

91
config_tools.py Executable file
View file

@ -0,0 +1,91 @@
import os
import yaml
from plex_tools import get_actor_rkey
from plex_tools import add_to_collection
from plexapi.server import PlexServer
from plexapi.video import Movie
from radarr_tools import add_to_radarr
class Config:
def __init__(self):
self.config_path = os.path.join(os.getcwd(), 'config.yml')
self.data = yaml.load(open(self.config_path), Loader=yaml.FullLoader)
self.plex = self.data['server']
self.tmdb = self.data['tmdb']
self.radarr = self.data['radarr']
self.collections = self.data['collections']
class Plex:
def __init__(self):
config = Config().plex
url = config['url']
token = config['token']
library = config['library']
self.Server = PlexServer(url, token)
self.MovieLibrary = self.Server.library.section(library)
self.Movie = Movie
class Radarr:
def __init__(self):
config = Config().radarr
self.url = config['url']
self.token = config['token']
self.quality = config['quality_profile_id']
class TMDB:
def __init__(self):
config = Config().tmdb
self.apikey = config['apikey']
def update_from_config(plex, skip_radarr=False):
collections = Config().collections
for c in collections:
print("Updating collection: {}...".format(c))
methods = [m for m in collections[c] if "subfilters" not in m]
if "subfilters" in collections[c]:
subfilters = []
for sf in collections[c]["subfilters"]:
sf_string = sf, collections[c]["subfilters"][sf]
subfilters.append(sf_string)
for m in methods:
values = collections[c][m].split(", ")
for v in values:
if m[-1:] == "s":
m_print = m[:-1]
else:
m_print = m
print("Processing {}: {}".format(m_print, v))
if m == "actors" or m == "actor":
v = get_actor_rkey(plex, v)
try:
missing = add_to_collection(plex, m, v, c, subfilters)
except UnboundLocalError:
missing = add_to_collection(plex, m, v, c)
if missing:
print("{} missing movies from IMDB List: {}".format(len(missing), v))
if not skip_radarr:
if input("Add missing movies to Radarr? (y/n)").upper() == "Y":
add_to_radarr(missing)
print("\n")
def modify_config(c_name, m, value):
config = Config()
if m == "movie":
print("Movie's in config not supported yet")
else:
try:
if value not in str(config.data['collections'][c_name][m]):
try:
config.data['collections'][c_name][m] = \
config.data['collections'][c_name][m] + ", {}".format(value)
except TypeError:
config.data['collections'][c_name][m] = value
else:
print("Value already in collection config")
return
except KeyError:
config.data['collections'][c_name][m] = value
print("Updated config file")
with open(config.config_path, "w") as f:
yaml.dump(config.data, f)

BIN
dist/plex_auto_collections vendored Executable file

Binary file not shown.

50
imdb_tools.py Executable file
View file

@ -0,0 +1,50 @@
import requests
from lxml import html
from tmdbv3api import TMDb
from tmdbv3api import Movie
import config_tools
def imdb_get_movies(plex, data):
tmdb = TMDb()
movie = Movie()
tmdb.api_key = config_tools.TMDB().apikey
imdb_url = data
if imdb_url[-1:] == " ":
imdb_url = imdb_url[:-1]
imdb_map = {}
library_language = plex.MovieLibrary.language
r = requests.get(imdb_url, headers={'Accept-Language': library_language})
tree = html.fromstring(r.content)
title_ids = tree.xpath("//div[contains(@class, 'lister-item-image')]"
"//a/img//@data-tconst")
for m in plex.MovieLibrary.all():
if 'themoviedb://' in m.guid:
if not tmdb.api_key == "None":
tmdb_id = m.guid.split('themoviedb://')[1].split('?')[0]
tmdbapi = movie.details(tmdb_id)
imdb_id = tmdbapi.imdb_id
else:
imdb_id = None
elif 'imdb://' in m.guid:
imdb_id = m.guid.split('imdb://')[1].split('?')[0]
else:
imdb_id = None
if imdb_id and imdb_id in title_ids:
imdb_map[imdb_id] = m
else:
imdb_map[m.ratingKey] = m
in_library_idx = []
matched_imbd_movies = []
for imdb_id in title_ids:
movie = imdb_map.pop(imdb_id, None)
if movie:
matched_imbd_movies.append(plex.Server.fetchItem(movie.ratingKey))
# Get list of missing movies from selected list
missing_imdb_movies = [imdb for idx, imdb in enumerate(title_ids)
if idx not in in_library_idx]
return matched_imbd_movies, missing_imdb_movies

209
plex_auto_collections.py Executable file
View file

@ -0,0 +1,209 @@
from config_tools import Plex
from config_tools import update_from_config
from config_tools import modify_config
from config_tools import Config
from plexapi.video import Movie
import plex_tools
from radarr_tools import add_to_radarr
import argparse
import sys
plex = Plex()
parser = argparse.ArgumentParser()
parser.add_argument("-u", "--update", help="Automatically update collections off config and quit",
action="store_true")
args = parser.parse_args()
if args.update:
update_from_config(plex, True)
sys.exit(0)
print("===================================================================")
print(" Plex Auto Collections by /u/iRawrz ")
print("===================================================================")
print("\n")
if hasattr(__builtins__, 'raw_input'):
input = raw_input
if input("Update Collections from Config? (y/n): ").upper() == "Y":
update_from_config(plex)
def append_collection(config_update=None):
while True:
if config_update:
collection_name = config_update
else:
collection_name = input("Enter collection to add to: ")
try:
selected_collection = plex_tools.get_collection(plex, collection_name)
if not isinstance(selected_collection, str):
print("\"{}\" Selected.".format(selected_collection.title))
finished = False
while not finished:
method = input("Add Movie(m), Actor(a), IMDB List(l), Custom (c)?: ")
if method == "m":
if not config_update:
method = "movie"
value = input("Enter Movie (Name or Rating Key): ")
if value is int:
plex_movie = plex_tools.get_movie(int(value))
print('+++ Adding %s to collection %s' % (plex_movie.title, selected_collection.title))
plex_movie.addCollection(selected_collection.title)
else:
results = plex_tools.get_movie(plex, value)
if len(results) > 1:
while True:
i = 1
for result in results:
print("{POS}) {TITLE} - {RATINGKEY}".format(POS=i, TITLE=result.title,
RATINGKEY=result.ratingKey))
i += 1
s = input("Select movie (N for None): ")
if int(s):
s = int(s)
if len(results) >= s > 0:
result = results[s - 1]
print('+++ Adding %s to collection %s' % (
result.title, selected_collection.title))
result.addCollection(selected_collection.title)
break
else:
break
else:
print("Movies in configuration file not yet supported")
elif method == "a":
method = "actors"
value = input("Enter Actor Name: ")
a_rkey = plex_tools.get_actor_rkey(plex, value)
if config_update:
modify_config(collection_name, method, value)
else:
plex_tools.add_to_collection(plex, method, a_rkey, selected_collection.title)
elif method == "l":
method = "imdb-list"
url = input("Enter IMDB List URL: ").strip()
print("Processing IMDB List: {}".format(url))
try:
missing = plex_tools.add_to_collection(plex, "imdb-list", url, selected_collection.title)
if missing:
print("{} missing movies from IMDB List: {}".format(len(missing), url))
if input("Add missing movies to Radarr? (y/n)").upper() == "Y":
add_to_radarr(missing)
except:
print("Bad IMDB List URL")
if config_update:
modify_config(collection_name, method, url)
elif method == "c":
print("Please read the below link to see valid filter types. "
"Please note not all have been tested")
print("https://python-plexapi.readthedocs.io/en/latest/modules/video.html?highlight=plexapi.video.Movie#plexapi.video.Movie")
while True:
method = input("Enter filter method: ")
m_search = " " + method + " "
if m_search in Movie.__doc__ or hasattr(Movie, m_search):
if method[-1:] == "s":
method_p = method[:-1]
else:
method_p = method
value = input("Enter {}: ".format(method_p))
if config_update:
modify_config(collection_name, method, value)
else:
plex_tools.add_to_collection(plex, method, value, selected_collection.title)
break
else:
print("Filter method did not match an attribute for plexapi.video.Movie")
if input("Add more to collection? (y/n):") == "n":
print("\n")
finished = True
break
else:
print(selected_collection)
break
except AttributeError:
print("No collection found")
mode = None
while not mode == "q":
try:
print("Modes: Rescan (r), Actor(a), IMDB List(l), "
"Add to Existing Collection (+), Delete(-), "
"Search(s), Quit(q)")
mode = input("Select Mode: ")
if mode == "a":
actor = input("Enter actor name: ")
a_rkey = plex_tools.get_actor_rkey(plex, actor)
if isinstance(a_rkey, int):
c_name = input("Enter collection name: ")
plex_tools.add_to_collection(plex, "actors", a_rkey, c_name)
else:
print("Invalid actor")
print("\n")
elif mode == "r":
update_from_config(plex)
elif mode == "l":
url = input("Enter IMDB List URL: ")
c_name = input("Enter collection name: ")
print("Processing IMDB List: {}".format(url))
try:
missing = plex_tools.add_to_collection(plex, "imdb-list", url, c_name)
if missing:
print("{} missing movies from IMDB List: {}".format(len(missing), url))
if input("Add missing movies to Radarr? (y/n)").upper() == "Y":
add_to_radarr(missing)
except:
print("Bad IMDB List URL")
print("\n")
elif mode == "+":
if input("Add to collection in config file? (y/n): ") == "y":
collections = Config().collections
for i, collection in enumerate(collections):
print("{}) {}".format(i + 1, collection))
selection = None
while selection not in collections:
selection = input("Enter Collection Number: ")
try:
if int(selection) > 0:
selection = list(collections)[int(selection) - 1]
else:
print("Invalid selection")
except (IndexError, ValueError) as e:
print("Invalid selection")
append_collection(selection)
else:
append_collection()
elif mode == "-":
data = input("Enter collection name to search for (blank for all): ")
collection = plex_tools.get_collection(plex, data)
if not isinstance(collection, str):
plex_tools.delete_collection(collection)
else:
print(collection)
print("\n")
elif mode == "s":
data = input("Enter collection name to search for (blank for all): ")
collection = plex_tools.get_collection(plex, data)
if not isinstance(collection, str):
print("Found collection {}".format(collection.title))
movies = collection.children
print("Movies in collection: ")
for i, m in enumerate(movies):
print("{}) {}".format(i + 1, m.title))
else:
print(collection)
print("\n")
except KeyboardInterrupt:
print("\n")
pass

146
plex_tools.py Executable file
View file

@ -0,0 +1,146 @@
from plexapi.video import Movie
from plexapi import exceptions as PlexExceptions
import imdb_tools
import inspect
def get_movie(plex, data):
# If an int is passed as data, assume it is a movie's rating key
if isinstance(data, int):
try:
return plex.Server.fetchItem(data)
except PlexExceptions.BadRequest:
return "Nothing found"
elif isinstance(data, Movie):
return data
else:
print(data)
movie_list = plex.MovieLibrary.search(title=data)
if movie_list:
return movie_list
else:
return "Movie: " + data + " not found"
def get_actor_rkey(plex, data):
"""Takes in actors name as str and returns as Plex's corresponding rating key ID"""
search = data
# We must first perform standard search against the Plex Server
# Searching in the Library via Actor only works if the ratingKey is already known
results = plex.Server.search(search)
for entry in results:
entry = str(entry)
entry = entry.split(":")
entry[0] = entry[0][1:]
if entry[0] == "Movie":
movie_id = int(entry[1])
break
try:
# We need to pull details from a movie to correspond the actor's name to their Plex Rating Key
movie_roles = plex.Server.fetchItem(movie_id).roles
for role in movie_roles:
role = str(role).split(":")
movie_actor_id = role[1]
movie_actor_name = role[2][:-1].upper()
if search.upper().replace(" ", "-") == movie_actor_name:
actor_id = movie_actor_id
return int(actor_id)
except UnboundLocalError:
return "Actor: " + search + " not found"
def get_all_movies(plex):
return plex.MovieLibrary.all()
def get_collection(plex, data):
collection_list = plex.MovieLibrary.search(title=data, libtype="collection")
if len(collection_list) > 1:
c_names = [(str(i+1) + ") " + collection.title) for i, collection in enumerate(collection_list)]
print("\n".join(c_names))
while True:
try:
selection = int(input("Choose collection number: ")) - 1
if selection >= 0:
return collection_list[selection]
elif selection == "q":
return
else:
print("Invalid entry")
except (IndexError, ValueError) as E:
print("Invalid entry")
elif len(collection_list) == 1:
return collection_list[0]
else:
return "No collection found"
def add_to_collection(plex, method, value, c, subfilters=None):
if method in Movie.__doc__ or hasattr(Movie, method):
try:
movies = plex.MovieLibrary.search(**{method: value})
except PlexExceptions.BadRequest:
# If last character is "s" remove it and try again
if method[-1:] == "s":
movies = plex.MovieLibrary.search(**{method[:-1]: value})
movies = [m.ratingKey for m in movies if movies]
else:
if method == "imdb-list":
movies, missing = imdb_tools.imdb_get_movies(plex, value)
if movies:
# Check if already in collection
cols = plex.MovieLibrary.search(title=c, libtype="collection")
try:
fs = cols[0].children
except IndexError:
fs = []
for rk in movies:
current_m = get_movie(plex, rk)
current_m.reload()
if current_m in fs:
print("{} is already in collection: {}".format(current_m.title, c))
elif subfilters:
match = True
for sf in subfilters:
method = sf[0]
terms = str(sf[1]).split(", ")
try:
mv_attrs = getattr(current_m, method)
# If it returns a list, get the 'tag' attribute
# Otherwise, it's a string. Make it a list.
if isinstance(mv_attrs, list) and "-" not in method:
mv_attrs = [getattr(x, 'tag') for x in mv_attrs]
else:
mv_attrs = [str(mv_attrs)]
except AttributeError:
for media in current_m.media:
for part in media.parts:
if method == "audio-language":
mv_attrs = ([audio_stream.language for audio_stream in part.audioStreams()])
if method == "subtitle-language":
mv_attrs = ([subtitle_stream for subtitle_stream in part.subtitleStreams()])
if method == "video-resolution":
mv_attrs = ([mv_part.videoResolution for mv_part in rk.media])
# Get the intersection of the user's terms and movie's terms
# If it's empty, it's not a match
if not list(set(terms) & set(mv_attrs)):
match = False
break
if match:
print("+++ Adding {} to collection {}".format(current_m.title, c))
current_m.addCollection(c)
elif not subfilters:
print("+++ Adding {} to collection: {}".format(current_m.title, c))
current_m.addCollection(c)
try:
missing
except UnboundLocalError:
return
else:
return missing
def delete_collection(data):
confirm = input("{} selected. Confirm deletion (y/n):".format(data.title))
if confirm == "y":
data.delete()
print("Collection deleted")

65
radarr_tools.py Executable file
View file

@ -0,0 +1,65 @@
import re, json, requests, os, yaml
from tmdbv3api import TMDb
from tmdbv3api import Movie
def add_to_radarr(missing):
config_path = os.path.join(os.getcwd(), 'config.yml')
config = yaml.load(open(config_path), Loader=yaml.FullLoader)
tmdb = TMDb()
tmdb.api_key = config['tmdb']['apikey']
tmdb.language = "en"
url = config['radarr']['url'] + "/api/movie"
quality = config['radarr']['quality_profile_id']
token = config['radarr']['token']
querystring = {"apikey": "{}".format(token)}
if "None" in (tmdb.api_key, url, quality, token):
print("\n")
print("All TMDB / Radarr details must be filled out in the configuration "
"file to import missing movies into Radarr")
print("\n")
return
movie = Movie()
for m in missing:
tmdb_details = movie.external(external_id=str(m), external_source="imdb_id")['movie_results'][0]
tmdb_title = tmdb_details['title']
tmdb_year = tmdb_details['release_date'].split("-")[0]
tmdb_id = tmdb_details['id']
tmdb_poster = "https://image.tmdb.org/t/p/original{}".format(tmdb_details['poster_path'])
titleslug = "{} {}".format(tmdb_title, tmdb_year)
titleslug = re.sub(r'([^\s\w]|_)+', '', titleslug)
titleslug = titleslug.replace(" ", "-")
titleslug = titleslug.lower()
payload = {
"title": tmdb_title,
"qualityProfileId": quality,
"year": int(tmdb_year),
"tmdbid": str(tmdb_id),
"titleslug": titleslug,
"monitored": "true",
"rootFolderPath": "//mnt//user//PlexMedia//movies",
"images": [{
"covertype": "poster",
"url": tmdb_poster
}]
}
headers = {
'Content-Type': "application/json",
'cache-control': "no-cache",
'Postman-Token': "0eddcc07-12ba-49d3-9756-3aa8256deaf3"
}
response = requests.request("POST", url, data=json.dumps(payload), headers=headers, params=querystring)
r_json = json.loads(response.text)
try:
if r_json[0]['errorMessage'] == "This movie has already been added":
print(tmdb_title + " already added to Radarr")
except KeyError:
print("+++ " + tmdb_title + " added to Radarr")

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
PyYAML==5.1.2
tmdbv3api
lxml
requests
git+git://github.com/pkkid/python-plexapi.git#egg=plexapi