Merge pull request #165 from meisnate12/develop

1.7.0
pull/176/head v1.7.0
meisnate12 4 years ago committed by GitHub
commit 82584fabd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,5 +1,5 @@
# Plex Meta Manager # Plex Meta Manager
#### Version 1.6.4 #### Version 1.7.0
The original concept for Plex Meta Manager is [Plex Auto Collections](https://github.com/mza921/Plex-Auto-Collections), but this is rewritten from the ground up to be able to include a scheduler, metadata edits, multiple libraries, and logging. Plex Meta Manager is a Python 3 script that can be continuously run using YAML configuration files to update on a schedule the metadata of the movies, shows, and collections in your libraries as well as automatically build collections based on various methods all detailed in the wiki. Some collection examples that the script can automatically build and update daily include Plex Based Searches like actor, genre, or studio collections or Collections based on TMDb, IMDb, Trakt, TVDb, AniDB, or MyAnimeList lists and various other services. The original concept for Plex Meta Manager is [Plex Auto Collections](https://github.com/mza921/Plex-Auto-Collections), but this is rewritten from the ground up to be able to include a scheduler, metadata edits, multiple libraries, and logging. Plex Meta Manager is a Python 3 script that can be continuously run using YAML configuration files to update on a schedule the metadata of the movies, shows, and collections in your libraries as well as automatically build collections based on various methods all detailed in the wiki. Some collection examples that the script can automatically build and update daily include Plex Based Searches like actor, genre, or studio collections or Collections based on TMDb, IMDb, Trakt, TVDb, AniDB, or MyAnimeList lists and various other services.
@ -11,15 +11,17 @@ The script is designed to work with most Metadata agents including the new Plex
## Getting Started ## Getting Started
* [Wiki](https://github.com/meisnate12/Plex-Meta-Manager/wiki) 1. Install Plex Meta Manager either by installing Python3 and following the [Local Installation Guide](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Local-Installation)
* [Local Installation](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Local-Installation) or by installing Docker and following the [Docker Installation Guide](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Docker-Installation) or the [unRAID Installation Guide](https://github.com/meisnate12/Plex-Meta-Manager/wiki/unRAID-Installation)
* [Docker Installation](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Docker) 2. Once installed, you have to create a [Configuration File](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Configuration-File) filled with all your values to connect to the various services.
3. After that you can start updating Metadata and building automatic Collections by creating a [Metadata File](https://github.com/meisnate12/Plex-Meta-Manager/wiki/Metadata-File) for each Library you want to interact with.
4. Explore the [Wiki](https://github.com/meisnate12/Plex-Meta-Manager/wiki) to see all the different Collection Builders that can be used to create collections.
## Support ## Support
* Before posting on Github about an enhancement, error, or configuration question please visit the [Plex Meta Manager Discord Server](https://discord.gg/NfH6mGFuAB) * Before posting on Github about an enhancement, error, or configuration question please visit the [Plex Meta Manager Discord Server](https://discord.gg/NfH6mGFuAB).
* If you're getting an error or have an enhancement post in the [Issues](https://github.com/meisnate12/Plex-Meta-Manager/issues) * If you're getting an Error or have an Enhancement post in the [Issues](https://github.com/meisnate12/Plex-Meta-Manager/issues).
* If you have a configuration question visit the [Discussions](https://github.com/meisnate12/Plex-Meta-Manager/discussions) * If you have a configuration question post in the [Discussions](https://github.com/meisnate12/Plex-Meta-Manager/discussions).
* To see user submitted Metadata configuration files and you could even add your own go to the [Plex Meta Manager Configs](https://github.com/meisnate12/Plex-Meta-Manager-Configs) * To see user submitted Metadata configuration files, and you to even add your own, go to the [Plex Meta Manager Configs](https://github.com/meisnate12/Plex-Meta-Manager-Configs).
* Pull Request are welcome but please submit them to the develop branch * Pull Request are welcome but please submit them to the develop branch.
* If you wish to contribute to the Wiki please fork and send a pull request on the [Plex Meta Manager Wiki Repository](https://github.com/meisnate12/Plex-Meta-Manager-Wiki) * If you wish to contribute to the Wiki please fork and send a pull request on the [Plex Meta Manager Wiki Repository](https://github.com/meisnate12/Plex-Meta-Manager-Wiki).

@ -1293,4 +1293,3 @@ collections:
- ~ - ~
sort_title: ~_Collectionless sort_title: ~_Collectionless
collection_order: alpha collection_order: alpha
collection_order: alpha

@ -30,19 +30,28 @@ tautulli: # Can be individually specified
radarr: # Can be individually specified per library as well radarr: # Can be individually specified per library as well
url: http://192.168.1.12:7878 url: http://192.168.1.12:7878
token: ################################ token: ################################
version: v2 version: v3
quality_profile: HD-1080p
root_folder_path: S:/Movies
add: false add: false
root_folder_path: S:/Movies
monitor: true
availability: announced
quality_profile: HD-1080p
tag:
search: false search: false
sonarr: # Can be individually specified per library as well sonarr: # Can be individually specified per library as well
url: http://192.168.1.12:8989 url: http://192.168.1.12:8989
token: ################################ token: ################################
version: v2 version: v3
quality_profile: HD-1080p
root_folder_path: "S:/TV Shows"
add: false add: false
root_folder_path: "S:/TV Shows"
monitor: all
quality_profile: HD-1080p
language_profile: English
series_type: standard
season_folder: true
tag:
search: false search: false
cutoff_search: false
omdb: omdb:
apikey: ######## apikey: ########
trakt: trakt:

@ -6,6 +6,8 @@ from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = ["anidb_id", "anidb_relation", "anidb_popular"]
class AniDBAPI: class AniDBAPI:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config

@ -5,6 +5,21 @@ from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = [
"anilist_genre",
"anilist_id",
"anilist_popular",
"anilist_relations",
"anilist_season",
"anilist_studio",
"anilist_tag",
"anilist_top_rated"
]
pretty_names = {
"score": "Average Score",
"popular": "Popularity"
}
class AniListAPI: class AniListAPI:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
@ -223,15 +238,15 @@ class AniListAPI:
elif method == "anilist_season": elif method == "anilist_season":
mal_ids = self.season(data["season"], data["year"], data["sort_by"], data["limit"]) mal_ids = self.season(data["season"], data["year"], data["sort_by"], data["limit"])
if status_message: if status_message:
logger.info(f"Processing {pretty}: {data['limit'] if data['limit'] > 0 else 'All'} Anime from {util.pretty_seasons[data['season']]} {data['year']} sorted by {util.anilist_pretty[data['sort_by']]}") logger.info(f"Processing {pretty}: {data['limit'] if data['limit'] > 0 else 'All'} Anime from {util.pretty_seasons[data['season']]} {data['year']} sorted by {pretty_names[data['sort_by']]}")
elif method == "anilist_genre": elif method == "anilist_genre":
mal_ids = self.genre(data["genre"], data["sort_by"], data["limit"]) mal_ids = self.genre(data["genre"], data["sort_by"], data["limit"])
if status_message: if status_message:
logger.info(f"Processing {pretty}: {data['limit'] if data['limit'] > 0 else 'All'} Anime from the Genre: {data['genre']} sorted by {util.anilist_pretty[data['sort_by']]}") logger.info(f"Processing {pretty}: {data['limit'] if data['limit'] > 0 else 'All'} Anime from the Genre: {data['genre']} sorted by {pretty_names[data['sort_by']]}")
elif method == "anilist_tag": elif method == "anilist_tag":
mal_ids = self.tag(data["tag"], data["sort_by"], data["limit"]) mal_ids = self.tag(data["tag"], data["sort_by"], data["limit"])
if status_message: if status_message:
logger.info(f"Processing {pretty}: {data['limit'] if data['limit'] > 0 else 'All'} Anime from the Tag: {data['tag']} sorted by {util.anilist_pretty[data['sort_by']]}") logger.info(f"Processing {pretty}: {data['limit'] if data['limit'] > 0 else 'All'} Anime from the Tag: {data['tag']} sorted by {pretty_names[data['sort_by']]}")
elif method in ["anilist_studio", "anilist_relations"]: elif method in ["anilist_studio", "anilist_relations"]:
if method == "anilist_studio": mal_ids, name = self.studio(data) if method == "anilist_studio": mal_ids, name = self.studio(data)
else: mal_ids, _, name = self.relations(data) else: mal_ids, _, name = self.relations(data)

@ -1,12 +1,148 @@
import glob, logging, os, re import glob, logging, os, re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from modules import util from modules import anidb, anilist, imdb, letterboxd, mal, plex, radarr, sonarr, tautulli, tmdb, trakttv, tvdb, util
from modules.util import Failed from modules.util import Failed
from plexapi.collection import Collections from plexapi.collection import Collections
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
image_file_details = ["file_poster", "file_background", "asset_directory"]
method_alias = {
"actors": "actor", "role": "actor", "roles": "actor",
"content_ratings": "content_rating", "contentRating": "content_rating", "contentRatings": "content_rating",
"countries": "country",
"decades": "decade",
"directors": "director",
"genres": "genre",
"labels": "label",
"rating": "critic_rating",
"studios": "studio",
"networks": "network",
"producers": "producer",
"writers": "writer",
"years": "year"
}
all_builders = anidb.builders + anilist.builders + imdb.builders + letterboxd.builders + mal.builders + plex.builders + tautulli.builders + tmdb.builders + trakttv.builders + tvdb.builders
dictionary_builders = [
"filters",
"anilist_genre",
"anilist_season",
"anilist_tag",
"mal_season",
"mal_userlist",
"plex_collectionless",
"plex_search",
"tautulli_popular",
"tautulli_watched",
"tmdb_discover"
]
show_only_builders = [
"tmdb_network",
"tmdb_show",
"tmdb_show_details",
"tvdb_show",
"tvdb_show_details"
]
movie_only_builders = [
"letterboxd_list",
"letterboxd_list_details",
"tmdb_collection",
"tmdb_collection_details",
"tmdb_movie",
"tmdb_movie_details",
"tmdb_now_playing",
"tvdb_movie",
"tvdb_movie_details"
]
numbered_builders = [
"anidb_popular",
"anilist_popular",
"anilist_top_rated",
"mal_all",
"mal_airing",
"mal_upcoming",
"mal_tv",
"mal_ova",
"mal_movie",
"mal_special",
"mal_popular",
"mal_favorite",
"mal_suggested",
"tmdb_popular",
"tmdb_top_rated",
"tmdb_now_playing",
"tmdb_trending_daily",
"tmdb_trending_weekly",
"trakt_trending",
"trakt_popular",
"trakt_recommended",
"trakt_watched",
"trakt_collected"
]
all_details = [
"sort_title", "content_rating", "collection_mode", "collection_order",
"summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary",
"tvdb_description", "trakt_description", "letterboxd_description",
"url_poster", "tmdb_poster", "tmdb_profile", "tvdb_poster", "file_poster",
"url_background", "tmdb_background", "tvdb_background", "file_background",
"name_mapping", "label", "show_filtered", "show_missing", "save_missing"
]
collectionless_details = [
"sort_title", "content_rating",
"summary", "tmdb_summary", "tmdb_description", "tmdb_biography",
"collection_order", "plex_collectionless",
"url_poster", "tmdb_poster", "tmdb_profile", "file_poster",
"url_background", "file_background",
"name_mapping", "label", "label_sync_mode", "test"
]
ignored_details = [
"run_again",
"schedule",
"sync_mode",
"template",
"test",
"tmdb_person"
]
boolean_details = [
"show_filtered",
"show_missing",
"save_missing"
]
all_filters = [
"actor", "actor.not",
"audio_language", "audio_language.not",
"audio_track_title", "audio_track_title.not",
"collection", "collection.not",
"content_rating", "content_rating.not",
"country", "country.not",
"director", "director.not",
"genre", "genre.not",
"max_age",
"originally_available.gte", "originally_available.lte",
"tmdb_vote_count.gte", "tmdb_vote_count.lte",
"duration.gte", "duration.lte",
"original_language", "original_language.not",
"audience_rating.gte", "audience_rating.lte",
"critic_rating.gte", "critic_rating.lte",
"studio", "studio.not",
"subtitle_language", "subtitle_language.not",
"video_resolution", "video_resolution.not",
"writer", "writer.not",
"year", "year.gte", "year.lte", "year.not"
]
movie_only_filters = [
"audio_language", "audio_language.not",
"audio_track_title", "audio_track_title.not",
"country", "country.not",
"director", "director.not",
"duration.gte", "duration.lte",
"original_language", "original_language.not",
"subtitle_language", "subtitle_language.not",
"video_resolution", "video_resolution.not",
"writer", "writer.not"
]
class CollectionBuilder: class CollectionBuilder:
def __init__(self, config, library, name, data): def __init__(self, config, library, name, data):
self.config = config self.config = config
@ -14,11 +150,12 @@ class CollectionBuilder:
self.name = name self.name = name
self.data = data self.data = data
self.details = { self.details = {
"arr_tag": None,
"show_filtered": library.show_filtered, "show_filtered": library.show_filtered,
"show_missing": library.show_missing, "show_missing": library.show_missing,
"save_missing": library.save_missing "save_missing": library.save_missing
} }
self.radarr_options = {}
self.sonarr_options = {}
self.missing_movies = [] self.missing_movies = []
self.missing_shows = [] self.missing_shows = []
self.methods = [] self.methods = []
@ -28,6 +165,8 @@ class CollectionBuilder:
self.summaries = {} self.summaries = {}
self.schedule = "" self.schedule = ""
self.rating_key_map = {} self.rating_key_map = {}
self.add_to_radarr = None
self.add_to_sonarr = None
current_time = datetime.now() current_time = datetime.now()
current_year = current_time.year current_year = current_time.year
@ -53,7 +192,7 @@ class CollectionBuilder:
else: else:
for tm in data_template: for tm in data_template:
if not data_template[tm]: if not data_template[tm]:
raise Failed(f"Collection Error: template sub-attribute {data_template[tm]} is blank") raise Failed(f"Collection Error: template sub-attribute {tm} is blank")
template_name = data_template["name"] template_name = data_template["name"]
template = self.library.templates[template_name] template = self.library.templates[template_name]
@ -95,18 +234,22 @@ class CollectionBuilder:
if option not in data_template and f"<<{option}>>" in txt: if option not in data_template and f"<<{option}>>" in txt:
raise Failed("remove attribute") raise Failed("remove attribute")
for template_method in data_template: for template_method in data_template:
if template_method != "name" and f"<<{template_method}>>" in txt: if template_method != "name" and txt == f"<<{template_method}>>":
return data_template[template_method]
elif template_method != "name" and f"<<{template_method}>>" in txt:
txt = txt.replace(f"<<{template_method}>>", str(data_template[template_method])) txt = txt.replace(f"<<{template_method}>>", str(data_template[template_method]))
if "<<collection_name>>" in txt: if "<<collection_name>>" in txt:
txt = txt.replace("<<collection_name>>", str(self.name)) txt = txt.replace("<<collection_name>>", str(self.name))
for dm in default: for dm in default:
if f"<<{dm}>>" in txt: if txt == f"<<{dm}>>":
txt = default[dm]
elif f"<<{dm}>>" in txt:
txt = txt.replace(f"<<{dm}>>", str(default[dm])) txt = txt.replace(f"<<{dm}>>", str(default[dm]))
if txt in ["true", "True"]: return True if txt in ["true", "True"]: return True
elif txt in ["false", "False"]: return False elif txt in ["false", "False"]: return False
else: else:
try: return int(txt) try: return int(txt)
except ValueError: return txt except (ValueError, TypeError): return txt
try: try:
if isinstance(attr_data, dict): if isinstance(attr_data, dict):
final_data = {} final_data = {}
@ -151,7 +294,7 @@ class CollectionBuilder:
run_time = str(schedule).lower() run_time = str(schedule).lower()
if run_time.startswith("day") or run_time.startswith("daily"): if run_time.startswith("day") or run_time.startswith("daily"):
skip_collection = False skip_collection = False
if run_time.startswith("week") or run_time.startswith("month") or run_time.startswith("year"): elif run_time.startswith("week") or run_time.startswith("month") or run_time.startswith("year"):
match = re.search("\\(([^)]+)\\)", run_time) match = re.search("\\(([^)]+)\\)", run_time)
if match: if match:
param = match.group(1) param = match.group(1)
@ -212,28 +355,37 @@ class CollectionBuilder:
else: else:
raise Failed("Collection Error: tmdb_person attribute is blank") raise Failed("Collection Error: tmdb_person attribute is blank")
for method_name, method_data in self.data.items(): for method_key, method_data in self.data.items():
if "tmdb" in method_name.lower() and not config.TMDb: raise Failed(f"Collection Error: {method_name} requires TMDb to be configured") if "trakt" in method_key.lower() and not config.Trakt: raise Failed(f"Collection Error: {method_key} requires Trakt todo be configured")
elif "trakt" in method_name.lower() and not config.Trakt: raise Failed(f"Collection Error: {method_name} requires Trakt todo be configured") elif "imdb" in method_key.lower() and not config.IMDb: raise Failed(f"Collection Error: {method_key} requires TMDb or Trakt to be configured")
elif "imdb" in method_name.lower() and not config.IMDb: raise Failed(f"Collection Error: {method_name} requires TMDb or Trakt to be configured") elif "radarr" in method_key.lower() and not self.library.Radarr: raise Failed(f"Collection Error: {method_key} requires Radarr to be configured")
elif "tautulli" in method_name.lower() and not self.library.Tautulli: raise Failed(f"Collection Error: {method_name} requires Tautulli to be configured") elif "sonarr" in method_key.lower() and not self.library.Sonarr: raise Failed(f"Collection Error: {method_key} requires Sonarr to be configured")
elif "mal" in method_name.lower() and not config.MyAnimeList: raise Failed(f"Collection Error: {method_name} requires MyAnimeList to be configured") elif "tautulli" in method_key.lower() and not self.library.Tautulli: raise Failed(f"Collection Error: {method_key} requires Tautulli to be configured")
elif "mal" in method_key.lower() and not config.MyAnimeList: raise Failed(f"Collection Error: {method_key} requires MyAnimeList to be configured")
elif method_data is not None: elif method_data is not None:
logger.debug("") logger.debug("")
logger.debug(f"Method: {method_name}") logger.debug(f"Validating Method: {method_key}")
logger.debug(f"Value: {method_data}") logger.debug(f"Value: {method_data}")
if method_name.lower() in util.method_alias: if method_key.lower() in method_alias:
method_name = util.method_alias[method_name.lower()] method_name = method_alias[method_key.lower()]
logger.warning(f"Collection Warning: {method_name} attribute will run as {method_name}") logger.warning(f"Collection Warning: {method_key} attribute will run as {method_name}")
else: elif method_key.lower() == "add_to_arr":
method_name = method_name.lower() method_name = "radarr_add" if self.library.is_movie else "sonarr_add"
if method_name in util.show_only_lists and self.library.is_movie: logger.warning(f"Collection Warning: {method_key} attribute will run as {method_name}")
elif method_key.lower() in ["arr_tag", "arr_folder"]:
method_name = f"{'rad' if self.library.is_movie else 'son'}{method_key.lower()}"
logger.warning(f"Collection Warning: {method_key} attribute will run as {method_name}")
else:
method_name = method_key.lower()
if method_name in show_only_builders and self.library.is_movie:
raise Failed(f"Collection Error: {method_name} attribute only works for show libraries") raise Failed(f"Collection Error: {method_name} attribute only works for show libraries")
elif method_name in util.movie_only_lists and self.library.is_show: elif method_name in movie_only_builders and self.library.is_show:
raise Failed(f"Collection Error: {method_name} attribute only works for movie libraries") raise Failed(f"Collection Error: {method_name} attribute only works for movie libraries")
elif method_name in util.movie_only_searches and self.library.is_show: elif method_name in plex.movie_only_searches and self.library.is_show:
raise Failed(f"Collection Error: {method_name} plex search only works for movie libraries") raise Failed(f"Collection Error: {method_name} plex search only works for movie libraries")
elif method_name not in util.collectionless_lists and self.collectionless: elif method_name in plex.show_only_searches and self.library.is_movie:
raise Failed(f"Collection Error: {method_name} plex search only works for show libraries")
elif method_name not in collectionless_details and self.collectionless:
raise Failed(f"Collection Error: {method_name} attribute does not work for Collectionless collection") raise Failed(f"Collection Error: {method_name} attribute does not work for Collectionless collection")
elif method_name == "summary": elif method_name == "summary":
self.summaries[method_name] = method_data self.summaries[method_name] = method_data
@ -297,36 +449,75 @@ class CollectionBuilder:
elif method_name == "sync_mode": elif method_name == "sync_mode":
if str(method_data).lower() in ["append", "sync"]: self.details[method_name] = method_data.lower() if str(method_data).lower() in ["append", "sync"]: self.details[method_name] = method_data.lower()
else: raise Failed("Collection Error: sync_mode attribute must be either 'append' or 'sync'") else: raise Failed("Collection Error: sync_mode attribute must be either 'append' or 'sync'")
elif method_name in ["arr_tag", "label"]: elif method_name == "label":
self.details[method_name] = util.get_list(method_data) self.details[method_name] = util.get_list(method_data)
elif method_name in util.boolean_details: elif method_name in boolean_details:
if isinstance(method_data, bool): self.details[method_name] = method_data if isinstance(method_data, bool): self.details[method_name] = method_data
elif str(method_data).lower() in ["t", "true"]: self.details[method_name] = True elif str(method_data).lower() in ["t", "true"]: self.details[method_name] = True
elif str(method_data).lower() in ["f", "false"]: self.details[method_name] = False elif str(method_data).lower() in ["f", "false"]: self.details[method_name] = False
else: raise Failed(f"Collection Error: {method_name} attribute must be either true or false") else: raise Failed(f"Collection Error: {method_name} attribute must be either true or false")
elif method_name in util.all_details: elif method_name in all_details:
self.details[method_name] = method_data self.details[method_name] = method_data
elif method_name == "radarr_add":
self.add_to_radarr = True
elif method_name == "radarr_folder":
self.radarr_options["folder"] = method_data
elif method_name in ["radarr_monitor", "radarr_search"]:
if isinstance(method_data, bool): self.radarr_options[method_name[7:]] = method_data
elif str(method_data).lower() in ["t", "true"]: self.radarr_options[method_name[7:]] = True
elif str(method_data).lower() in ["f", "false"]: self.radarr_options[method_name[7:]] = False
else: raise Failed(f"Collection Error: {method_name} attribute must be either true or false")
elif method_name == "radarr_availability":
if str(method_data).lower() in radarr.availability_translation:
self.radarr_options["availability"] = str(method_data).lower()
else:
raise Failed(f"Collection Error: {method_name} attribute must be either announced, cinemas, released or db")
elif method_name == "radarr_quality":
self.library.Radarr.get_profile_id(method_data)
self.radarr_options["quality"] = method_data
elif method_name == "radarr_tag":
self.radarr_options["tag"] = util.get_list(method_data)
elif method_name == "sonarr_add":
self.add_to_sonarr = True
elif method_name == "sonarr_folder":
self.sonarr_options["folder"] = method_data
elif method_name == "sonarr_monitor":
if str(method_data).lower() in sonarr.monitor_translation:
self.sonarr_options["monitor"] = str(method_data).lower()
else:
raise Failed(f"Collection Error: {method_name} attribute must be either all, future, missing, existing, pilot, first, latest or none")
elif method_name == "sonarr_quality":
self.library.Sonarr.get_profile_id(method_data, "quality_profile")
self.sonarr_options["quality"] = method_data
elif method_name == "sonarr_language":
self.library.Sonarr.get_profile_id(method_data, "language_profile")
self.sonarr_options["language"] = method_data
elif method_name == "sonarr_series":
if str(method_data).lower() in sonarr.series_type:
self.sonarr_options["series"] = str(method_data).lower()
else:
raise Failed(f"Collection Error: {method_name} attribute must be either standard, daily, or anime")
elif method_name in ["sonarr_season", "sonarr_search", "sonarr_cutoff_search"]:
if isinstance(method_data, bool): self.sonarr_options[method_name[7:]] = method_data
elif str(method_data).lower() in ["t", "true"]: self.sonarr_options[method_name[7:]] = True
elif str(method_data).lower() in ["f", "false"]: self.sonarr_options[method_name[7:]] = False
else: raise Failed(f"Collection Error: {method_name} attribute must be either true or false")
elif method_name == "sonarr_tag":
self.sonarr_options["tag"] = util.get_list(method_data)
elif method_name in ["title", "title.and", "title.not", "title.begins", "title.ends"]: elif method_name in ["title", "title.and", "title.not", "title.begins", "title.ends"]:
self.methods.append(("plex_search", [{method_name: util.get_list(method_data, split=False)}])) self.methods.append(("plex_search", [{method_name: util.get_list(method_data, split=False)}]))
elif method_name in ["decade", "year.greater", "year.less"]: elif method_name in ["year.greater", "year.less"]:
self.methods.append(("plex_search", [{method_name: [util.check_year(method_data, current_year, method_name)]}])) self.methods.append(("plex_search", [{method_name: util.check_year(method_data, current_year, method_name)}]))
elif method_name in ["added.before", "added.after", "originally_available.before", "originally_available.after"]: elif method_name in ["added.before", "added.after", "originally_available.before", "originally_available.after"]:
self.methods.append(("plex_search", [{method_name: [util.check_date(method_data, method_name, return_string=True, plex_date=True)]}])) self.methods.append(("plex_search", [{method_name: util.check_date(method_data, method_name, return_string=True, plex_date=True)}]))
elif method_name in ["duration.greater", "duration.less", "rating.greater", "rating.less"]: elif method_name in ["added", "added.not", "originally_available", "originally_available.not", "duration.greater", "duration.less"]:
self.methods.append(("plex_search", [{method_name: [util.check_number(method_data, method_name, minimum=0)]}])) self.methods.append(("plex_search", [{method_name: util.check_number(method_data, method_name, minimum=1)}]))
elif method_name in ["year", "year.not"]: elif method_name in ["critic_rating.greater", "critic_rating.less", "audience_rating.greater", "audience_rating.less"]:
self.methods.append(("plex_search", [{method_name: util.check_number(method_data, method_name, number_type="float", minimum=0, maximum=10)}]))
elif method_name in ["decade", "year", "year.not"]:
self.methods.append(("plex_search", [{method_name: util.get_year_list(method_data, current_year, method_name)}])) self.methods.append(("plex_search", [{method_name: util.get_year_list(method_data, current_year, method_name)}]))
elif method_name in util.tmdb_searches: elif method_name in plex.searches:
final_values = [] if method_name in plex.tmdb_searches:
for value in util.get_list(method_data):
if value.lower() == "tmdb" and "tmdb_person" in self.details:
for name in self.details["tmdb_person"]:
final_values.append(name)
else:
final_values.append(value)
self.methods.append(("plex_search", [{method_name: self.library.validate_search_list(final_values, os.path.splitext(method_name)[0])}]))
elif method_name in util.plex_searches:
if method_name in util.tmdb_searches:
final_values = [] final_values = []
for value in util.get_list(method_data): for value in util.get_list(method_data):
if value.lower() == "tmdb" and "tmdb_person" in self.details: if value.lower() == "tmdb" and "tmdb_person" in self.details:
@ -336,7 +527,12 @@ class CollectionBuilder:
final_values.append(value) final_values.append(value)
else: else:
final_values = method_data final_values = method_data
self.methods.append(("plex_search", [{method_name: self.library.validate_search_list(final_values, os.path.splitext(method_name)[0])}])) search = os.path.splitext(method_name)[0]
valid_values = self.library.validate_search_list(final_values, search)
if valid_values:
self.methods.append(("plex_search", [{method_name: valid_values}]))
else:
logger.warning(f"Collection Warning: No valid {search} values found in {final_values}")
elif method_name == "plex_all": elif method_name == "plex_all":
self.methods.append((method_name, [""])) self.methods.append((method_name, [""]))
elif method_name == "plex_collection": elif method_name == "plex_collection":
@ -363,19 +559,22 @@ class CollectionBuilder:
self.summaries[method_name] = item.description self.summaries[method_name] = item.description
self.methods.append((method_name[:-8], valid_list)) self.methods.append((method_name[:-8], valid_list))
elif method_name in ["trakt_watchlist", "trakt_collection"]: elif method_name in ["trakt_watchlist", "trakt_collection"]:
self.methods.append((method_name, config.Trakt.validate_trakt(method_name[6:], util.get_list(method_data), self.library.is_movie))) self.methods.append((method_name, config.Trakt.validate_trakt(util.get_list(method_data), trakt_type=method_name[6:], is_movie=self.library.is_movie)))
elif method_name == "imdb_list": elif method_name == "imdb_list":
new_list = [] new_list = []
for imdb_list in util.get_list(method_data, split=False): for imdb_list in util.get_list(method_data, split=False):
if isinstance(imdb_list, dict): if isinstance(imdb_list, dict):
dict_methods = {dm.lower(): dm for dm in imdb_list} dict_methods = {dm.lower(): dm for dm in imdb_list}
if "url" in dict_methods and imdb_list[dict_methods["url"]]: if "url" in dict_methods and imdb_list[dict_methods["url"]]:
imdb_url = imdb_list[dict_methods["url"]] imdb_url = config.IMDb.validate_imdb_url(imdb_list[dict_methods["url"]])
else: else:
raise Failed("Collection Error: imdb_list attribute url is required") raise Failed("Collection Error: imdb_list attribute url is required")
list_count = util.regex_first_int(imdb_list[dict_methods["limit"]], "List Limit", default=0) if "limit" in dict_methods and imdb_list[dict_methods["limit"]] else 0 if "limit" in dict_methods and imdb_list[dict_methods["limit"]]:
list_count = util.regex_first_int(imdb_list[dict_methods["limit"]], "List Limit", default=0)
else: else:
imdb_url = str(imdb_list) list_count = 0
else:
imdb_url = config.IMDb.validate_imdb_url(str(imdb_list))
list_count = 0 list_count = 0
new_list.append({"url": imdb_url, "limit": list_count}) new_list.append({"url": imdb_url, "limit": list_count})
self.methods.append((method_name, new_list)) self.methods.append((method_name, new_list))
@ -385,11 +584,11 @@ class CollectionBuilder:
values = util.get_list(method_data, split=False) values = util.get_list(method_data, split=False)
self.summaries[method_name] = config.Letterboxd.get_list_description(values[0], self.library.Plex.language) self.summaries[method_name] = config.Letterboxd.get_list_description(values[0], self.library.Plex.language)
self.methods.append((method_name[:-8], values)) self.methods.append((method_name[:-8], values))
elif method_name in util.dictionary_lists: elif method_name in dictionary_builders:
if isinstance(method_data, dict): if isinstance(method_data, dict):
def get_int(parent, method, data_in, methods_in, default_in, minimum=1, maximum=None): def get_int(parent, method, data_in, methods_in, default_in, minimum=1, maximum=None):
if method not in methods_in: if method not in methods_in:
logger.warning(f"Collection Warning: {parent} {methods_in[method]} attribute not found using {default_in} as default") logger.warning(f"Collection Warning: {parent} {method} attribute not found using {default_in} as default")
elif not data_in[methods_in[method]]: elif not data_in[methods_in[method]]:
logger.warning(f"Collection Warning: {parent} {methods_in[method]} attribute is blank using {default_in} as default") logger.warning(f"Collection Warning: {parent} {methods_in[method]} attribute is blank using {default_in} as default")
elif isinstance(data_in[methods_in[method]], int) and data_in[methods_in[method]] >= minimum: elif isinstance(data_in[methods_in[method]], int) and data_in[methods_in[method]] >= minimum:
@ -402,12 +601,14 @@ class CollectionBuilder:
return default_in return default_in
if method_name == "filters": if method_name == "filters":
for filter_name, filter_data in method_data.items(): for filter_name, filter_data in method_data.items():
if filter_name.lower() in util.method_alias or (filter_name.lower().endswith(".not") and filter_name.lower()[:-4] in util.method_alias): modifier = filter_name[-4:].lower()
filter_method = (util.method_alias[filter_name.lower()[:-4]] + filter_name.lower()[-4:]) if filter_name.lower().endswith(".not") else util.method_alias[filter_name.lower()] method = filter_name[:-4].lower() if modifier in [".not", ".lte", ".gte"] else filter_name.lower()
if method in method_alias:
filter_method = f"{method_alias[method]}{modifier}"
logger.warning(f"Collection Warning: {filter_name} filter will run as {filter_method}") logger.warning(f"Collection Warning: {filter_name} filter will run as {filter_method}")
else: else:
filter_method = filter_name.lower() filter_method = f"{method}{modifier}"
if filter_method in util.movie_only_filters and self.library.is_show: if filter_method in movie_only_filters and self.library.is_show:
raise Failed(f"Collection Error: {filter_method} filter only works for movie libraries") raise Failed(f"Collection Error: {filter_method} filter only works for movie libraries")
elif filter_data is None: elif filter_data is None:
raise Failed(f"Collection Error: {filter_method} filter is blank") raise Failed(f"Collection Error: {filter_method} filter is blank")
@ -417,7 +618,7 @@ class CollectionBuilder:
valid_data = util.check_number(filter_data, f"{filter_method} filter", minimum=1) valid_data = util.check_number(filter_data, f"{filter_method} filter", minimum=1)
elif filter_method in ["year.gte", "year.lte"]: elif filter_method in ["year.gte", "year.lte"]:
valid_data = util.check_year(filter_data, current_year, f"{filter_method} filter") valid_data = util.check_year(filter_data, current_year, f"{filter_method} filter")
elif filter_method in ["rating.gte", "rating.lte"]: elif filter_method in ["audience_rating.gte", "audience_rating.lte", "critic_rating.gte", "critic_rating.lte"]:
valid_data = util.check_number(filter_data, f"{filter_method} filter", number_type="float", minimum=0.1, maximum=10) valid_data = util.check_number(filter_data, f"{filter_method} filter", number_type="float", minimum=0.1, maximum=10)
elif filter_method in ["originally_available.gte", "originally_available.lte"]: elif filter_method in ["originally_available.gte", "originally_available.lte"]:
valid_data = util.check_date(filter_data, f"{filter_method} filter") valid_data = util.check_date(filter_data, f"{filter_method} filter")
@ -425,7 +626,7 @@ class CollectionBuilder:
valid_data = util.get_list(filter_data, lower=True) valid_data = util.get_list(filter_data, lower=True)
elif filter_method == "collection": elif filter_method == "collection":
valid_data = filter_data if isinstance(filter_data, list) else [filter_data] valid_data = filter_data if isinstance(filter_data, list) else [filter_data]
elif filter_method in util.all_filters: elif filter_method in all_filters:
valid_data = util.get_list(filter_data) valid_data = util.get_list(filter_data)
else: else:
raise Failed(f"Collection Error: {filter_method} filter not supported") raise Failed(f"Collection Error: {filter_method} filter not supported")
@ -454,16 +655,18 @@ class CollectionBuilder:
searches = {} searches = {}
for search_name, search_data in method_data.items(): for search_name, search_data in method_data.items():
search, modifier = os.path.splitext(str(search_name).lower()) search, modifier = os.path.splitext(str(search_name).lower())
if search in util.method_alias: if search in method_alias:
search = util.method_alias[search] search = method_alias[search]
logger.warning(f"Collection Warning: {str(search_name).lower()} plex search attribute will run as {search}{modifier if modifier else ''}") logger.warning(f"Collection Warning: {str(search_name).lower()} plex search attribute will run as {search}{modifier if modifier else ''}")
search_final = f"{search}{modifier}" search_final = f"{search}{modifier}"
if search_final in util.movie_only_searches and self.library.is_show: if search_final in plex.movie_only_searches and self.library.is_show:
raise Failed(f"Collection Error: {search_final} plex search attribute only works for movie libraries") raise Failed(f"Collection Error: {search_final} plex search attribute only works for movie libraries")
if search_final in plex.show_only_searches and self.library.is_movie:
raise Failed(f"Collection Error: {search_final} plex search attribute only works for show libraries")
elif search_data is None: elif search_data is None:
raise Failed(f"Collection Error: {search_final} plex search attribute is blank") raise Failed(f"Collection Error: {search_final} plex search attribute is blank")
elif search == "sort_by": elif search == "sort_by":
if str(search_data).lower() in util.plex_sort: if str(search_data).lower() in plex.sorts:
searches[search] = str(search_data).lower() searches[search] = str(search_data).lower()
else: else:
logger.warning(f"Collection Error: {search_data} is not a valid plex search sort defaulting to title.asc") logger.warning(f"Collection Error: {search_data} is not a valid plex search sort defaulting to title.asc")
@ -477,9 +680,9 @@ class CollectionBuilder:
elif search == "title" and modifier in ["", ".and", ".not", ".begins", ".ends"]: elif search == "title" and modifier in ["", ".and", ".not", ".begins", ".ends"]:
searches[search_final] = util.get_list(search_data, split=False) searches[search_final] = util.get_list(search_data, split=False)
elif (search == "studio" and modifier in ["", ".and", ".not", ".begins", ".ends"]) \ elif (search == "studio" and modifier in ["", ".and", ".not", ".begins", ".ends"]) \
or (search in ["actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", "producer", "subtitle_language", "writer"] and modifier in ["", ".and", ".not"]) \ or (search in ["actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", "network", "producer", "subtitle_language", "writer"] and modifier in ["", ".and", ".not"]) \
or (search == "resolution" and modifier in [""]): or (search == "resolution" and modifier in [""]):
if search_final in util.tmdb_searches: if search_final in plex.tmdb_searches:
final_values = [] final_values = []
for value in util.get_list(search_data): for value in util.get_list(search_data):
if value.lower() == "tmdb" and "tmdb_person" in self.details: if value.lower() == "tmdb" and "tmdb_person" in self.details:
@ -489,21 +692,26 @@ class CollectionBuilder:
final_values.append(value) final_values.append(value)
else: else:
final_values = search_data final_values = search_data
searches[search_final] = self.library.validate_search_list(final_values, search) valid_values = self.library.validate_search_list(final_values, search)
elif (search == "decade" and modifier in [""]) \ if valid_values:
or (search == "year" and modifier in [".greater", ".less"]): searches[search_final] = valid_values
searches[search_final] = [util.check_year(search_data, current_year, search_final)] else:
logger.warning(f"Collection Warning: No valid {search} values found in {final_values}")
elif search == "year" and modifier in [".greater", ".less"]:
searches[search_final] = util.check_year(search_data, current_year, search_final)
elif search in ["added", "originally_available"] and modifier in [".before", ".after"]: elif search in ["added", "originally_available"] and modifier in [".before", ".after"]:
searches[search_final] = [util.check_date(search_data, search_final, return_string=True, plex_date=True)] searches[search_final] = util.check_date(search_data, search_final, return_string=True, plex_date=True)
elif search in ["duration", "rating"] and modifier in [".greater", ".less"]: elif (search in ["added", "originally_available"] and modifier in ["", ".not"]) or (search in ["duration"] and modifier in [".greater", ".less"]):
searches[search_final] = [util.check_number(search_data, search_final, minimum=0)] searches[search_final] = util.check_number(search_data, search_final, minimum=1)
elif search == "year" and modifier in ["", ".not"]: elif search in ["critic_rating", "audience_rating"] and modifier in [".greater", ".less"]:
searches[search_final] = util.check_number(search_data, search_final, number_type="float", minimum=0, maximum=10)
elif (search == "decade" and modifier in [""]) or (search == "year" and modifier in ["", ".not"]):
searches[search_final] = util.get_year_list(search_data, current_year, search_final) searches[search_final] = util.get_year_list(search_data, current_year, search_final)
elif (search in ["title", "studio"] and modifier not in ["", ".and", ".not", ".begins", ".ends"]) \ elif (search in ["title", "studio"] and modifier not in ["", ".and", ".not", ".begins", ".ends"]) \
or (search in ["actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", "producer", "subtitle_language", "writer"] and modifier not in ["", ".and", ".not"]) \ or (search in ["actor", "audio_language", "collection", "content_rating", "country", "director", "genre", "label", "network", "producer", "subtitle_language", "writer"] and modifier not in ["", ".and", ".not"]) \
or (search in ["resolution", "decade"] and modifier not in [""]) \ or (search in ["resolution", "decade"] and modifier not in [""]) \
or (search in ["added", "originally_available"] and modifier not in [".before", ".after"]) \ or (search in ["added", "originally_available"] and modifier not in ["", ".not", ".before", ".after"]) \
or (search in ["duration", "rating"] and modifier not in [".greater", ".less"]) \ or (search in ["duration", "critic_rating", "audience_rating"] and modifier not in [".greater", ".less"]) \
or (search in ["year"] and modifier not in ["", ".not", ".greater", ".less"]): or (search in ["year"] and modifier not in ["", ".not", ".greater", ".less"]):
raise Failed(f"Collection Error: modifier: {modifier} not supported with the {search} plex search attribute") raise Failed(f"Collection Error: modifier: {modifier} not supported with the {search} plex search attribute")
else: else:
@ -514,7 +722,7 @@ class CollectionBuilder:
for discover_name, discover_data in method_data.items(): for discover_name, discover_data in method_data.items():
discover_final = discover_name.lower() discover_final = discover_name.lower()
if discover_data: if discover_data:
if (self.library.is_movie and discover_final in util.discover_movie) or (self.library.is_show and discover_final in util.discover_tv): if (self.library.is_movie and discover_final in tmdb.discover_movie) or (self.library.is_show and discover_final in tmdb.discover_tv):
if discover_final == "language": if discover_final == "language":
if re.compile("([a-z]{2})-([A-Z]{2})").match(str(discover_data)): if re.compile("([a-z]{2})-([A-Z]{2})").match(str(discover_data)):
new_dictionary[discover_final] = str(discover_data) new_dictionary[discover_final] = str(discover_data)
@ -526,7 +734,7 @@ class CollectionBuilder:
else: else:
raise Failed(f"Collection Error: {method_name} attribute {discover_final}: {discover_data} must match pattern ^[A-Z]{{2}}$ e.g. US") raise Failed(f"Collection Error: {method_name} attribute {discover_final}: {discover_data} must match pattern ^[A-Z]{{2}}$ e.g. US")
elif discover_final == "sort_by": elif discover_final == "sort_by":
if (self.library.is_movie and discover_data in util.discover_movie_sort) or (self.library.is_show and discover_data in util.discover_tv_sort): if (self.library.is_movie and discover_data in tmdb.discover_movie_sort) or (self.library.is_show and discover_data in tmdb.discover_tv_sort):
new_dictionary[discover_final] = discover_data new_dictionary[discover_final] = discover_data
else: else:
raise Failed(f"Collection Error: {method_name} attribute {discover_final}: {discover_data} is invalid") raise Failed(f"Collection Error: {method_name} attribute {discover_final}: {discover_data} is invalid")
@ -543,7 +751,7 @@ class CollectionBuilder:
elif discover_final in ["include_adult", "include_null_first_air_dates", "screened_theatrically"]: elif discover_final in ["include_adult", "include_null_first_air_dates", "screened_theatrically"]:
if discover_data is True: if discover_data is True:
new_dictionary[discover_final] = discover_data new_dictionary[discover_final] = discover_data
elif discover_final in util.discover_dates: elif discover_final in tmdb.discover_dates:
new_dictionary[discover_final] = util.check_date(discover_data, f"{method_name} attribute {discover_final}", return_string=True) new_dictionary[discover_final] = util.check_date(discover_data, f"{method_name} attribute {discover_final}", return_string=True)
elif discover_final in ["primary_release_year", "year", "first_air_date_year"]: elif discover_final in ["primary_release_year", "year", "first_air_date_year"]:
new_dictionary[discover_final] = util.check_number(discover_data, f"{method_name} attribute {discover_final}", minimum=1800, maximum=current_year + 1) new_dictionary[discover_final] = util.check_number(discover_data, f"{method_name} attribute {discover_final}", minimum=1800, maximum=current_year + 1)
@ -586,10 +794,10 @@ class CollectionBuilder:
logger.warning("Collection Warning: mal_season sort_by attribute not found using members as default") logger.warning("Collection Warning: mal_season sort_by attribute not found using members as default")
elif not method_data[dict_methods["sort_by"]]: elif not method_data[dict_methods["sort_by"]]:
logger.warning("Collection Warning: mal_season sort_by attribute is blank using members as default") logger.warning("Collection Warning: mal_season sort_by attribute is blank using members as default")
elif method_data[dict_methods["sort_by"]] not in util.mal_season_sort: elif method_data[dict_methods["sort_by"]] not in mal.season_sort:
logger.warning(f"Collection Warning: mal_season sort_by attribute {method_data[dict_methods['sort_by']]} invalid must be either 'members' or 'score' using members as default") logger.warning(f"Collection Warning: mal_season sort_by attribute {method_data[dict_methods['sort_by']]} invalid must be either 'members' or 'score' using members as default")
else: else:
new_dictionary["sort_by"] = util.mal_season_sort[method_data[dict_methods["sort_by"]]] new_dictionary["sort_by"] = mal.season_sort[method_data[dict_methods["sort_by"]]]
if current_time.month in [1, 2, 3]: new_dictionary["season"] = "winter" if current_time.month in [1, 2, 3]: new_dictionary["season"] = "winter"
elif current_time.month in [4, 5, 6]: new_dictionary["season"] = "spring" elif current_time.month in [4, 5, 6]: new_dictionary["season"] = "spring"
@ -622,19 +830,19 @@ class CollectionBuilder:
logger.warning("Collection Warning: mal_season status attribute not found using all as default") logger.warning("Collection Warning: mal_season status attribute not found using all as default")
elif not method_data[dict_methods["status"]]: elif not method_data[dict_methods["status"]]:
logger.warning("Collection Warning: mal_season status attribute is blank using all as default") logger.warning("Collection Warning: mal_season status attribute is blank using all as default")
elif method_data[dict_methods["status"]] not in util.mal_userlist_status: elif method_data[dict_methods["status"]] not in mal.userlist_status:
logger.warning(f"Collection Warning: mal_season status attribute {method_data[dict_methods['status']]} invalid must be either 'all', 'watching', 'completed', 'on_hold', 'dropped' or 'plan_to_watch' using all as default") logger.warning(f"Collection Warning: mal_season status attribute {method_data[dict_methods['status']]} invalid must be either 'all', 'watching', 'completed', 'on_hold', 'dropped' or 'plan_to_watch' using all as default")
else: else:
new_dictionary["status"] = util.mal_userlist_status[method_data[dict_methods["status"]]] new_dictionary["status"] = mal.userlist_status[method_data[dict_methods["status"]]]
if "sort_by" not in dict_methods: if "sort_by" not in dict_methods:
logger.warning("Collection Warning: mal_season sort_by attribute not found using score as default") logger.warning("Collection Warning: mal_season sort_by attribute not found using score as default")
elif not method_data[dict_methods["sort_by"]]: elif not method_data[dict_methods["sort_by"]]:
logger.warning("Collection Warning: mal_season sort_by attribute is blank using score as default") logger.warning("Collection Warning: mal_season sort_by attribute is blank using score as default")
elif method_data[dict_methods["sort_by"]] not in util.mal_userlist_sort: elif method_data[dict_methods["sort_by"]] not in mal.userlist_sort:
logger.warning(f"Collection Warning: mal_season sort_by attribute {method_data[dict_methods['sort_by']]} invalid must be either 'score', 'last_updated', 'title' or 'start_date' using score as default") logger.warning(f"Collection Warning: mal_season sort_by attribute {method_data[dict_methods['sort_by']]} invalid must be either 'score', 'last_updated', 'title' or 'start_date' using score as default")
else: else:
new_dictionary["sort_by"] = util.mal_userlist_sort[method_data[dict_methods["sort_by"]]] new_dictionary["sort_by"] = mal.userlist_sort[method_data[dict_methods["sort_by"]]]
new_dictionary["limit"] = get_int(method_name, "limit", method_data, dict_methods, 100, maximum=1000) new_dictionary["limit"] = get_int(method_name, "limit", method_data, dict_methods, 100, maximum=1000)
self.methods.append((method_name, [new_dictionary])) self.methods.append((method_name, [new_dictionary]))
@ -686,7 +894,7 @@ class CollectionBuilder:
self.methods.append((method_name, [new_dictionary])) self.methods.append((method_name, [new_dictionary]))
else: else:
raise Failed(f"Collection Error: {method_name} attribute is not a dictionary: {method_data}") raise Failed(f"Collection Error: {method_name} attribute is not a dictionary: {method_data}")
elif method_name in util.count_lists: elif method_name in numbered_builders:
list_count = util.regex_first_int(method_data, "List Size", default=10) list_count = util.regex_first_int(method_data, "List Size", default=10)
if list_count < 1: if list_count < 1:
logger.warning(f"Collection Warning: {method_name} must be an integer greater then 0 defaulting to 10") logger.warning(f"Collection Warning: {method_name} must be an integer greater then 0 defaulting to 10")
@ -716,8 +924,8 @@ class CollectionBuilder:
self.methods.append((method_name[:-8], values)) self.methods.append((method_name[:-8], values))
else: else:
self.methods.append((method_name, values)) self.methods.append((method_name, values))
elif method_name in util.tmdb_lists: elif method_name in tmdb.builders:
values = config.TMDb.validate_tmdb_list(util.get_int_list(method_data, f"TMDb {util.tmdb_type[method_name]} ID"), util.tmdb_type[method_name]) values = config.TMDb.validate_tmdb_list(util.get_int_list(method_data, f"TMDb {tmdb.type_map[method_name]} ID"), tmdb.type_map[method_name])
if method_name[-8:] == "_details": if method_name[-8:] == "_details":
if method_name in ["tmdb_collection_details", "tmdb_movie_details", "tmdb_show_details"]: if method_name in ["tmdb_collection_details", "tmdb_movie_details", "tmdb_show_details"]:
item = config.TMDb.get_movie_show_or_collection(values[0], self.library.is_movie) item = config.TMDb.get_movie_show_or_collection(values[0], self.library.is_movie)
@ -740,14 +948,14 @@ class CollectionBuilder:
self.methods.append((method_name[:-8], values)) self.methods.append((method_name[:-8], values))
else: else:
self.methods.append((method_name, values)) self.methods.append((method_name, values))
elif method_name in util.all_lists: elif method_name in all_builders:
self.methods.append((method_name, util.get_list(method_data))) self.methods.append((method_name, util.get_list(method_data)))
elif method_name not in util.other_attributes: elif method_name not in ignored_details:
raise Failed(f"Collection Error: {method_name} attribute not supported") raise Failed(f"Collection Error: {method_name} attribute not supported")
elif method_name in util.all_lists or method_name in util.method_alias or method_name in util.plex_searches: elif method_key.lower() in all_builders or method_key.lower() in method_alias or method_key.lower() in plex.searches:
raise Failed(f"Collection Error: {method_name} attribute is blank") raise Failed(f"Collection Error: {method_key} attribute is blank")
else: else:
logger.warning(f"Collection Warning: {method_name} attribute is blank") logger.warning(f"Collection Warning: {method_key} attribute is blank")
self.sync = self.library.sync_mode == "sync" self.sync = self.library.sync_mode == "sync"
if "sync_mode" in methods: if "sync_mode" in methods:
@ -758,14 +966,14 @@ class CollectionBuilder:
else: else:
self.sync = self.data[methods["sync_mode"]].lower() == "sync" self.sync = self.data[methods["sync_mode"]].lower() == "sync"
self.do_arr = False if self.add_to_radarr is None:
if self.library.Radarr: self.add_to_radarr = self.library.Radarr.add if self.library.Radarr else False
self.do_arr = self.details["add_to_arr"] if "add_to_arr" in self.details else self.library.Radarr.add if self.add_to_sonarr is None:
if self.library.Sonarr: self.add_to_sonarr = self.library.Sonarr.add if self.library.Sonarr else False
self.do_arr = self.details["add_to_arr"] if "add_to_arr" in self.details else self.library.Sonarr.add
if self.collectionless: if self.collectionless:
self.details["add_to_arr"] = False self.add_to_radarr = False
self.add_to_sonarr = False
self.details["collection_mode"] = "hide" self.details["collection_mode"] = "hide"
self.sync = True self.sync = True
@ -775,7 +983,6 @@ class CollectionBuilder:
logger.debug("") logger.debug("")
logger.debug(f"Method: {method}") logger.debug(f"Method: {method}")
logger.debug(f"Values: {values}") logger.debug(f"Values: {values}")
pretty = util.pretty_names[method] if method in util.pretty_names else method
for value in values: for value in values:
items = [] items = []
missing_movies = [] missing_movies = []
@ -786,8 +993,10 @@ class CollectionBuilder:
if len(movie_ids) > 0: if len(movie_ids) > 0:
items_found_inside += len(movie_ids) items_found_inside += len(movie_ids)
for movie_id in movie_ids: for movie_id in movie_ids:
if movie_id in movie_map: items.append(movie_map[movie_id]) if movie_id in movie_map:
else: missing_movies.append(movie_id) items.append(movie_map[movie_id])
else:
missing_movies.append(movie_id)
if len(show_ids) > 0: if len(show_ids) > 0:
items_found_inside += len(show_ids) items_found_inside += len(show_ids)
for show_id in show_ids: for show_id in show_ids:
@ -796,70 +1005,9 @@ class CollectionBuilder:
return items_found_inside return items_found_inside
logger.info("") logger.info("")
logger.debug(f"Value: {value}") logger.debug(f"Value: {value}")
if method == "plex_all": if "plex" in method:
logger.info(f"Processing {pretty} {'Movies' if self.library.is_movie else 'Shows'}") items = self.library.get_items(method, value)
items = self.library.Plex.all()
items_found += len(items)
elif method == "plex_collection":
items = value.items()
items_found += len(items)
elif method == "plex_search":
search_terms = {}
has_processed = False
search_limit = None
search_sort = None
for search_method, search_data in value.items():
if search_method == "limit":
search_limit = search_data
elif search_method == "sort_by":
search_sort = util.plex_sort[search_data]
else:
search, modifier = os.path.splitext(str(search_method).lower())
final_search = util.search_alias[search] if search in util.search_alias else search
final_mod = util.plex_modifiers[modifier] if modifier in util.plex_modifiers else ""
final_method = f"{final_search}{final_mod}"
search_terms[final_method] = search_data * 60000 if final_search == "duration" else search_data
ors = ""
conjunction = " AND " if final_mod == "&" else " OR "
for o, param in enumerate(search_data):
or_des = conjunction if o > 0 else f"{search_method}("
ors += f"{or_des}{param}"
if has_processed:
logger.info(f"\t\t AND {ors})")
else:
logger.info(f"Processing {pretty}: {ors})")
has_processed = True
items = self.library.Plex.search(sort=search_sort, maxresults=search_limit, **search_terms)
items_found += len(items)
elif method == "plex_collectionless":
good_collections = []
for col in self.library.get_all_collections():
keep_collection = True
for pre in value["exclude_prefix"]:
if col.title.startswith(pre) or (col.titleSort and col.titleSort.startswith(pre)):
keep_collection = False
break
if keep_collection:
for ext in value["exclude"]:
if col.title == ext or (col.titleSort and col.titleSort == ext):
keep_collection = False
break
if keep_collection:
good_collections.append(col.index)
all_items = self.library.Plex.all()
length = 0
for i, item in enumerate(all_items, 1):
length = util.print_return(length, f"Processing: {i}/{len(all_items)} {item.title}")
add_item = True
item.reload()
for collection in item.collections:
if collection.id in good_collections:
add_item = False
break
if add_item:
items.append(item)
items_found += len(items) items_found += len(items)
util.print_end(length, f"Processed {len(all_items)} {'Movies' if self.library.is_movie else 'Shows'}")
elif "tautulli" in method: elif "tautulli" in method:
items = self.library.Tautulli.get_items(self.library, time_range=value["list_days"], stats_count=value["list_size"], list_type=value["list_type"], stats_count_buffer=value["list_buffer"]) items = self.library.Tautulli.get_items(self.library, time_range=value["list_days"], stats_count=value["list_size"], list_type=value["list_type"], stats_count_buffer=value["list_buffer"])
items_found += len(items) items_found += len(items)
@ -907,8 +1055,11 @@ class CollectionBuilder:
logger.info(f"{len(missing_movies_with_names)} Movie{'s' if len(missing_movies_with_names) > 1 else ''} Missing") logger.info(f"{len(missing_movies_with_names)} Movie{'s' if len(missing_movies_with_names) > 1 else ''} Missing")
if self.details["save_missing"] is True: if self.details["save_missing"] is True:
self.library.add_missing(collection_name, missing_movies_with_names, True) self.library.add_missing(collection_name, missing_movies_with_names, True)
if self.do_arr and self.library.Radarr: if self.add_to_radarr and self.library.Radarr:
self.library.Radarr.add_tmdb([missing_id for title, missing_id in missing_movies_with_names], tag=self.details["arr_tag"]) try:
self.library.Radarr.add_tmdb([missing_id for title, missing_id in missing_movies_with_names], **self.radarr_options)
except Failed as e:
logger.error(e)
if self.run_again: if self.run_again:
self.missing_movies.extend([missing_id for title, missing_id in missing_movies_with_names]) self.missing_movies.extend([missing_id for title, missing_id in missing_movies_with_names])
if len(missing_shows) > 0 and self.library.is_show: if len(missing_shows) > 0 and self.library.is_show:
@ -936,8 +1087,11 @@ class CollectionBuilder:
logger.info(f"{len(missing_shows_with_names)} Show{'s' if len(missing_shows_with_names) > 1 else ''} Missing") logger.info(f"{len(missing_shows_with_names)} Show{'s' if len(missing_shows_with_names) > 1 else ''} Missing")
if self.details["save_missing"] is True: if self.details["save_missing"] is True:
self.library.add_missing(collection_name, missing_shows_with_names, False) self.library.add_missing(collection_name, missing_shows_with_names, False)
if self.do_arr and self.library.Sonarr: if self.add_to_sonarr and self.library.Sonarr:
self.library.Sonarr.add_tvdb([missing_id for title, missing_id in missing_shows_with_names], tag=self.details["arr_tag"]) try:
self.library.Sonarr.add_tvdb([missing_id for title, missing_id in missing_shows_with_names], **self.sonarr_options)
except Failed as e:
logger.error(e)
if self.run_again: if self.run_again:
self.missing_shows.extend([missing_id for title, missing_id in missing_shows_with_names]) self.missing_shows.extend([missing_id for title, missing_id in missing_shows_with_names])
@ -1061,15 +1215,19 @@ class CollectionBuilder:
logger.warning(f"No Folder: {os.path.join(path, folder)}") logger.warning(f"No Folder: {os.path.join(path, folder)}")
def set_image(image_method, images, is_background=False): def set_image(image_method, images, is_background=False):
if image_method in ["file_poster", "file_background", "asset_directory"]: message = f"{'background' if is_background else 'poster'} to [{'File' if image_method in image_file_details else 'URL'}] {images[image_method]}"
if is_background: collection.uploadArt(filepath=images[image_method]) try:
else: collection.uploadPoster(filepath=images[image_method]) if image_method in image_file_details and is_background:
image_location = "File" collection.uploadArt(filepath=images[image_method])
else: elif image_method in image_file_details:
if is_background: collection.uploadArt(url=images[image_method]) collection.uploadPoster(filepath=images[image_method])
else: collection.uploadPoster(url=images[image_method]) elif is_background:
image_location = "URL" collection.uploadArt(url=images[image_method])
logger.info(f"Detail: {image_method} updated collection {'background' if is_background else 'poster'} to [{image_location}] {images[image_method]}") else:
collection.uploadPoster(url=images[image_method])
logger.info(f"Detail: {image_method} updated collection {message}")
except BadRequest:
logger.error(f"Detail: {image_method} failed to update {message}")
if len(self.posters) > 1: if len(self.posters) > 1:
logger.info(f"{len(self.posters)} posters found:") logger.info(f"{len(self.posters)} posters found:")

@ -22,6 +22,33 @@ from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
sync_modes = {"append": "Only Add Items to the Collection", "sync": "Add & Remove Items from the Collection"}
radarr_versions = {"v2": "For Radarr 0.2", "v3": "For Radarr 3.0"}
radarr_availabilities = {
"announced": "For Announced",
"cinemas": "For In Cinemas",
"released": "For Released",
"db": "For PreDB"
}
sonarr_versions = {"v2": "For Sonarr 0.2", "v3": "For Sonarr 3.0"}
sonarr_monitors = {
"all": "Monitor all episodes except specials",
"future": "Monitor episodes that have not aired yet",
"missing": "Monitor episodes that do not have files or have not aired yet",
"existing": "Monitor episodes that have files or have not aired yet",
"pilot": "Monitor the first episode. All other episodes will be ignored",
"first": "Monitor all episodes of the first season. All other seasons will be ignored",
"latest": "Monitor all episodes of the latest season and future seasons",
"none": "No episodes will be monitored"
}
sonarr_series_types = {
"standard": "Episodes released with SxxEyy pattern",
"daily": "Episodes released daily or less frequently that use year-month-day (2017-05-25)",
"anime": "Episodes released using an absolute episode number"
}
mass_genre_update_options = {"tmdb": "Use TMDb Metadata", "omdb": "Use IMDb Metadata through OMDb"}
library_types = {"movie": "For Movie Libraries", "show": "For Show Libraries"}
class Config: class Config:
def __init__(self, default_dir, config_path=None): def __init__(self, default_dir, config_path=None):
logger.info("Locating config...") logger.info("Locating config...")
@ -33,7 +60,7 @@ class Config:
yaml.YAML().allow_duplicate_keys = True yaml.YAML().allow_duplicate_keys = True
try: try:
new_config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path)) new_config, ind, bsi = yaml.util.load_yaml_guess_indent(open(self.config_path, encoding="utf-8"))
def replace_attr(all_data, attr, par): def replace_attr(all_data, attr, par):
if "settings" not in all_data: if "settings" not in all_data:
all_data["settings"] = {} all_data["settings"] = {}
@ -75,12 +102,15 @@ class Config:
if "omdb" in new_config: new_config["omdb"] = new_config.pop("omdb") if "omdb" in new_config: new_config["omdb"] = new_config.pop("omdb")
if "trakt" in new_config: new_config["trakt"] = new_config.pop("trakt") if "trakt" in new_config: new_config["trakt"] = new_config.pop("trakt")
if "mal" in new_config: new_config["mal"] = new_config.pop("mal") if "mal" in new_config: new_config["mal"] = new_config.pop("mal")
yaml.round_trip_dump(new_config, open(self.config_path, "w"), indent=ind, block_seq_indent=bsi) yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=ind, block_seq_indent=bsi)
self.data = new_config self.data = new_config
except yaml.scanner.ScannerError as e: except yaml.scanner.ScannerError as e:
raise Failed(f"YAML Error: {util.tab_new_lines(e)}") raise Failed(f"YAML Error: {util.tab_new_lines(e)}")
except Exception as e:
util.print_stacktrace()
raise Failed(f"YAML Error: {e}")
def check_for_attribute(data, attribute, parent=None, test_list=None, options="", default=None, do_print=True, default_is_none=False, req_default=False, var_type="str", throw=False, save=True): def check_for_attribute(data, attribute, parent=None, test_list=None, default=None, do_print=True, default_is_none=False, req_default=False, var_type="str", throw=False, save=True):
endline = "" endline = ""
if parent is not None: if parent is not None:
if parent in data: if parent in data:
@ -122,16 +152,22 @@ class Config:
if var_type == "path" and default and os.path.exists(os.path.abspath(default)): if var_type == "path" and default and os.path.exists(os.path.abspath(default)):
return default return default
elif var_type == "path" and default: elif var_type == "path" and default:
default = None
if attribute in data and data[attribute]: if attribute in data and data[attribute]:
message = f"neither {data[attribute]} or the default path {default} could be found" message = f"neither {data[attribute]} or the default path {default} could be found"
else: else:
message = f"no {text} found and the default path {default} could be found" message = f"no {text} found and the default path {default} could not be found"
default = None
if default is not None or default_is_none: if default is not None or default_is_none:
message = message + f" using {default} as default" message = message + f" using {default} as default"
message = message + endline message = message + endline
if req_default and default is None: if req_default and default is None:
raise Failed(f"Config Error: {attribute} attribute must be set under {parent} globally or under this specific Library") raise Failed(f"Config Error: {attribute} attribute must be set under {parent} globally or under this specific Library")
options = ""
if test_list:
for option, description in test_list.items():
if len(options) > 0:
options = f"{options}\n"
options = f"{options} {option} ({description})"
if (default is None and not default_is_none) or throw: if (default is None and not default_is_none) or throw:
if len(options) > 0: if len(options) > 0:
message = message + "\n" + options message = message + "\n" + options
@ -143,7 +179,7 @@ class Config:
return default return default
self.general = {} self.general = {}
self.general["cache"] = check_for_attribute(self.data, "cache", parent="settings", options=" true (Create a cache to store ids)\n false (Do not create a cache to store ids)", var_type="bool", default=True) self.general["cache"] = check_for_attribute(self.data, "cache", parent="settings", var_type="bool", default=True)
self.general["cache_expiration"] = check_for_attribute(self.data, "cache_expiration", parent="settings", var_type="int", default=60) self.general["cache_expiration"] = check_for_attribute(self.data, "cache_expiration", parent="settings", var_type="int", default=60)
if self.general["cache"]: if self.general["cache"]:
util.separator() util.separator()
@ -151,7 +187,7 @@ class Config:
else: else:
self.Cache = None self.Cache = None
self.general["asset_directory"] = check_for_attribute(self.data, "asset_directory", parent="settings", var_type="list_path", default=[os.path.join(default_dir, "assets")]) self.general["asset_directory"] = check_for_attribute(self.data, "asset_directory", parent="settings", var_type="list_path", default=[os.path.join(default_dir, "assets")])
self.general["sync_mode"] = check_for_attribute(self.data, "sync_mode", parent="settings", default="append", test_list=["append", "sync"], options=" append (Only Add Items to the Collection)\n sync (Add & Remove Items from the Collection)") self.general["sync_mode"] = check_for_attribute(self.data, "sync_mode", parent="settings", default="append", test_list=sync_modes)
self.general["run_again_delay"] = check_for_attribute(self.data, "run_again_delay", parent="settings", var_type="int", default=0) self.general["run_again_delay"] = check_for_attribute(self.data, "run_again_delay", parent="settings", var_type="int", default=0)
self.general["show_unmanaged"] = check_for_attribute(self.data, "show_unmanaged", parent="settings", var_type="bool", default=True) self.general["show_unmanaged"] = check_for_attribute(self.data, "show_unmanaged", parent="settings", var_type="bool", default=True)
self.general["show_filtered"] = check_for_attribute(self.data, "show_filtered", parent="settings", var_type="bool", default=False) self.general["show_filtered"] = check_for_attribute(self.data, "show_filtered", parent="settings", var_type="bool", default=False)
@ -241,24 +277,30 @@ class Config:
self.general["radarr"] = {} self.general["radarr"] = {}
self.general["radarr"]["url"] = check_for_attribute(self.data, "url", parent="radarr", default_is_none=True) self.general["radarr"]["url"] = check_for_attribute(self.data, "url", parent="radarr", default_is_none=True)
self.general["radarr"]["version"] = check_for_attribute(self.data, "version", parent="radarr", test_list=["v2", "v3"], options=" v2 (For Radarr 0.2)\n v3 (For Radarr 3.0)", default="v2")
self.general["radarr"]["token"] = check_for_attribute(self.data, "token", parent="radarr", default_is_none=True) self.general["radarr"]["token"] = check_for_attribute(self.data, "token", parent="radarr", default_is_none=True)
self.general["radarr"]["quality_profile"] = check_for_attribute(self.data, "quality_profile", parent="radarr", default_is_none=True) self.general["radarr"]["version"] = check_for_attribute(self.data, "version", parent="radarr", test_list=radarr_versions, default="v3")
self.general["radarr"]["root_folder_path"] = check_for_attribute(self.data, "root_folder_path", parent="radarr", default_is_none=True)
self.general["radarr"]["add"] = check_for_attribute(self.data, "add", parent="radarr", var_type="bool", default=False) self.general["radarr"]["add"] = check_for_attribute(self.data, "add", parent="radarr", var_type="bool", default=False)
self.general["radarr"]["search"] = check_for_attribute(self.data, "search", parent="radarr", var_type="bool", default=False) self.general["radarr"]["root_folder_path"] = check_for_attribute(self.data, "root_folder_path", parent="radarr", default_is_none=True)
self.general["radarr"]["monitor"] = check_for_attribute(self.data, "monitor", parent="radarr", var_type="bool", default=True)
self.general["radarr"]["availability"] = check_for_attribute(self.data, "availability", parent="radarr", test_list=radarr_availabilities, default="announced")
self.general["radarr"]["quality_profile"] = check_for_attribute(self.data, "quality_profile", parent="radarr", default_is_none=True)
self.general["radarr"]["tag"] = check_for_attribute(self.data, "tag", parent="radarr", var_type="lower_list", default_is_none=True) self.general["radarr"]["tag"] = check_for_attribute(self.data, "tag", parent="radarr", var_type="lower_list", default_is_none=True)
self.general["radarr"]["search"] = check_for_attribute(self.data, "search", parent="radarr", var_type="bool", default=False)
self.general["sonarr"] = {} self.general["sonarr"] = {}
self.general["sonarr"]["url"] = check_for_attribute(self.data, "url", parent="sonarr", default_is_none=True) self.general["sonarr"]["url"] = check_for_attribute(self.data, "url", parent="sonarr", default_is_none=True)
self.general["sonarr"]["token"] = check_for_attribute(self.data, "token", parent="sonarr", default_is_none=True) self.general["sonarr"]["token"] = check_for_attribute(self.data, "token", parent="sonarr", default_is_none=True)
self.general["sonarr"]["version"] = check_for_attribute(self.data, "version", parent="sonarr", test_list=["v2", "v3"], options=" v2 (For Sonarr 0.2)\n v3 (For Sonarr 3.0)", default="v2") self.general["sonarr"]["version"] = check_for_attribute(self.data, "version", parent="sonarr", test_list=sonarr_versions, default="v3")
self.general["sonarr"]["quality_profile"] = check_for_attribute(self.data, "quality_profile", parent="sonarr", default_is_none=True)
self.general["sonarr"]["root_folder_path"] = check_for_attribute(self.data, "root_folder_path", parent="sonarr", default_is_none=True)
self.general["sonarr"]["add"] = check_for_attribute(self.data, "add", parent="sonarr", var_type="bool", default=False) self.general["sonarr"]["add"] = check_for_attribute(self.data, "add", parent="sonarr", var_type="bool", default=False)
self.general["sonarr"]["search"] = check_for_attribute(self.data, "search", parent="sonarr", var_type="bool", default=False) self.general["sonarr"]["root_folder_path"] = check_for_attribute(self.data, "root_folder_path", parent="sonarr", default_is_none=True)
self.general["sonarr"]["monitor"] = check_for_attribute(self.data, "monitor", parent="sonarr", test_list=sonarr_monitors, default="all")
self.general["sonarr"]["quality_profile"] = check_for_attribute(self.data, "quality_profile", parent="sonarr", default_is_none=True)
self.general["sonarr"]["language_profile"] = check_for_attribute(self.data, "language_profile", parent="sonarr", default_is_none=True)
self.general["sonarr"]["series_type"] = check_for_attribute(self.data, "series_type", parent="sonarr", test_list=sonarr_series_types, default="standard")
self.general["sonarr"]["season_folder"] = check_for_attribute(self.data, "season_folder", parent="sonarr", var_type="bool", default=True) self.general["sonarr"]["season_folder"] = check_for_attribute(self.data, "season_folder", parent="sonarr", var_type="bool", default=True)
self.general["sonarr"]["tag"] = check_for_attribute(self.data, "tag", parent="sonarr", var_type="lower_list", default_is_none=True) self.general["sonarr"]["tag"] = check_for_attribute(self.data, "tag", parent="sonarr", var_type="lower_list", default_is_none=True)
self.general["sonarr"]["search"] = check_for_attribute(self.data, "search", parent="sonarr", var_type="bool", default=False)
self.general["sonarr"]["cutoff_search"] = check_for_attribute(self.data, "cutoff_search", parent="sonarr", var_type="bool", default=False)
self.general["tautulli"] = {} self.general["tautulli"] = {}
self.general["tautulli"]["url"] = check_for_attribute(self.data, "url", parent="tautulli", default_is_none=True) self.general["tautulli"]["url"] = check_for_attribute(self.data, "url", parent="tautulli", default_is_none=True)
@ -282,9 +324,9 @@ class Config:
logger.warning("Config Warning: Assets will not be used asset_directory attribute must be set under config or under this specific Library") logger.warning("Config Warning: Assets will not be used asset_directory attribute must be set under config or under this specific Library")
if "settings" in lib and lib["settings"] and "sync_mode" in lib["settings"]: if "settings" in lib and lib["settings"] and "sync_mode" in lib["settings"]:
params["sync_mode"] = check_for_attribute(lib, "sync_mode", parent="settings", test_list=["append", "sync"], options=" append (Only Add Items to the Collection)\n sync (Add & Remove Items from the Collection)", default=self.general["sync_mode"], do_print=False, save=False) params["sync_mode"] = check_for_attribute(lib, "sync_mode", parent="settings", test_list=sync_modes, default=self.general["sync_mode"], do_print=False, save=False)
else: else:
params["sync_mode"] = check_for_attribute(lib, "sync_mode", test_list=["append", "sync"], options=" append (Only Add Items to the Collection)\n sync (Add & Remove Items from the Collection)", default=self.general["sync_mode"], do_print=False, save=False) params["sync_mode"] = check_for_attribute(lib, "sync_mode", test_list=sync_modes, default=self.general["sync_mode"], do_print=False, save=False)
if "settings" in lib and lib["settings"] and "show_unmanaged" in lib["settings"]: if "settings" in lib and lib["settings"] and "show_unmanaged" in lib["settings"]:
params["show_unmanaged"] = check_for_attribute(lib, "show_unmanaged", parent="settings", var_type="bool", default=self.general["show_unmanaged"], do_print=False, save=False) params["show_unmanaged"] = check_for_attribute(lib, "show_unmanaged", parent="settings", var_type="bool", default=self.general["show_unmanaged"], do_print=False, save=False)
@ -307,7 +349,7 @@ class Config:
params["save_missing"] = check_for_attribute(lib, "save_missing", var_type="bool", default=self.general["save_missing"], do_print=False, save=False) params["save_missing"] = check_for_attribute(lib, "save_missing", var_type="bool", default=self.general["save_missing"], do_print=False, save=False)
if "mass_genre_update" in lib and lib["mass_genre_update"]: if "mass_genre_update" in lib and lib["mass_genre_update"]:
params["mass_genre_update"] = check_for_attribute(lib, "mass_genre_update", test_list=["tmdb", "omdb"], options=" tmdb (Use TMDb Metadata)\n omdb (Use IMDb Metadata through OMDb)", default_is_none=True, save=False) params["mass_genre_update"] = check_for_attribute(lib, "mass_genre_update", test_list=mass_genre_update_options, default_is_none=True, save=False)
else: else:
params["mass_genre_update"] = None params["mass_genre_update"] = None
@ -317,7 +359,7 @@ class Config:
try: try:
params["metadata_path"] = check_for_attribute(lib, "metadata_path", var_type="path", default=os.path.join(default_dir, f"{library_name}.yml"), throw=True) params["metadata_path"] = check_for_attribute(lib, "metadata_path", var_type="path", default=os.path.join(default_dir, f"{library_name}.yml"), throw=True)
params["library_type"] = check_for_attribute(lib, "library_type", test_list=["movie", "show"], options=" movie (For Movie Libraries)\n show (For Show Libraries)", throw=True) params["library_type"] = check_for_attribute(lib, "library_type", test_list=library_types, throw=True)
params["plex"] = {} params["plex"] = {}
params["plex"]["url"] = check_for_attribute(lib, "url", parent="plex", default=self.general["plex"]["url"], req_default=True, save=False) params["plex"]["url"] = check_for_attribute(lib, "url", parent="plex", default=self.general["plex"]["url"], req_default=True, save=False)
params["plex"]["token"] = check_for_attribute(lib, "token", parent="plex", default=self.general["plex"]["token"], req_default=True, save=False) params["plex"]["token"] = check_for_attribute(lib, "token", parent="plex", default=self.general["plex"]["token"], req_default=True, save=False)
@ -335,13 +377,15 @@ class Config:
try: try:
radarr_params["url"] = check_for_attribute(lib, "url", parent="radarr", default=self.general["radarr"]["url"], req_default=True, save=False) radarr_params["url"] = check_for_attribute(lib, "url", parent="radarr", default=self.general["radarr"]["url"], req_default=True, save=False)
radarr_params["token"] = check_for_attribute(lib, "token", parent="radarr", default=self.general["radarr"]["token"], req_default=True, save=False) radarr_params["token"] = check_for_attribute(lib, "token", parent="radarr", default=self.general["radarr"]["token"], req_default=True, save=False)
radarr_params["version"] = check_for_attribute(lib, "version", parent="radarr", test_list=["v2", "v3"], options=" v2 (For Radarr 0.2)\n v3 (For Radarr 3.0)", default=self.general["radarr"]["version"], save=False) radarr_params["version"] = check_for_attribute(lib, "version", parent="radarr", test_list=radarr_versions, default=self.general["radarr"]["version"], save=False)
radarr_params["quality_profile"] = check_for_attribute(lib, "quality_profile", parent="radarr", default=self.general["radarr"]["quality_profile"], req_default=True, save=False)
radarr_params["root_folder_path"] = check_for_attribute(lib, "root_folder_path", parent="radarr", default=self.general["radarr"]["root_folder_path"], req_default=True, save=False)
radarr_params["add"] = check_for_attribute(lib, "add", parent="radarr", var_type="bool", default=self.general["radarr"]["add"], save=False) radarr_params["add"] = check_for_attribute(lib, "add", parent="radarr", var_type="bool", default=self.general["radarr"]["add"], save=False)
radarr_params["search"] = check_for_attribute(lib, "search", parent="radarr", var_type="bool", default=self.general["radarr"]["search"], save=False) radarr_params["root_folder_path"] = check_for_attribute(lib, "root_folder_path", parent="radarr", default=self.general["radarr"]["root_folder_path"], req_default=True, save=False)
radarr_params["monitor"] = check_for_attribute(lib, "monitor", parent="radarr", var_type="bool", default=self.general["radarr"]["monitor"], save=False)
radarr_params["availability"] = check_for_attribute(lib, "availability", parent="radarr", test_list=radarr_availabilities, default=self.general["radarr"]["availability"], save=False)
radarr_params["quality_profile"] = check_for_attribute(lib, "quality_profile", parent="radarr", default=self.general["radarr"]["quality_profile"], req_default=True, save=False)
radarr_params["tag"] = check_for_attribute(lib, "search", parent="radarr", var_type="lower_list", default=self.general["radarr"]["tag"], default_is_none=True, save=False) radarr_params["tag"] = check_for_attribute(lib, "search", parent="radarr", var_type="lower_list", default=self.general["radarr"]["tag"], default_is_none=True, save=False)
library.Radarr = RadarrAPI(self.TMDb, radarr_params) radarr_params["search"] = check_for_attribute(lib, "search", parent="radarr", var_type="bool", default=self.general["radarr"]["search"], save=False)
library.Radarr = RadarrAPI(radarr_params)
except Failed as e: except Failed as e:
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.info(f"{params['name']} library's Radarr Connection {'Failed' if library.Radarr is None else 'Successful'}") logger.info(f"{params['name']} library's Radarr Connection {'Failed' if library.Radarr is None else 'Successful'}")
@ -352,14 +396,21 @@ class Config:
try: try:
sonarr_params["url"] = check_for_attribute(lib, "url", parent="sonarr", default=self.general["sonarr"]["url"], req_default=True, save=False) sonarr_params["url"] = check_for_attribute(lib, "url", parent="sonarr", default=self.general["sonarr"]["url"], req_default=True, save=False)
sonarr_params["token"] = check_for_attribute(lib, "token", parent="sonarr", default=self.general["sonarr"]["token"], req_default=True, save=False) sonarr_params["token"] = check_for_attribute(lib, "token", parent="sonarr", default=self.general["sonarr"]["token"], req_default=True, save=False)
sonarr_params["version"] = check_for_attribute(lib, "version", parent="sonarr", test_list=["v2", "v3"], options=" v2 (For Sonarr 0.2)\n v3 (For Sonarr 3.0)", default=self.general["sonarr"]["version"], save=False) sonarr_params["version"] = check_for_attribute(lib, "version", parent="sonarr", test_list=sonarr_versions, default=self.general["sonarr"]["version"], save=False)
sonarr_params["quality_profile"] = check_for_attribute(lib, "quality_profile", parent="sonarr", default=self.general["sonarr"]["quality_profile"], req_default=True, save=False)
sonarr_params["root_folder_path"] = check_for_attribute(lib, "root_folder_path", parent="sonarr", default=self.general["sonarr"]["root_folder_path"], req_default=True, save=False)
sonarr_params["add"] = check_for_attribute(lib, "add", parent="sonarr", var_type="bool", default=self.general["sonarr"]["add"], save=False) sonarr_params["add"] = check_for_attribute(lib, "add", parent="sonarr", var_type="bool", default=self.general["sonarr"]["add"], save=False)
sonarr_params["search"] = check_for_attribute(lib, "search", parent="sonarr", var_type="bool", default=self.general["sonarr"]["search"], save=False) sonarr_params["root_folder_path"] = check_for_attribute(lib, "root_folder_path", parent="sonarr", default=self.general["sonarr"]["root_folder_path"], req_default=True, save=False)
sonarr_params["monitor"] = check_for_attribute(lib, "monitor", parent="sonarr", test_list=sonarr_monitors, default=self.general["sonarr"]["monitor"], save=False)
sonarr_params["quality_profile"] = check_for_attribute(lib, "quality_profile", parent="sonarr", default=self.general["sonarr"]["quality_profile"], req_default=True, save=False)
if self.general["sonarr"]["language_profile"]:
sonarr_params["language_profile"] = check_for_attribute(lib, "language_profile", parent="sonarr", default=self.general["sonarr"]["language_profile"], save=False)
else:
sonarr_params["language_profile"] = check_for_attribute(lib, "language_profile", parent="sonarr", default_is_none=True, save=False)
sonarr_params["series_type"] = check_for_attribute(lib, "series_type", parent="sonarr", test_list=sonarr_series_types, default=self.general["sonarr"]["series_type"], save=False)
sonarr_params["season_folder"] = check_for_attribute(lib, "season_folder", parent="sonarr", var_type="bool", default=self.general["sonarr"]["season_folder"], save=False) sonarr_params["season_folder"] = check_for_attribute(lib, "season_folder", parent="sonarr", var_type="bool", default=self.general["sonarr"]["season_folder"], save=False)
sonarr_params["tag"] = check_for_attribute(lib, "search", parent="sonarr", var_type="lower_list", default=self.general["sonarr"]["tag"], default_is_none=True, save=False) sonarr_params["tag"] = check_for_attribute(lib, "search", parent="sonarr", var_type="lower_list", default=self.general["sonarr"]["tag"], default_is_none=True, save=False)
library.Sonarr = SonarrAPI(self.TVDb, sonarr_params, library.Plex.language) sonarr_params["search"] = check_for_attribute(lib, "search", parent="sonarr", var_type="bool", default=self.general["sonarr"]["search"], save=False)
sonarr_params["cutoff_search"] = check_for_attribute(lib, "cutoff_search", parent="sonarr", var_type="bool", default=self.general["sonarr"]["cutoff_search"], save=False)
library.Sonarr = SonarrAPI(sonarr_params, library.Plex.language)
except Failed as e: except Failed as e:
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.info(f"{params['name']} library's Sonarr Connection {'Failed' if library.Sonarr is None else 'Successful'}") logger.info(f"{params['name']} library's Sonarr Connection {'Failed' if library.Sonarr is None else 'Successful'}")
@ -382,11 +433,11 @@ class Config:
if len(self.libraries) > 0: if len(self.libraries) > 0:
logger.info(f"{len(self.libraries)} Plex Library Connection{'s' if len(self.libraries) > 1 else ''} Successful") logger.info(f"{len(self.libraries)} Plex Library Connection{'s' if len(self.libraries) > 1 else ''} Successful")
else: else:
raise Failed("Plex Error: No Plex libraries were found") raise Failed("Plex Error: No Plex libraries were connected to")
util.separator() util.separator()
def update_libraries(self, test, requested_collections): def update_libraries(self, test, requested_collections, resume_from):
for library in self.libraries: for library in self.libraries:
os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout)
logger.info("") logger.info("")
@ -395,7 +446,7 @@ class Config:
util.separator(f"Mapping {library.name} Library") util.separator(f"Mapping {library.name} Library")
logger.info("") logger.info("")
movie_map, show_map = self.map_guids(library) movie_map, show_map = self.map_guids(library)
if not test: if not test and not resume_from:
if library.mass_genre_update: if library.mass_genre_update:
self.mass_metadata(library, movie_map, show_map) self.mass_metadata(library, movie_map, show_map)
try: library.update_metadata(self.TMDb, test) try: library.update_metadata(self.TMDb, test)
@ -403,6 +454,9 @@ class Config:
logger.info("") logger.info("")
util.separator(f"{library.name} Library {'Test ' if test else ''}Collections") util.separator(f"{library.name} Library {'Test ' if test else ''}Collections")
collections = {c: library.collections[c] for c in util.get_list(requested_collections) if c in library.collections} if requested_collections else library.collections collections = {c: library.collections[c] for c in util.get_list(requested_collections) if c in library.collections} if requested_collections else library.collections
if resume_from and resume_from not in collections:
logger.warning(f"Collection: {resume_from} not in {library.name}")
continue
if collections: if collections:
for mapping_name, collection_attrs in collections.items(): for mapping_name, collection_attrs in collections.items():
if test and ("test" not in collection_attrs or collection_attrs["test"] is not True): if test and ("test" not in collection_attrs or collection_attrs["test"] is not True):
@ -420,6 +474,13 @@ class Config:
if no_template_test: if no_template_test:
continue continue
try: try:
if resume_from and resume_from != mapping_name:
continue
elif resume_from == mapping_name:
resume_from = None
logger.info("")
util.separator(f"Resuming Collections")
logger.info("") logger.info("")
util.separator(f"{mapping_name} Collection") util.separator(f"{mapping_name} Collection")
logger.info("") logger.info("")

@ -6,6 +6,8 @@ from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = ["imdb_list", "imdb_id"]
class IMDbAPI: class IMDbAPI:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
@ -14,11 +16,13 @@ class IMDbAPI:
"search": "https://www.imdb.com/search/title/?" "search": "https://www.imdb.com/search/title/?"
} }
def get_imdb_ids_from_url(self, imdb_url, language, limit): def validate_imdb_url(self, imdb_url):
imdb_url = imdb_url.strip() imdb_url = imdb_url.strip()
if not imdb_url.startswith(self.urls["list"]) and not imdb_url.startswith(self.urls["search"]): if not imdb_url.startswith(self.urls["list"]) and not imdb_url.startswith(self.urls["search"]):
raise Failed(f"IMDb Error: {imdb_url} must begin with either:\n| {self.urls['list']} (For Lists)\n| {self.urls['search']} (For Searches)") raise Failed(f"IMDb Error: {imdb_url} must begin with either:\n{self.urls['list']} (For Lists)\n{self.urls['search']} (For Searches)")
return imdb_url
def get_imdb_ids_from_url(self, imdb_url, language, limit):
if imdb_url.startswith(self.urls["list"]): if imdb_url.startswith(self.urls["list"]):
try: list_id = re.search("(\\d+)", str(imdb_url)).group(1) try: list_id = re.search("(\\d+)", str(imdb_url)).group(1)
except AttributeError: raise Failed(f"IMDb Error: Failed to parse List ID from {imdb_url}") except AttributeError: raise Failed(f"IMDb Error: Failed to parse List ID from {imdb_url}")

@ -6,6 +6,8 @@ from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = ["letterboxd_list", "letterboxd_list_details"]
class LetterboxdAPI: class LetterboxdAPI:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config

@ -6,6 +6,73 @@ from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = [
"mal_id",
"mal_all",
"mal_airing",
"mal_upcoming",
"mal_tv",
"mal_ova",
"mal_movie",
"mal_special",
"mal_popular",
"mal_favorite",
"mal_season",
"mal_suggested",
"mal_userlist"
]
mal_ranked_name = {
"mal_all": "all",
"mal_airing": "airing",
"mal_upcoming": "upcoming",
"mal_tv": "tv",
"mal_ova": "ova",
"mal_movie": "movie",
"mal_special": "special",
"mal_popular": "bypopularity",
"mal_favorite": "favorite"
}
season_sort = {
"anime_score": "anime_score",
"anime_num_list_users": "anime_num_list_users",
"score": "anime_score",
"members": "anime_num_list_users"
}
pretty_names = {
"anime_score": "Score",
"anime_num_list_users": "Members",
"list_score": "Score",
"list_updated_at": "Last Updated",
"anime_title": "Title",
"anime_start_date": "Start Date",
"all": "All Anime",
"watching": "Currently Watching",
"completed": "Completed",
"on_hold": "On Hold",
"dropped": "Dropped",
"plan_to_watch": "Plan to Watch"
}
userlist_sort = {
"score": "list_score",
"list_score": "list_score",
"last_updated": "list_updated_at",
"list_updated": "list_updated_at",
"list_updated_at": "list_updated_at",
"title": "anime_title",
"anime_title": "anime_title",
"start_date": "anime_start_date",
"anime_start_date": "anime_start_date"
}
userlist_status = [
"all",
"watching",
"completed",
"on_hold",
"dropped",
"plan_to_watch"
]
class MyAnimeListIDList: class MyAnimeListIDList:
def __init__(self): def __init__(self):
self.ids = json.loads(requests.get("https://raw.githubusercontent.com/Fribb/anime-lists/master/animeMapping_full.json").content) self.ids = json.loads(requests.get("https://raw.githubusercontent.com/Fribb/anime-lists/master/animeMapping_full.json").content)
@ -155,14 +222,14 @@ class MyAnimeListAPI:
mal_ids = [data] mal_ids = [data]
if status_message: if status_message:
logger.info(f"Processing {pretty}: {data}") logger.info(f"Processing {pretty}: {data}")
elif method in util.mal_ranked_name: elif method in mal_ranked_name:
mal_ids = self.get_ranked(util.mal_ranked_name[method], data) mal_ids = self.get_ranked(mal_ranked_name[method], data)
if status_message: if status_message:
logger.info(f"Processing {pretty}: {data} Anime") logger.info(f"Processing {pretty}: {data} Anime")
elif method == "mal_season": elif method == "mal_season":
mal_ids = self.get_season(data["season"], data["year"], data["sort_by"], data["limit"]) mal_ids = self.get_season(data["season"], data["year"], data["sort_by"], data["limit"])
if status_message: if status_message:
logger.info(f"Processing {pretty}: {data['limit']} Anime from {util.pretty_seasons[data['season']]} {data['year']} sorted by {util.mal_pretty[data['sort_by']]}") logger.info(f"Processing {pretty}: {data['limit']} Anime from {util.pretty_seasons[data['season']]} {data['year']} sorted by {pretty_names[data['sort_by']]}")
elif method == "mal_suggested": elif method == "mal_suggested":
mal_ids = self.get_suggestions(data) mal_ids = self.get_suggestions(data)
if status_message: if status_message:
@ -170,7 +237,7 @@ class MyAnimeListAPI:
elif method == "mal_userlist": elif method == "mal_userlist":
mal_ids = self.get_userlist(data["username"], data["status"], data["sort_by"], data["limit"]) mal_ids = self.get_userlist(data["username"], data["status"], data["sort_by"], data["limit"])
if status_message: if status_message:
logger.info(f"Processing {pretty}: {data['limit']} Anime from {self.get_username() if data['username'] == '@me' else data['username']}'s {util.mal_pretty[data['status']]} list sorted by {util.mal_pretty[data['sort_by']]}") logger.info(f"Processing {pretty}: {data['limit']} Anime from {self.get_username() if data['username'] == '@me' else data['username']}'s {pretty_names[data['status']]} list sorted by {pretty_names[data['sort_by']]}")
else: else:
raise Failed(f"MyAnimeList Error: Method {method} not supported") raise Failed(f"MyAnimeList Error: Method {method} not supported")
show_ids = [] show_ids = []

@ -12,6 +12,100 @@ from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = ["plex_all", "plex_collection", "plex_collectionless", "plex_search",]
search_translation = {
"audio_language": "audioLanguage",
"content_rating": "contentRating",
"subtitle_language": "subtitleLanguage",
"added": "addedAt",
"originally_available": "originallyAvailableAt",
"audience_rating": "audienceRating",
"critic_rating": "rating"
}
episode_sorting_options = {"default": "-1", "oldest": "0", "newest": "1"}
keep_episodes_options = {"all": 0, "5_latest": 5, "3_latest": 3, "latest": 1, "past_3": -3, "past_7": -7, "past_30": -30}
delete_episodes_options = {"never": 0, "day": 1, "week": 7, "refresh": 100}
season_display_options = {"default": -1, "show": 0, "hide": 1}
episode_ordering_options = {"default": None, "tmdb_aired": "tmdbAiring", "tvdb_aired": "airing", "tvdb_dvd": "dvd", "tvdb_absolute": "absolute"}
plex_languages = ["default", "ar-SA", "ca-ES", "cs-CZ", "da-DK", "de-DE", "el-GR", "en-AU", "en-CA", "en-GB", "en-US",
"es-ES", "es-MX", "et-EE", "fa-IR", "fi-FI", "fr-CA", "fr-FR", "he-IL", "hi-IN", "hu-HU", "id-ID",
"it-IT", "ja-JP", "ko-KR", "lt-LT", "lv-LV", "nb-NO", "nl-NL", "pl-PL", "pt-BR", "pt-PT", "ro-RO",
"ru-RU", "sk-SK", "sv-SE", "th-TH", "tr-TR", "uk-UA", "vi-VN", "zh-CN", "zh-HK", "zh-TW"]
metadata_language_options = {lang.lower(): lang for lang in plex_languages}
metadata_language_options["default"] = None
filter_alias = {
"actor": "actors",
"audience_rating": "audienceRating",
"collection": "collections",
"content_rating": "contentRating",
"country": "countries",
"critic_rating": "rating",
"director": "directors",
"genre": "genres",
"originally_available": "originallyAvailableAt",
"tmdb_vote_count": "vote_count",
"writer": "writers"
}
searches = [
"title", "title.and", "title.not", "title.begins", "title.ends",
"studio", "studio.and", "studio.not", "studio.begins", "studio.ends",
"actor", "actor.and", "actor.not",
"audio_language", "audio_language.and", "audio_language.not",
"collection", "collection.and", "collection.not",
"content_rating", "content_rating.and", "content_rating.not",
"country", "country.and", "country.not",
"director", "director.and", "director.not",
"genre", "genre.and", "genre.not",
"label", "label.and", "label.not",
"network", "network.and", "network.not",
"producer", "producer.and", "producer.not",
"subtitle_language", "subtitle_language.and", "subtitle_language.not",
"writer", "writer.and", "writer.not",
"decade", "resolution",
"added.before", "added.after",
"originally_available.before", "originally_available.after",
"duration.greater", "duration.less",
"audience_rating.greater", "audience_rating.less",
"critic_rating.greater", "critic_rating.less",
"year", "year.not", "year.greater", "year.less"
]
movie_only_searches = [
"audio_language", "audio_language.and", "audio_language.not",
"country", "country.and", "country.not",
"subtitle_language", "subtitle_language.and", "subtitle_language.not",
"decade", "resolution",
"originally_available.before", "originally_available.after",
"duration.greater", "duration.less"
]
show_only_searches = [
"network", "network.and", "network.not",
]
tmdb_searches = [
"actor", "actor.and", "actor.not",
"director", "director.and", "director.not",
"producer", "producer.and", "producer.not",
"writer", "writer.and", "writer.not"
]
sorts = {
None: None,
"title.asc": "titleSort:asc", "title.desc": "titleSort:desc",
"originally_available.asc": "originallyAvailableAt:asc", "originally_available.desc": "originallyAvailableAt:desc",
"critic_rating.asc": "rating:asc", "critic_rating.desc": "rating:desc",
"audience_rating.asc": "audienceRating:asc", "audience_rating.desc": "audienceRating:desc",
"duration.asc": "duration:asc", "duration.desc": "duration:desc",
"added.asc": "addedAt:asc", "added.desc": "addedAt:desc"
}
modifiers = {
".and": "&",
".not": "!",
".begins": "<",
".ends": ">",
".before": "<<",
".after": ">>",
".greater": ">>",
".less": "<<"
}
class PlexAPI: class PlexAPI:
def __init__(self, params, TMDb, TVDb): def __init__(self, params, TMDb, TVDb):
try: try:
@ -28,10 +122,15 @@ class PlexAPI:
self.Plex = next((s for s in self.PlexServer.library.sections() if s.title == params["name"] and ((self.is_movie and isinstance(s, MovieSection)) or (self.is_show and isinstance(s, ShowSection)))), None) self.Plex = next((s for s in self.PlexServer.library.sections() if s.title == params["name"] and ((self.is_movie and isinstance(s, MovieSection)) or (self.is_show and isinstance(s, ShowSection)))), None)
if not self.Plex: if not self.Plex:
raise Failed(f"Plex Error: Plex Library {params['name']} not found") raise Failed(f"Plex Error: Plex Library {params['name']} not found")
logger.info(f"Using Metadata File: {params['metadata_path']}")
try: try:
self.data, ind, bsi = yaml.util.load_yaml_guess_indent(open(params["metadata_path"], encoding="utf-8")) self.data, ind, bsi = yaml.util.load_yaml_guess_indent(open(params["metadata_path"], encoding="utf-8"))
except yaml.scanner.ScannerError as e: except yaml.scanner.ScannerError as ye:
raise Failed(f"YAML Error: {util.tab_new_lines(e)}") raise Failed(f"YAML Error: {util.tab_new_lines(ye)}")
except Exception as e:
util.print_stacktrace()
raise Failed(f"YAML Error: {e}")
def get_dict(attribute): def get_dict(attribute):
if attribute in self.data: if attribute in self.data:
@ -91,18 +190,21 @@ class PlexAPI:
return self.PlexServer.search(data) return self.PlexServer.search(data)
def get_search_choices(self, search_name, key=False): def get_search_choices(self, search_name, key=False):
try:
if key: return {c.key.lower(): c.key for c in self.Plex.listFilterChoices(search_name)} if key: return {c.key.lower(): c.key for c in self.Plex.listFilterChoices(search_name)}
else: return {c.title.lower(): c.title for c in self.Plex.listFilterChoices(search_name)} else: return {c.title.lower(): c.title for c in self.Plex.listFilterChoices(search_name)}
except NotFound:
raise Failed(f"Collection Error: plex search attribute: {search_name} only supported with Plex's New TV Agent")
def validate_search_list(self, data, search_name): def validate_search_list(self, data, search_name):
final_search = util.search_alias[search_name] if search_name in util.search_alias else search_name final_search = search_translation[search_name] if search_name in search_translation else search_name
search_choices = self.get_search_choices(final_search, key=final_search.endswith("Language")) search_choices = self.get_search_choices(final_search, key=final_search.endswith("Language"))
valid_list = [] valid_list = []
for value in util.get_list(data): for value in util.get_list(data):
if str(value).lower() in search_choices: if str(value).lower() in search_choices:
valid_list.append(search_choices[str(value).lower()]) valid_list.append(search_choices[str(value).lower()])
else: else:
raise Failed(f"Plex Error: {search_name}: {value} not found") logger.error(f"Plex Error: {search_name}: {value} not found")
return valid_list return valid_list
def get_all_collections(self): def get_all_collections(self):
@ -122,6 +224,108 @@ class PlexAPI:
raise Failed(f"Collection Error: No valid Plex Collections in {collections}") raise Failed(f"Collection Error: No valid Plex Collections in {collections}")
return valid_collections return valid_collections
def get_items(self, method, data, status_message=True):
if status_message:
logger.debug(f"Data: {data}")
pretty = util.pretty_names[method] if method in util.pretty_names else method
media_type = "Movie" if self.is_movie else "Show"
items = []
if method == "plex_all":
if status_message:
logger.info(f"Processing {pretty} {media_type}s")
items = self.Plex.all()
elif method == "plex_collection":
if status_message:
logger.info(f"Processing {pretty} {data}")
items = data.items()
elif method == "plex_search":
search_terms = {}
has_processed = False
search_limit = None
search_sort = None
for search_method, search_data in data.items():
if search_method == "limit":
search_limit = search_data
elif search_method == "sort_by":
search_sort = search_data
else:
search, modifier = os.path.splitext(str(search_method).lower())
final_search = search_translation[search] if search in search_translation else search
if search == "originally_available" and modifier == "":
final_mod = ">>"
elif search == "originally_available" and modifier == ".not":
final_mod = "<<"
elif search in ["critic_rating", "audience_rating"] and modifier == ".greater":
final_mod = "__gte"
elif search in ["critic_rating", "audience_rating"] and modifier == ".less":
final_mod = "__lt"
else:
final_mod = modifiers[modifier] if modifier in modifiers else ""
final_method = f"{final_search}{final_mod}"
if search == "duration":
search_terms[final_method] = search_data * 60000
elif search in ["added", "originally_available"] and modifier in ["", ".not"]:
search_terms[final_method] = f"{search_data}d"
else:
search_terms[final_method] = search_data
if status_message:
if search in ["added", "originally_available"] or modifier in [".greater", ".less", ".before", ".after"]:
ors = f"{search_method}({search_data}"
else:
ors = ""
conjunction = " AND " if final_mod == "&" else " OR "
for o, param in enumerate(search_data):
or_des = conjunction if o > 0 else f"{search_method}("
ors += f"{or_des}{param}"
if has_processed:
logger.info(f"\t\t AND {ors})")
else:
logger.info(f"Processing {pretty}: {ors})")
has_processed = True
if status_message:
if search_sort:
logger.info(f"\t\t SORT BY {search_sort})")
if search_limit:
logger.info(f"\t\t LIMIT {search_limit})")
logger.debug(f"Search: {search_terms}")
return self.Plex.search(sort=sorts[search_sort], maxresults=search_limit, **search_terms)
elif method == "plex_collectionless":
good_collections = []
for col in self.get_all_collections():
keep_collection = True
for pre in data["exclude_prefix"]:
if col.title.startswith(pre) or (col.titleSort and col.titleSort.startswith(pre)):
keep_collection = False
break
if keep_collection:
for ext in data["exclude"]:
if col.title == ext or (col.titleSort and col.titleSort == ext):
keep_collection = False
break
if keep_collection:
good_collections.append(col.index)
all_items = self.Plex.all()
length = 0
for i, item in enumerate(all_items, 1):
length = util.print_return(length, f"Processing: {i}/{len(all_items)} {item.title}")
add_item = True
item.reload()
for collection in item.collections:
if collection.id in good_collections:
add_item = False
break
if add_item:
items.append(item)
util.print_end(length, f"Processed {len(all_items)} {'Movies' if self.is_movie else 'Shows'}")
else:
raise Failed(f"Plex Error: Method {method} not supported")
if len(items) > 0:
return items
else:
raise Failed("Plex Error: No Items found in Plex")
def add_missing(self, collection, items, is_movie): def add_missing(self, collection, items, is_movie):
col_name = collection.encode("ascii", "replace").decode() col_name = collection.encode("ascii", "replace").decode()
if col_name not in self.missing: if col_name not in self.missing:
@ -157,7 +361,7 @@ class PlexAPI:
for filter_method, filter_data in filters: for filter_method, filter_data in filters:
modifier = filter_method[-4:] modifier = filter_method[-4:]
method = filter_method[:-4] if modifier in [".not", ".lte", ".gte"] else filter_method method = filter_method[:-4] if modifier in [".not", ".lte", ".gte"] else filter_method
method_name = util.filter_alias[method] if method in util.filter_alias else method method_name = filter_alias[method] if method in filter_alias else method
if method_name == "max_age": if method_name == "max_age":
threshold_date = datetime.now() - timedelta(days=filter_data) threshold_date = datetime.now() - timedelta(days=filter_data)
if current.originallyAvailableAt is None or current.originallyAvailableAt < threshold_date: if current.originallyAvailableAt is None or current.originallyAvailableAt < threshold_date:
@ -210,7 +414,7 @@ class PlexAPI:
attr = tmdb_item.vote_count attr = tmdb_item.vote_count
else: else:
attr = getattr(current, method_name) / 60000 if method_name == "duration" else getattr(current, method_name) attr = getattr(current, method_name) / 60000 if method_name == "duration" else getattr(current, method_name)
if (modifier == ".lte" and attr > filter_data) or (modifier == ".gte" and attr < filter_data): if attr is None or (modifier == ".lte" and attr > filter_data) or (modifier == ".gte" and attr < filter_data):
match = False match = False
break break
else: else:
@ -306,29 +510,34 @@ class PlexAPI:
tagline = tmdb_item.tagline if tmdb_item and len(tmdb_item.tagline) > 0 else None tagline = tmdb_item.tagline if tmdb_item and len(tmdb_item.tagline) > 0 else None
summary = tmdb_item.overview if tmdb_item else None summary = tmdb_item.overview if tmdb_item else None
details_updated = False updated = False
advance_details_updated = False
genre_updated = False
label_updated = False
season_updated = False
episode_updated = False
edits = {} edits = {}
def add_edit(name, current, group, alias, key=None, value=None): def add_edit(name, current, group, alias, key=None, value=None, var_type="str"):
if value or name in alias: if value or name in alias:
if value or group[alias[name]]: if value or group[alias[name]]:
if key is None: key = name if key is None: key = name
if value is None: value = group[alias[name]] if value is None: value = group[alias[name]]
if str(current) != str(value): try:
edits[f"{key}.value"] = value if var_type == "date":
final_value = util.check_date(value, name, return_string=True, plex_date=True)
elif var_type == "float":
final_value = util.check_number(value, name, number_type="float", minimum=0, maximum=10)
else:
final_value = value
if str(current) != str(final_value):
edits[f"{key}.value"] = final_value
edits[f"{key}.locked"] = 1 edits[f"{key}.locked"] = 1
logger.info(f"Detail: {name} updated to {value}") logger.info(f"Detail: {name} updated to {final_value}")
except Failed as ee:
logger.error(ee)
else: else:
logger.error(f"Metadata Error: {name} attribute is blank") logger.error(f"Metadata Error: {name} attribute is blank")
add_edit("title", item.title, meta, methods, value=title) add_edit("title", item.title, meta, methods, value=title)
add_edit("sort_title", item.titleSort, meta, methods, key="titleSort") add_edit("sort_title", item.titleSort, meta, methods, key="titleSort")
add_edit("originally_available", str(item.originallyAvailableAt)[:-9], meta, methods, key="originallyAvailableAt", value=originally_available) add_edit("originally_available", str(item.originallyAvailableAt)[:-9], meta, methods, key="originallyAvailableAt", value=originally_available, var_type="date")
add_edit("rating", item.rating, meta, methods, value=rating) add_edit("critic_rating", item.rating, meta, methods, value=rating, key="rating", var_type="float")
add_edit("audience_rating", item.audienceRating, meta, methods, key="audienceRating", var_type="float")
add_edit("content_rating", item.contentRating, meta, methods, key="contentRating") add_edit("content_rating", item.contentRating, meta, methods, key="contentRating")
add_edit("original_title", item.originalTitle, meta, methods, key="originalTitle", value=original_title) add_edit("original_title", item.originalTitle, meta, methods, key="originalTitle", value=original_title)
add_edit("studio", item.studio, meta, methods, value=studio) add_edit("studio", item.studio, meta, methods, value=studio)
@ -336,7 +545,7 @@ class PlexAPI:
add_edit("summary", item.summary, meta, methods, value=summary) add_edit("summary", item.summary, meta, methods, value=summary)
if len(edits) > 0: if len(edits) > 0:
logger.debug(f"Details Update: {edits}") logger.debug(f"Details Update: {edits}")
details_updated = True updated = True
try: try:
item.edit(**edits) item.edit(**edits)
item.reload() item.reload()
@ -346,143 +555,35 @@ class PlexAPI:
logger.error(f"{item_type}: {mapping_name} Details Update Failed") logger.error(f"{item_type}: {mapping_name} Details Update Failed")
advance_edits = {} advance_edits = {}
if self.is_show: def add_advanced_edit(attr, options, key=None, show_library=False):
if key is None:
if "episode_sorting" in methods: key = attr
if meta[methods["episode_sorting"]]: if attr in methods:
method_data = str(meta[methods["episode_sorting"]]).lower() if show_library and not self.is_show:
if method_data in ["default", "oldest", "newest"]: logger.error(f"Metadata Error: {attr} attribute only works for show libraries")
if method_data == "default" and item.episodeSort != "-1": elif meta[methods[attr]]:
advance_edits["episodeSort"] = "-1" method_data = str(meta[methods[attr]]).lower()
elif method_data == "oldest" and item.episodeSort != "0": if method_data in options and getattr(item, key) != options[method_data]:
advance_edits["episodeSort"] = "0" advance_edits[key] = options[method_data]
elif method_data == "newest" and item.episodeSort != "1": logger.info(f"Detail: {attr} updated to {method_data}")
advance_edits["episodeSort"] = "1" else:
if "episodeSort" in advance_edits: logger.error(f"Metadata Error: {meta[methods[attr]]} {attr} attribute invalid")
logger.info(f"Detail: episode_sorting updated to {method_data}") else:
else: logger.error(f"Metadata Error: {attr} attribute is blank")
logger.error(f"Metadata Error: {meta[methods['episode_sorting']]} episode_sorting attribute invalid")
else: add_advanced_edit("episode_sorting", episode_sorting_options, key="episodeSort", show_library=True)
logger.error(f"Metadata Error: episode_sorting attribute is blank") add_advanced_edit("keep_episodes", keep_episodes_options, key="autoDeletionItemPolicyUnwatchedLibrary", show_library=True)
add_advanced_edit("delete_episodes", delete_episodes_options, key="autoDeletionItemPolicyWatchedLibrary", show_library=True)
if "keep_episodes" in methods: add_advanced_edit("season_display", season_display_options, key="flattenSeasons", show_library=True)
if meta[methods["keep_episodes"]]: add_advanced_edit("episode_ordering", episode_ordering_options, key="showOrdering", show_library=True)
method_data = str(meta[methods["keep_episodes"]]).lower() add_advanced_edit("metadata_language", metadata_language_options, key="languageOverride")
if method_data in ["all", "5_latest", "3_latest", "latest", "past_3", "past_7", "past_30"]:
if method_data == "all" and item.autoDeletionItemPolicyUnwatchedLibrary != 0: use_original_title_options = {"default": -1, "no": 0, "yes": 1}
advance_edits["autoDeletionItemPolicyUnwatchedLibrary"] = 0 add_advanced_edit("use_original_title", use_original_title_options, key="useOriginalTitle")
elif method_data == "5_latest" and item.autoDeletionItemPolicyUnwatchedLibrary != 5:
advance_edits["autoDeletionItemPolicyUnwatchedLibrary"] = 5
elif method_data == "3_latest" and item.autoDeletionItemPolicyUnwatchedLibrary != 3:
advance_edits["autoDeletionItemPolicyUnwatchedLibrary"] = 3
elif method_data == "latest" and item.autoDeletionItemPolicyUnwatchedLibrary != 1:
advance_edits["autoDeletionItemPolicyUnwatchedLibrary"] = 1
elif method_data == "past_3" and item.autoDeletionItemPolicyUnwatchedLibrary != -3:
advance_edits["autoDeletionItemPolicyUnwatchedLibrary"] = -3
elif method_data == "past_7" and item.autoDeletionItemPolicyUnwatchedLibrary != -7:
advance_edits["autoDeletionItemPolicyUnwatchedLibrary"] = -7
elif method_data == "past_30" and item.autoDeletionItemPolicyUnwatchedLibrary != -30:
advance_edits["autoDeletionItemPolicyUnwatchedLibrary"] = -30
if "autoDeletionItemPolicyUnwatchedLibrary" in advance_edits:
logger.info(f"Detail: keep_episodes updated to {method_data}")
else:
logger.error(f"Metadata Error: {meta[methods['keep_episodes']]} keep_episodes attribute invalid")
else:
logger.error(f"Metadata Error: keep_episodes attribute is blank")
if "delete_episodes" in methods:
if meta[methods["delete_episodes"]]:
method_data = str(meta[methods["delete_episodes"]]).lower()
if method_data in ["never", "day", "week", "refresh"]:
if method_data == "never" and item.autoDeletionItemPolicyWatchedLibrary != 0:
advance_edits["autoDeletionItemPolicyWatchedLibrary"] = 0
elif method_data == "day" and item.autoDeletionItemPolicyWatchedLibrary != 1:
advance_edits["autoDeletionItemPolicyWatchedLibrary"] = 1
elif method_data == "week" and item.autoDeletionItemPolicyWatchedLibrary != 7:
advance_edits["autoDeletionItemPolicyWatchedLibrary"] = 7
elif method_data == "refresh" and item.autoDeletionItemPolicyWatchedLibrary != 100:
advance_edits["autoDeletionItemPolicyWatchedLibrary"] = 100
if "autoDeletionItemPolicyWatchedLibrary" in advance_edits:
logger.info(f"Detail: delete_episodes updated to {method_data}")
else:
logger.error(f"Metadata Error: {meta[methods['delete_episodes']]} delete_episodes attribute invalid")
else:
logger.error(f"Metadata Error: delete_episodes attribute is blank")
if "season_display" in methods:
if meta[methods["season_display"]]:
method_data = str(meta[methods["season_display"]]).lower()
if method_data in ["default", "hide", "show"]:
if method_data == "default" and item.flattenSeasons != -1:
advance_edits["flattenSeasons"] = -1
elif method_data == "show" and item.flattenSeasons != 0:
advance_edits["flattenSeasons"] = 0
elif method_data == "hide" and item.flattenSeasons != 1:
advance_edits["flattenSeasons"] = 1
if "flattenSeasons" in advance_edits:
logger.info(f"Detail: season_display updated to {method_data}")
else:
logger.error(f"Metadata Error: {meta[methods['season_display']]} season_display attribute invalid")
else:
logger.error(f"Metadata Error: season_display attribute is blank")
if "episode_ordering" in methods:
if meta[methods["episode_ordering"]]:
method_data = str(meta[methods["episode_ordering"]]).lower()
if method_data in ["default", "tmdb_aired", "tvdb_aired", "tvdb_dvd", "tvdb_absolute"]:
if method_data == "default" and item.showOrdering is not None:
advance_edits["showOrdering"] = None
elif method_data == "tmdb_aired" and item.showOrdering != "tmdbAiring":
advance_edits["showOrdering"] = "tmdbAiring"
elif method_data == "tvdb_aired" and item.showOrdering != "airing":
advance_edits["showOrdering"] = "airing"
elif method_data == "tvdb_dvd" and item.showOrdering != "dvd":
advance_edits["showOrdering"] = "dvd"
elif method_data == "tvdb_absolute" and item.showOrdering != "absolute":
advance_edits["showOrdering"] = "absolute"
if "showOrdering" in advance_edits:
logger.info(f"Detail: episode_ordering updated to {method_data}")
else:
logger.error(f"Metadata Error: {meta[methods['episode_ordering']]} episode_ordering attribute invalid")
else:
logger.error(f"Metadata Error: episode_ordering attribute is blank")
if "metadata_language" in methods:
if meta[methods["metadata_language"]]:
method_data = str(meta[methods["metadata_language"]]).lower()
lower_languages = {la.lower(): la for la in util.plex_languages}
if method_data in lower_languages:
if method_data == "default" and item.languageOverride is None:
advance_edits["languageOverride"] = None
elif str(item.languageOverride).lower() != lower_languages[method_data]:
advance_edits["languageOverride"] = lower_languages[method_data]
if "languageOverride" in advance_edits:
logger.info(f"Detail: metadata_language updated to {method_data}")
else:
logger.error(f"Metadata Error: {meta[methods['metadata_language']]} metadata_language attribute invalid")
else:
logger.error(f"Metadata Error: metadata_language attribute is blank")
if "use_original_title" in methods:
if meta[methods["use_original_title"]]:
method_data = str(meta[methods["use_original_title"]]).lower()
if method_data in ["default", "no", "yes"]:
if method_data == "default" and item.useOriginalTitle != -1:
advance_edits["useOriginalTitle"] = -1
elif method_data == "no" and item.useOriginalTitle != 0:
advance_edits["useOriginalTitle"] = 0
elif method_data == "yes" and item.useOriginalTitle != 1:
advance_edits["useOriginalTitle"] = 1
if "useOriginalTitle" in advance_edits:
logger.info(f"Detail: use_original_title updated to {method_data}")
else:
logger.error(f"Metadata Error: {meta[methods['use_original_title']]} use_original_title attribute invalid")
else:
logger.error(f"Metadata Error: use_original_title attribute is blank")
if len(advance_edits) > 0: if len(advance_edits) > 0:
logger.debug(f"Details Update: {advance_edits}") logger.debug(f"Details Update: {advance_edits}")
advance_details_updated = True updated = True
try: try:
check_dict = {pref.id: list(pref.enumValues.keys()) for pref in item.preferences()} check_dict = {pref.id: list(pref.enumValues.keys()) for pref in item.preferences()}
logger.info(check_dict) logger.info(check_dict)
@ -493,51 +594,44 @@ class PlexAPI:
util.print_stacktrace() util.print_stacktrace()
logger.error(f"{item_type}: {mapping_name} Advanced Details Update Failed") logger.error(f"{item_type}: {mapping_name} Advanced Details Update Failed")
genres = [] def edit_tags(attr, obj, key=None, extra=None, movie_library=False):
if tmdb_item: if key is None:
genres.extend([genre.name for genre in tmdb_item.genres]) key = f"{attr}s"
if "genre" in methods: if attr in methods and f"{attr}.sync" in methods:
if meta[methods["genre"]]: logger.error(f"Metadata Error: Cannot use {attr} and {attr}.sync together")
genres.extend(util.get_list(meta[methods["genre"]])) elif attr in methods or f"{attr}.sync" in methods:
else: attr_key = attr if attr in methods else f"{attr}.sync"
logger.error("Metadata Error: genre attribute is blank") if movie_library and not self.is_movie:
if len(genres) > 0: logger.error(f"Metadata Error: {attr_key} attribute only works for movie libraries")
item_genres = [genre.tag for genre in item.genres] elif meta[methods[attr_key]] or extra:
if "genre_sync_mode" in methods: item_tags = [item_tag.tag for item_tag in getattr(obj, key)]
if meta[methods["genre_sync_mode"]] is None: input_tags = []
logger.error("Metadata Error: genre_sync_mode attribute is blank defaulting to append") if meta[methods[attr_key]]:
elif str(meta[methods["genre_sync_mode"]]).lower() not in ["append", "sync"]: input_tags.extend(util.get_list(meta[methods[attr_key]]))
logger.error("Metadata Error: genre_sync_mode attribute must be either 'append' or 'sync' defaulting to append") if extra:
elif str(meta["genre_sync_mode"]).lower() == "sync": input_tags.extend(extra)
for genre in (g for g in item_genres if g not in genres): if f"{attr}.sync" in methods:
genre_updated = True remove_method = getattr(obj, f"remove{attr.capitalize()}")
item.removeGenre(genre) for tag in (t for t in item_tags if t not in input_tags):
logger.info(f"Detail: Genre {genre} removed") updated = True
for genre in (g for g in genres if g not in item_genres): remove_method(tag)
genre_updated = True logger.info(f"Detail: {attr.capitalize()} {tag} removed")
item.addGenre(genre) add_method = getattr(obj, f"add{attr.capitalize()}")
logger.info(f"Detail: Genre {genre} added") for tag in (t for t in input_tags if t not in item_tags):
updated = True
if "label" in methods: add_method(tag)
if meta[methods["label"]]: logger.info(f"Detail: {attr.capitalize()} {tag} added")
item_labels = [label.tag for label in item.labels] else:
labels = util.get_list(meta[methods["label"]]) logger.error(f"Metadata Error: {attr} attribute is blank")
if "label_sync_mode" in methods:
if meta[methods["label_sync_mode"]] is None: genres = [genre.name for genre in tmdb_item.genres] if tmdb_item else []
logger.error("Metadata Error: label_sync_mode attribute is blank defaulting to append") edit_tags("genre", item, extra=genres)
elif str(meta[methods["label_sync_mode"]]).lower() not in ["append", "sync"]: edit_tags("label", item)
logger.error("Metadata Error: label_sync_mode attribute must be either 'append' or 'sync' defaulting to append") edit_tags("collection", item)
elif str(meta[methods["label_sync_mode"]]).lower() == "sync": edit_tags("country", item, key="countries", movie_library=True)
for label in (la for la in item_labels if la not in labels): edit_tags("director", item, movie_library=True)
label_updated = True edit_tags("producer", item, movie_library=True)
item.removeLabel(label) edit_tags("writer", item, movie_library=True)
logger.info(f"Detail: Label {label} removed")
for label in (la for la in labels if la not in item_labels):
label_updated = True
item.addLabel(label)
logger.info(f"Detail: Label {label} added")
else:
logger.error("Metadata Error: label attribute is blank")
if "seasons" in methods and self.is_show: if "seasons" in methods and self.is_show:
if meta[methods["seasons"]]: if meta[methods["seasons"]]:
@ -570,7 +664,7 @@ class PlexAPI:
add_edit("summary", season.summary, season_methods, season_dict) add_edit("summary", season.summary, season_methods, season_dict)
if len(edits) > 0: if len(edits) > 0:
logger.debug(f"Season: {season_id} Details Update: {edits}") logger.debug(f"Season: {season_id} Details Update: {edits}")
season_updated = True updated = True
try: try:
season.edit(**edits) season.edit(**edits)
season.reload() season.reload()
@ -582,6 +676,8 @@ class PlexAPI:
logger.error(f"Metadata Error: Season: {season_id} invalid, it must be an integer") logger.error(f"Metadata Error: Season: {season_id} invalid, it must be an integer")
else: else:
logger.error("Metadata Error: seasons attribute is blank") logger.error("Metadata Error: seasons attribute is blank")
elif "seasons" in methods:
logger.error("Metadata Error: seasons attribute only works for show libraries")
if "episodes" in methods and self.is_show: if "episodes" in methods and self.is_show:
if meta[methods["episodes"]]: if meta[methods["episodes"]]:
@ -620,7 +716,7 @@ class PlexAPI:
add_edit("summary", episode.summary, episode_dict, episode_methods) add_edit("summary", episode.summary, episode_dict, episode_methods)
if len(edits) > 0: if len(edits) > 0:
logger.debug(f"Season: {season_id} Episode: {episode_id} Details Update: {edits}") logger.debug(f"Season: {season_id} Episode: {episode_id} Details Update: {edits}")
episode_updated = True updated = True
try: try:
episode.edit(**edits) episode.edit(**edits)
episode.reload() episode.reload()
@ -629,10 +725,15 @@ class PlexAPI:
except BadRequest: except BadRequest:
util.print_stacktrace() util.print_stacktrace()
logger.error(f"Season: {season_id} Episode: {episode_id} Details Update Failed") logger.error(f"Season: {season_id} Episode: {episode_id} Details Update Failed")
edit_tags("director", episode)
edit_tags("writer", episode)
else: else:
logger.error(f"Metadata Error: episode {episode_str} invalid must have S##E## format") logger.error(f"Metadata Error: episode {episode_str} invalid must have S##E## format")
else: else:
logger.error("Metadata Error: episodes attribute is blank") logger.error("Metadata Error: episodes attribute is blank")
elif "episodes" in methods:
logger.error("Metadata Error: episodes attribute only works for show libraries")
if not details_updated and not advance_details_updated and not genre_updated and not label_updated and not season_updated and not episode_updated: if not updated:
logger.info(f"{item_type}: {mapping_name} Details Update Not Needed") logger.info(f"{item_type}: {mapping_name} Details Update Not Needed")

@ -1,108 +1,130 @@
import logging, re, requests import logging, requests
from modules import util from modules import util
from modules.util import Failed from modules.util import Failed
from retrying import retry from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
availability_translation = {
"announced": "announced",
"cinemas": "inCinemas",
"released": "released",
"db": "preDB"
}
class RadarrAPI: class RadarrAPI:
def __init__(self, tmdb, params): def __init__(self, params):
self.url_params = {"apikey": f"{params['token']}"} self.url = params["url"]
self.base_url = f"{params['url']}/api{'/v3' if params['version'] == 'v3' else ''}/" self.token = params["token"]
self.version = params["version"]
self.base_url = f"{self.url}/api{'/v3' if self.version == 'v3' else ''}/"
try: try:
result = requests.get(f"{self.base_url}system/status", params=self.url_params).json() result = requests.get(f"{self.base_url}system/status", params={"apikey": f"{self.token}"}).json()
except Exception: except Exception:
util.print_stacktrace() util.print_stacktrace()
raise Failed(f"Radarr Error: Could not connect to Radarr at {params['url']}") raise Failed(f"Radarr Error: Could not connect to Radarr at {self.url}")
if "error" in result and result["error"] == "Unauthorized": if "error" in result and result["error"] == "Unauthorized":
raise Failed("Radarr Error: Invalid API Key") raise Failed("Radarr Error: Invalid API Key")
if "version" not in result: if "version" not in result:
raise Failed("Radarr Error: Unexpected Response Check URL") raise Failed("Radarr Error: Unexpected Response Check URL")
self.quality_profile_id = None self.add = params["add"]
self.root_folder_path = params["root_folder_path"]
self.monitor = params["monitor"]
self.availability = params["availability"]
self.quality_profile_id = self.get_profile_id(params["quality_profile"])
self.tag = params["tag"]
self.tags = self.get_tags()
self.search = params["search"]
def get_profile_id(self, profile_name):
profiles = "" profiles = ""
for profile in self.send_get(f"{self.base_url}{'qualityProfile' if params['version'] == 'v3' else 'profile'}"): for profile in self.send_get("qualityProfile" if self.version == "v3" else "profile"):
if len(profiles) > 0: if len(profiles) > 0:
profiles += ", " profiles += ", "
profiles += profile["name"] profiles += profile["name"]
if profile["name"] == params["quality_profile"]: if profile["name"] == profile_name:
self.quality_profile_id = profile["id"] return profile["id"]
if not self.quality_profile_id: raise Failed(f"Radarr Error: quality_profile: {profile_name} does not exist in radarr. Profiles available: {profiles}")
raise Failed(f"Radarr Error: quality_profile: {params['quality_profile']} does not exist in radarr. Profiles available: {profiles}")
self.tmdb = tmdb def get_tags(self):
self.url = params["url"] return {tag["label"]: tag["id"] for tag in self.send_get("tag")}
self.version = params["version"]
self.token = params["token"] def add_tags(self, tags):
self.root_folder_path = params["root_folder_path"] added = False
self.add = params["add"] for label in tags:
self.search = params["search"] if label not in self.tags:
self.tag = params["tag"] added = True
self.send_post("tag", {"label": str(label)})
if added:
self.tags = self.get_tags()
def lookup(self, tmdb_id):
results = self.send_get("movie/lookup", params={"term": f"tmdb:{tmdb_id}"})
if results:
return results[0]
else:
raise Failed(f"Sonarr Error: TMDb ID: {tmdb_id} not found")
def add_tmdb(self, tmdb_ids, tag=None): def add_tmdb(self, tmdb_ids, **options):
logger.info("") logger.info("")
logger.debug(f"TMDb IDs: {tmdb_ids}") logger.debug(f"TMDb IDs: {tmdb_ids}")
tag_nums = [] tag_nums = []
add_count = 0 add_count = 0
if tag is None: folder = options["folder"] if "folder" in options else self.root_folder_path
tag = self.tag monitor = options["monitor"] if "monitor" in options else self.monitor
if tag: availability = options["availability"] if "availability" in options else self.availability
tag_cache = {} quality_profile_id = self.get_profile_id(options["quality"]) if "quality" in options else self.quality_profile_id
for label in tag: tags = options["tag"] if "tag" in options else self.tag
self.send_post(f"{self.base_url}tag", {"label": str(label)}) search = options["search"] if "search" in options else self.search
for t in self.send_get(f"{self.base_url}tag"): if tags:
tag_cache[t["label"]] = t["id"] self.add_tags(tags)
for label in tag: tag_nums = [self.tags[label] for label in tags if label in self.tags]
if label in tag_cache:
tag_nums.append(tag_cache[label])
for tmdb_id in tmdb_ids: for tmdb_id in tmdb_ids:
try: try:
movie = self.tmdb.get_movie(tmdb_id) movie_info = self.lookup(tmdb_id)
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
continue continue
try: poster_url = None
year = movie.release_date.split("-")[0] for image in movie_info["images"]:
except AttributeError: if "coverType" in image and image["coverType"] == "poster" and "remoteUrl" in image:
logger.error(f"TMDb Error: No year for ({tmdb_id}) {movie.title}") poster_url = image["remoteUrl"]
continue
if year.isdigit() is False:
logger.error(f"TMDb Error: No release date yet for ({tmdb_id}) {movie.title}")
continue
poster = f"https://image.tmdb.org/t/p/original{movie.poster_path}"
titleslug = re.sub(r"([^\s\w]|_)+", "", f"{movie.title} {year}").replace(" ", "-").lower()
url_json = { url_json = {
"title": movie.title, "title": movie_info["title"],
f"{'qualityProfileId' if self.version == 'v3' else 'profileId'}": self.quality_profile_id, f"{'qualityProfileId' if self.version == 'v3' else 'profileId'}": quality_profile_id,
"year": int(year), "year": int(movie_info["year"]),
"tmdbid": int(tmdb_id), "tmdbid": int(tmdb_id),
"titleslug": titleslug, "titleslug": movie_info["titleSlug"],
"monitored": True, "minimumAvailability": availability_translation[availability],
"rootFolderPath": self.root_folder_path, "monitored": monitor,
"images": [{"covertype": "poster", "url": poster}], "rootFolderPath": folder,
"addOptions": {"searchForMovie": self.search} "images": [{"covertype": "poster", "url": poster_url}],
"addOptions": {"searchForMovie": search}
} }
if tag_nums: if tag_nums:
url_json["tags"] = tag_nums url_json["tags"] = tag_nums
response = self.send_post(f"{self.base_url}movie", url_json) response = self.send_post("movie", url_json)
if response.status_code < 400: if response.status_code < 400:
logger.info(f"Added to Radarr | {tmdb_id:<6} | {movie.title}") logger.info(f"Added to Radarr | {tmdb_id:<6} | {movie_info['title']}")
add_count += 1 add_count += 1
else: else:
try: try:
logger.error(f"Radarr Error: ({tmdb_id}) {movie.title}: ({response.status_code}) {response.json()[0]['errorMessage']}") logger.error(f"Radarr Error: ({tmdb_id}) {movie_info['title']}: ({response.status_code}) {response.json()[0]['errorMessage']}")
except KeyError: except KeyError:
logger.debug(url_json) logger.debug(url_json)
logger.error(f"Radarr Error: {response.json()}") logger.error(f"Radarr Error: {response.json()}")
logger.info(f"{add_count} Movie{'s' if add_count > 1 else ''} added to Radarr") logger.info(f"{add_count} Movie{'s' if add_count > 1 else ''} added to Radarr")
@retry(stop_max_attempt_number=6, wait_fixed=10000) @retry(stop_max_attempt_number=6, wait_fixed=10000)
def send_get(self, url): def send_get(self, url, params=None):
return requests.get(url, params=self.url_params).json() url_params = {"apikey": f"{self.token}"}
if params:
for param in params:
url_params[param] = params[param]
return requests.get(f"{self.base_url}{url}", params=url_params).json()
@retry(stop_max_attempt_number=6, wait_fixed=10000) @retry(stop_max_attempt_number=6, wait_fixed=10000)
def send_post(self, url, url_json): def send_post(self, url, url_json):
return requests.post(url, json=url_json, params=self.url_params) return requests.post(f"{self.base_url}{url}", json=url_json, params={"apikey": f"{self.token}"})

@ -1,101 +1,164 @@
import logging, re, requests import logging, requests
from json.decoder import JSONDecodeError
from modules import util from modules import util
from modules.util import Failed from modules.util import Failed
from retrying import retry from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
series_type = ["standard", "daily", "anime"]
monitor_translation = {
"all": "all",
"future": "future",
"missing": "missing",
"existing": "existing",
"pilot": "pilot",
"first": "firstSeason",
"latest": "latestSeason",
"none": "none"
}
class SonarrAPI: class SonarrAPI:
def __init__(self, tvdb, params, language): def __init__(self, params, language):
self.url_params = {"apikey": f"{params['token']}"} self.url = params["url"]
self.base_url = f"{params['url']}/api{'/v3/' if params['version'] == 'v3' else '/'}" self.token = params["token"]
self.version = params["version"]
self.base_url = f"{self.url}/api{'/v3/' if self.version == 'v3' else '/'}"
try: try:
result = requests.get(f"{self.base_url}system/status", params=self.url_params).json() result = requests.get(f"{self.base_url}system/status", params={"apikey": f"{self.token}"}).json()
except Exception: except Exception:
util.print_stacktrace() util.print_stacktrace()
raise Failed(f"Sonarr Error: Could not connect to Sonarr at {params['url']}") raise Failed(f"Sonarr Error: Could not connect to Sonarr at {self.url}")
if "error" in result and result["error"] == "Unauthorized": if "error" in result and result["error"] == "Unauthorized":
raise Failed("Sonarr Error: Invalid API Key") raise Failed("Sonarr Error: Invalid API Key")
if "version" not in result: if "version" not in result:
raise Failed("Sonarr Error: Unexpected Response Check URL") raise Failed("Sonarr Error: Unexpected Response Check URL")
self.quality_profile_id = None self.add = params["add"]
self.root_folder_path = params["root_folder_path"]
self.monitor = params["monitor"]
self.quality_profile_id = self.get_profile_id(params["quality_profile"], "quality_profile")
self.language_profile_id = None
if self.version == "v3" and params["language_profile"] is not None:
self.language_profile_id = self.get_profile_id(params["language_profile"], "language_profile")
if self.language_profile_id is None:
self.language_profile_id = 1
self.series_type = params["series_type"]
self.season_folder = params["season_folder"]
self.tag = params["tag"]
self.tags = self.get_tags()
self.search = params["search"]
self.cutoff_search = params["cutoff_search"]
self.language = language
def get_profile_id(self, profile_name, profile_type):
profiles = "" profiles = ""
for profile in self.send_get(f"{self.base_url}{'qualityProfile' if params['version'] == 'v3' else 'profile'}"): if profile_type == "quality_profile" and self.version == "v3":
endpoint = "qualityProfile"
elif profile_type == "language_profile":
endpoint = "languageProfile"
else:
endpoint = "profile"
for profile in self.send_get(endpoint):
if len(profiles) > 0: if len(profiles) > 0:
profiles += ", " profiles += ", "
profiles += profile["name"] profiles += profile["name"]
if profile["name"] == params["quality_profile"]: if profile["name"] == profile_name:
self.quality_profile_id = profile["id"] return profile["id"]
if not self.quality_profile_id: raise Failed(f"Sonarr Error: {profile_type}: {profile_name} does not exist in sonarr. Profiles available: {profiles}")
raise Failed(f"Sonarr Error: quality_profile: {params['quality_profile']} does not exist in sonarr. Profiles available: {profiles}")
self.tvdb = tvdb
self.language = language
self.url = params["url"]
self.version = params["version"]
self.token = params["token"]
self.root_folder_path = params["root_folder_path"]
self.add = params["add"]
self.search = params["search"]
self.season_folder = params["season_folder"]
self.tag = params["tag"]
def add_tvdb(self, tvdb_ids, tag=None): def get_tags(self):
return {tag["label"]: tag["id"] for tag in self.send_get("tag")}
def add_tags(self, tags):
added = False
for label in tags:
if label not in self.tags:
added = True
self.send_post("tag", {"label": str(label)})
if added:
self.tags = self.get_tags()
def lookup(self, tvdb_id):
results = self.send_get("series/lookup", params={"term": f"tvdb:{tvdb_id}"})
if results:
return results[0]
else:
raise Failed(f"Sonarr Error: TVDb ID: {tvdb_id} not found")
def add_tvdb(self, tvdb_ids, **options):
logger.info("") logger.info("")
logger.debug(f"TVDb IDs: {tvdb_ids}") logger.debug(f"TVDb IDs: {tvdb_ids}")
tag_nums = [] tag_nums = []
add_count = 0 add_count = 0
if tag is None: folder = options["folder"] if "folder" in options else self.root_folder_path
tag = self.tag monitor = options["monitor"] if "monitor" in options else self.monitor
if tag: quality_profile_id = self.get_profile_id(options["quality"], "quality_profile") if "quality" in options else self.quality_profile_id
tag_cache = {} language_profile_id = self.get_profile_id(options["language"], "language_profile") if "quality" in options else self.quality_profile_id
for label in tag: series = options["series"] if "series" in options else self.series_type
self.send_post(f"{self.base_url}tag", {"label": str(label)}) season = options["season"] if "season" in options else self.season_folder
for t in self.send_get(f"{self.base_url}tag"): tags = options["tag"] if "tag" in options else self.tag
tag_cache[t["label"]] = t["id"] search = options["search"] if "search" in options else self.search
for label in tag: cutoff_search = options["cutoff_search"] if "cutoff_search" in options else self.cutoff_search
if label in tag_cache: if tags:
tag_nums.append(tag_cache[label]) self.add_tags(tags)
tag_nums = [self.tags[label] for label in tags if label in self.tags]
for tvdb_id in tvdb_ids: for tvdb_id in tvdb_ids:
try: try:
show = self.tvdb.get_series(self.language, tvdb_id) show_info = self.lookup(tvdb_id)
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
continue continue
titleslug = re.sub(r"([^\s\w]|_)+", "", show.title).replace(" ", "-").lower() poster_url = None
for image in show_info["images"]:
if "coverType" in image and image["coverType"] == "poster" and "remoteUrl" in image:
poster_url = image["remoteUrl"]
url_json = { url_json = {
"title": show.title, "title": show_info["title"],
f"{'qualityProfileId' if self.version == 'v3' else 'profileId'}": self.quality_profile_id, f"{'qualityProfileId' if self.version == 'v3' else 'profileId'}": quality_profile_id,
"languageProfileId": 1, "languageProfileId": language_profile_id,
"tvdbId": int(tvdb_id), "tvdbId": int(tvdb_id),
"titleslug": titleslug, "titleslug": show_info["titleSlug"],
"language": self.language, "language": self.language,
"monitored": True, "monitored": monitor != "none",
"seasonFolder": self.season_folder, "seasonFolder": season,
"rootFolderPath": self.root_folder_path, "seriesType": series,
"rootFolderPath": folder,
"seasons": [], "seasons": [],
"images": [{"covertype": "poster", "url": show.poster_path}], "images": [{"covertype": "poster", "url": poster_url}],
"addOptions": {"searchForMissingEpisodes": self.search} "addOptions": {
"searchForMissingEpisodes": search,
"searchForCutoffUnmetEpisodes": cutoff_search,
"monitor": monitor_translation[monitor]
}
} }
if tag_nums: if tag_nums:
url_json["tags"] = tag_nums url_json["tags"] = tag_nums
response = self.send_post(f"{self.base_url}series", url_json) response = self.send_post("series", url_json)
if response.status_code < 400: if response.status_code < 400:
logger.info(f"Added to Sonarr | {tvdb_id:<6} | {show.title}") logger.info(f"Added to Sonarr | {tvdb_id:<6} | {show_info['title']}")
add_count += 1 add_count += 1
else: else:
try: try:
logger.error(f"Sonarr Error: ({tvdb_id}) {show.title}: ({response.status_code}) {response.json()[0]['errorMessage']}") logger.error(f"Sonarr Error: ({tvdb_id}) {show_info['title']}: ({response.status_code}) {response.json()[0]['errorMessage']}")
except KeyError: except KeyError:
logger.debug(url_json) logger.debug(url_json)
logger.error(f"Sonarr Error: {response.json()}") logger.error(f"Sonarr Error: {response.json()}")
except JSONDecodeError:
logger.debug(url_json)
logger.error(f"Sonarr Error: {response}")
logger.info(f"{add_count} Show{'s' if add_count > 1 else ''} added to Sonarr") logger.info(f"{add_count} Show{'s' if add_count > 1 else ''} added to Sonarr")
@retry(stop_max_attempt_number=6, wait_fixed=10000) @retry(stop_max_attempt_number=6, wait_fixed=10000)
def send_get(self, url): def send_get(self, url, params=None):
return requests.get(url, params=self.url_params).json() url_params = {"apikey": f"{self.token}"}
if params:
for param in params:
url_params[param] = params[param]
return requests.get(f"{self.base_url}{url}", params=url_params).json()
@retry(stop_max_attempt_number=6, wait_fixed=10000) @retry(stop_max_attempt_number=6, wait_fixed=10000)
def send_post(self, url, url_json): def send_post(self, url, url_json):
return requests.post(url, json=url_json, params=self.url_params) return requests.post(f"{self.base_url}{url}", json=url_json, params={"apikey": f"{self.token}"})

@ -5,6 +5,8 @@ from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = ["tautulli_popular", "tautulli_watched"]
class TautulliAPI: class TautulliAPI:
def __init__(self, params): def __init__(self, params):
try: try:

@ -7,6 +7,107 @@ from tmdbv3api.exceptions import TMDbException
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = [
"tmdb_actor",
"tmdb_actor_details",
"tmdb_collection",
"tmdb_collection_details",
"tmdb_company",
"tmdb_crew",
"tmdb_crew_details",
"tmdb_director",
"tmdb_director_details",
"tmdb_discover",
"tmdb_keyword",
"tmdb_list",
"tmdb_list_details",
"tmdb_movie",
"tmdb_movie_details",
"tmdb_network",
"tmdb_now_playing",
"tmdb_popular",
"tmdb_producer",
"tmdb_producer_details",
"tmdb_show",
"tmdb_show_details",
"tmdb_top_rated",
"tmdb_trending_daily",
"tmdb_trending_weekly",
"tmdb_writer",
"tmdb_writer_details"
]
type_map = {
"tmdb_actor": "Person",
"tmdb_actor_details": "Person",
"tmdb_collection": "Collection",
"tmdb_collection_details": "Collection",
"tmdb_company": "Company",
"tmdb_crew": "Person",
"tmdb_crew_details": "Person",
"tmdb_director": "Person",
"tmdb_director_details": "Person",
"tmdb_keyword": "Keyword",
"tmdb_list": "List",
"tmdb_list_details": "List",
"tmdb_movie": "Movie",
"tmdb_movie_details": "Movie",
"tmdb_network": "Network",
"tmdb_person": "Person",
"tmdb_producer": "Person",
"tmdb_producer_details": "Person",
"tmdb_show": "Show",
"tmdb_show_details": "Show",
"tmdb_writer": "Person",
"tmdb_writer_details": "Person"
}
discover_movie = [
"language", "with_original_language", "region", "sort_by",
"certification_country", "certification", "certification.lte", "certification.gte",
"include_adult",
"primary_release_year", "primary_release_date.gte", "primary_release_date.lte",
"release_date.gte", "release_date.lte", "year",
"vote_count.gte", "vote_count.lte",
"vote_average.gte", "vote_average.lte",
"with_cast", "with_crew", "with_people",
"with_companies",
"with_genres", "without_genres",
"with_keywords", "without_keywords",
"with_runtime.gte", "with_runtime.lte"
]
discover_tv = [
"language", "with_original_language", "timezone", "sort_by",
"air_date.gte", "air_date.lte",
"first_air_date.gte", "first_air_date.lte", "first_air_date_year",
"vote_count.gte", "vote_count.lte",
"vote_average.gte", "vote_average.lte",
"with_genres", "without_genres",
"with_keywords", "without_keywords",
"with_networks", "with_companies",
"with_runtime.gte", "with_runtime.lte",
"include_null_first_air_dates",
"screened_theatrically"
]
discover_dates = [
"primary_release_date.gte", "primary_release_date.lte",
"release_date.gte", "release_date.lte",
"air_date.gte", "air_date.lte",
"first_air_date.gte", "first_air_date.lte"
]
discover_movie_sort = [
"popularity.asc", "popularity.desc",
"release_date.asc", "release_date.desc",
"revenue.asc", "revenue.desc",
"primary_release_date.asc", "primary_release_date.desc",
"original_title.asc", "original_title.desc",
"vote_average.asc", "vote_average.desc",
"vote_count.asc", "vote_count.desc"
]
discover_tv_sort = [
"vote_average.desc", "vote_average.asc",
"first_air_date.desc", "first_air_date.asc",
"popularity.desc", "popularity.asc"
]
class TMDbAPI: class TMDbAPI:
def __init__(self, params): def __init__(self, params):
self.TMDb = tmdbv3api.TMDb() self.TMDb = tmdbv3api.TMDb()
@ -156,7 +257,7 @@ class TMDbAPI:
def get_discover(self, attrs, amount, is_movie): def get_discover(self, attrs, amount, is_movie):
ids = [] ids = []
count = 0 count = 0
for date_attr in util.discover_dates: for date_attr in discover_dates:
if date_attr in attrs: if date_attr in attrs:
attrs[date_attr] = datetime.strftime(datetime.strptime(attrs[date_attr], "%m/%d/%Y"), "%Y-%m-%d") attrs[date_attr] = datetime.strftime(datetime.strptime(attrs[date_attr], "%m/%d/%Y"), "%Y-%m-%d")
self.Discover.discover_movies(attrs) if is_movie else self.Discover.discover_tv_shows(attrs) self.Discover.discover_movies(attrs) if is_movie else self.Discover.discover_tv_shows(attrs)

@ -11,6 +11,18 @@ from trakt.objects.show import Show
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = [
"trakt_collected",
"trakt_collection",
"trakt_list",
"trakt_list_details",
"trakt_popular",
"trakt_recommended",
"trakt_trending",
"trakt_watched",
"trakt_watchlist"
]
class TraktAPI: class TraktAPI:
def __init__(self, params, authorization=None): def __init__(self, params, authorization=None):
self.base_url = "https://api.trakt.tv" self.base_url = "https://api.trakt.tv"
@ -94,9 +106,15 @@ class TraktAPI:
return lookup.get_key(to_source) if to_source == "imdb" else int(lookup.get_key(to_source)) return lookup.get_key(to_source) if to_source == "imdb" else int(lookup.get_key(to_source))
raise Failed(f"No {to_source.upper().replace('B', 'b')} ID found for {from_source.upper().replace('B', 'b')} ID {external_id}") raise Failed(f"No {to_source.upper().replace('B', 'b')} ID found for {from_source.upper().replace('B', 'b')} ID {external_id}")
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) def collection(self, data, is_movie):
return self.user_list("collection", data, is_movie)
def watchlist(self, data, is_movie): def watchlist(self, data, is_movie):
items = Trakt[f"users/{data}/watchlist"].movies() if is_movie else Trakt[f"users/{data}/watchlist"].shows() return self.user_list("watchlist", data, is_movie)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed)
def user_list(self, list_type, data, is_movie):
items = Trakt[f"users/{data}/{list_type}"].movies() if is_movie else Trakt[f"users/{data}/{list_type}"].shows()
if items is None: raise Failed("Trakt Error: No List found") if items is None: raise Failed("Trakt Error: No List found")
else: return [i for i in items] else: return [i for i in items]

@ -6,6 +6,15 @@ from retrying import retry
logger = logging.getLogger("Plex Meta Manager") logger = logging.getLogger("Plex Meta Manager")
builders = [
"tvdb_list",
"tvdb_list_details",
"tvdb_movie",
"tvdb_movie_details",
"tvdb_show",
"tvdb_show_details"
]
class TVDbObj: class TVDbObj:
def __init__(self, tvdb_url, language, is_movie, TVDb): def __init__(self, tvdb_url, language, is_movie, TVDb):
tvdb_url = tvdb_url.strip() tvdb_url = tvdb_url.strip()

@ -22,38 +22,6 @@ def retry_if_not_failed(exception):
separating_character = "=" separating_character = "="
screen_width = 100 screen_width = 100
method_alias = {
"actors": "actor", "role": "actor", "roles": "actor",
"content_ratings": "content_rating", "contentRating": "content_rating", "contentRatings": "content_rating",
"countries": "country",
"decades": "decade",
"directors": "director",
"genres": "genre",
"labels": "label",
"studios": "studio", "network": "studio", "networks": "studio",
"producers": "producer",
"writers": "writer",
"years": "year"
}
search_alias = {
"audio_language": "audioLanguage",
"content_rating": "contentRating",
"subtitle_language": "subtitleLanguage",
"added": "addedAt",
"originally_available": "originallyAvailableAt",
"rating": "userRating"
}
filter_alias = {
"actor": "actors",
"collection": "collections",
"content_rating": "contentRating",
"country": "countries",
"director": "directors",
"genre": "genres",
"originally_available": "originallyAvailableAt",
"tmdb_vote_count": "vote_count",
"writer": "writers"
}
days_alias = { days_alias = {
"monday": 0, "mon": 0, "m": 0, "monday": 0, "mon": 0, "m": 0,
"tuesday": 1, "tues": 1, "tue": 1, "tu": 1, "t": 1, "tuesday": 1, "tues": 1, "tue": 1, "tu": 1, "t": 1,
@ -170,64 +138,6 @@ pretty_names = {
"tvdb_show": "TVDb Show", "tvdb_show": "TVDb Show",
"tvdb_show_details": "TVDb Show" "tvdb_show_details": "TVDb Show"
} }
plex_languages = ["default", "ar-SA", "ca-ES", "cs-CZ", "da-DK", "de-DE", "el-GR", "en-AU", "en-CA", "en-GB", "en-US", "es-ES",
"es-MX", "et-EE", "fa-IR", "fi-FI", "fr-CA", "fr-FR", "he-IL", "hi-IN", "hu-HU", "id-ID", "it-IT",
"ja-JP", "ko-KR", "lt-LT", "lv-LV", "nb-NO", "nl-NL", "pl-PL", "pt-BR", "pt-PT", "ro-RO", "ru-RU",
"sk-SK", "sv-SE", "th-TH", "tr-TR", "uk-UA", "vi-VN", "zh-CN", "zh-HK", "zh-TW"]
mal_ranked_name = {
"mal_all": "all",
"mal_airing": "airing",
"mal_upcoming": "upcoming",
"mal_tv": "tv",
"mal_ova": "ova",
"mal_movie": "movie",
"mal_special": "special",
"mal_popular": "bypopularity",
"mal_favorite": "favorite"
}
mal_season_sort = {
"anime_score": "anime_score",
"anime_num_list_users": "anime_num_list_users",
"score": "anime_score",
"members": "anime_num_list_users"
}
mal_pretty = {
"anime_score": "Score",
"anime_num_list_users": "Members",
"list_score": "Score",
"list_updated_at": "Last Updated",
"anime_title": "Title",
"anime_start_date": "Start Date",
"all": "All Anime",
"watching": "Currently Watching",
"completed": "Completed",
"on_hold": "On Hold",
"dropped": "Dropped",
"plan_to_watch": "Plan to Watch"
}
mal_userlist_sort = {
"score": "list_score",
"list_score": "list_score",
"last_updated": "list_updated_at",
"list_updated": "list_updated_at",
"list_updated_at": "list_updated_at",
"title": "anime_title",
"anime_title": "anime_title",
"start_date": "anime_start_date",
"anime_start_date": "anime_start_date"
}
mal_userlist_status = [
"all",
"watching",
"completed",
"on_hold",
"dropped",
"plan_to_watch"
]
anilist_pretty = {
"score": "Average Score",
"popular": "Popularity"
}
pretty_ids = { pretty_ids = {
"anidbid": "AniDB", "anidbid": "AniDB",
"imdbid": "IMDb", "imdbid": "IMDb",
@ -236,354 +146,6 @@ pretty_ids = {
"thetvdb_id": "TVDb", "thetvdb_id": "TVDb",
"tvdbid": "TVDb" "tvdbid": "TVDb"
} }
all_lists = [
"anidb_id",
"anidb_relation",
"anidb_popular",
"anilist_genre",
"anilist_id",
"anilist_popular",
"anilist_relations",
"anilist_season",
"anilist_studio",
"anilist_tag",
"anilist_top_rated",
"imdb_list",
"imdb_id",
"letterboxd_list",
"letterboxd_list_details",
"mal_id",
"mal_all",
"mal_airing",
"mal_upcoming",
"mal_tv",
"mal_ova",
"mal_movie",
"mal_special",
"mal_popular",
"mal_favorite",
"mal_season",
"mal_suggested",
"mal_userlist",
"plex_collection",
"plex_search",
"tautulli_popular",
"tautulli_watched",
"tmdb_actor",
"tmdb_actor_details",
"tmdb_collection",
"tmdb_collection_details",
"tmdb_company",
"tmdb_crew",
"tmdb_crew_details",
"tmdb_director",
"tmdb_director_details",
"tmdb_discover",
"tmdb_keyword",
"tmdb_list",
"tmdb_list_details",
"tmdb_movie",
"tmdb_movie_details",
"tmdb_network",
"tmdb_now_playing",
"tmdb_popular",
"tmdb_producer",
"tmdb_producer_details",
"tmdb_show",
"tmdb_show_details",
"tmdb_top_rated",
"tmdb_trending_daily",
"tmdb_trending_weekly",
"tmdb_writer",
"tmdb_writer_details",
"trakt_collected",
"trakt_collection",
"trakt_list",
"trakt_list_details",
"trakt_popular",
"trakt_recommended",
"trakt_trending",
"trakt_watched",
"trakt_watchlist",
"tvdb_list",
"tvdb_list_details",
"tvdb_movie",
"tvdb_movie_details",
"tvdb_show",
"tvdb_show_details"
]
collectionless_lists = [
"sort_title", "content_rating",
"summary", "tmdb_summary", "tmdb_description", "tmdb_biography",
"collection_order", "plex_collectionless",
"url_poster", "tmdb_poster", "tmdb_profile", "file_poster",
"url_background", "file_background",
"name_mapping", "label", "label_sync_mode", "test"
]
other_attributes = [
"run_again",
"schedule",
"sync_mode",
"template",
"test",
"tmdb_person"
]
dictionary_lists = [
"filters",
"anilist_genre",
"anilist_season",
"anilist_tag",
"mal_season",
"mal_userlist",
"plex_collectionless",
"plex_search",
"tautulli_popular",
"tautulli_watched",
"tmdb_discover"
]
show_only_lists = [
"tmdb_network",
"tmdb_show",
"tmdb_show_details",
"tvdb_show",
"tvdb_show_details"
]
movie_only_lists = [
"letterboxd_list",
"letterboxd_list_details",
"tmdb_collection",
"tmdb_collection_details",
"tmdb_movie",
"tmdb_movie_details",
"tmdb_now_playing",
"tvdb_movie",
"tvdb_movie_details"
]
count_lists = [
"anidb_popular",
"anilist_popular",
"anilist_top_rated",
"mal_all",
"mal_airing",
"mal_upcoming",
"mal_tv",
"mal_ova",
"mal_movie",
"mal_special",
"mal_popular",
"mal_favorite",
"mal_suggested",
"tmdb_popular",
"tmdb_top_rated",
"tmdb_now_playing",
"tmdb_trending_daily",
"tmdb_trending_weekly",
"trakt_trending",
"trakt_popular",
"trakt_recommended",
"trakt_watched",
"trakt_collected"
]
tmdb_lists = [
"tmdb_actor",
"tmdb_actor_details",
"tmdb_collection",
"tmdb_collection_details",
"tmdb_company",
"tmdb_crew",
"tmdb_crew_details",
"tmdb_director",
"tmdb_director_details",
"tmdb_discover",
"tmdb_keyword",
"tmdb_list",
"tmdb_list_details",
"tmdb_movie",
"tmdb_movie_details",
"tmdb_network",
"tmdb_now_playing",
"tmdb_popular",
"tmdb_producer",
"tmdb_producer_details",
"tmdb_show",
"tmdb_show_details",
"tmdb_top_rated",
"tmdb_trending_daily",
"tmdb_trending_weekly",
"tmdb_writer",
"tmdb_writer_details"
]
tmdb_type = {
"tmdb_actor": "Person",
"tmdb_actor_details": "Person",
"tmdb_collection": "Collection",
"tmdb_collection_details": "Collection",
"tmdb_company": "Company",
"tmdb_crew": "Person",
"tmdb_crew_details": "Person",
"tmdb_director": "Person",
"tmdb_director_details": "Person",
"tmdb_keyword": "Keyword",
"tmdb_list": "List",
"tmdb_list_details": "List",
"tmdb_movie": "Movie",
"tmdb_movie_details": "Movie",
"tmdb_network": "Network",
"tmdb_person": "Person",
"tmdb_producer": "Person",
"tmdb_producer_details": "Person",
"tmdb_show": "Show",
"tmdb_show_details": "Show",
"tmdb_writer": "Person",
"tmdb_writer_details": "Person"
}
plex_searches = [
"title", "title.and", "title.not", "title.begins", "title.ends",
"studio", "studio.and", "studio.not", "studio.begins", "studio.ends",
"actor", "actor.and", "actor.not",
"audio_language", "audio_language.and", "audio_language.not",
"collection", "collection.and", "collection.not",
"content_rating", "content_rating.and", "content_rating.not",
"country", "country.and", "country.not",
"director", "director.and", "director.not",
"genre", "genre.and", "genre.not",
"label", "label.and", "label.not",
"producer", "producer.and", "producer.not",
"subtitle_language", "subtitle_language.and", "subtitle_language.not",
"writer", "writer.and", "writer.not",
"decade", "resolution",
"added.before", "added.after",
"originally_available.before", "originally_available.after",
"duration.greater", "duration.less",
"rating.greater", "rating.less",
"year", "year.not", "year.greater", "year.less"
]
plex_sort = {
"title.asc": "titleSort:asc", "title.desc": "titleSort:desc",
"originally_available.asc": "originallyAvailableAt:asc", "originally_available.desc": "originallyAvailableAt:desc",
"critic_rating.asc": "rating:asc", "critic_rating.desc": "rating:desc",
"audience_rating.asc": "audienceRating:asc", "audience_rating.desc": "audienceRating:desc",
"duration.asc": "duration:asc", "duration.desc": "duration:desc",
"added.asc": "addedAt:asc", "added.desc": "addedAt:desc"
}
plex_modifiers = {
".and": "&",
".not": "!",
".begins": "<",
".ends": ">",
".before": "<<",
".after": ">>",
".greater": ">>",
".less": "<<"
}
movie_only_searches = [
"audio_language", "audio_language.and", "audio_language.not",
"country", "country.and", "country.not",
"subtitle_language", "subtitle_language.and", "subtitle_language.not",
"decade", "resolution",
"originally_available.before", "originally_available.after",
"duration.greater", "duration.less"
]
tmdb_searches = [
"actor", "actor.and", "actor.not",
"director", "director.and", "director.not",
"producer", "producer.and", "producer.not",
"writer", "writer.and", "writer.not"
]
all_filters = [
"actor", "actor.not",
"audio_language", "audio_language.not",
"audio_track_title", "audio_track_title.not",
"collection", "collection.not",
"content_rating", "content_rating.not",
"country", "country.not",
"director", "director.not",
"genre", "genre.not",
"max_age",
"originally_available.gte", "originally_available.lte",
"tmdb_vote_count.gte", "tmdb_vote_count.lte",
"duration.gte", "duration.lte",
"original_language", "original_language.not",
"rating.gte", "rating.lte",
"studio", "studio.not",
"subtitle_language", "subtitle_language.not",
"video_resolution", "video_resolution.not",
"writer", "writer.not",
"year", "year.gte", "year.lte", "year.not"
]
movie_only_filters = [
"audio_language", "audio_language.not",
"audio_track_title", "audio_track_title.not",
"country", "country.not",
"director", "director.not",
"duration.gte", "duration.lte",
"original_language", "original_language.not",
"subtitle_language", "subtitle_language.not",
"video_resolution", "video_resolution.not",
"writer", "writer.not"
]
boolean_details = [
"add_to_arr",
"show_filtered",
"show_missing",
"save_missing"
]
all_details = [
"sort_title", "content_rating",
"summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary", "tvdb_description", "trakt_description", "letterboxd_description",
"collection_mode", "collection_order",
"url_poster", "tmdb_poster", "tmdb_profile", "tvdb_poster", "file_poster",
"url_background", "tmdb_background", "tvdb_background", "file_background",
"name_mapping", "add_to_arr", "arr_tag", "label",
"show_filtered", "show_missing", "save_missing"
]
discover_movie = [
"language", "with_original_language", "region", "sort_by",
"certification_country", "certification", "certification.lte", "certification.gte",
"include_adult",
"primary_release_year", "primary_release_date.gte", "primary_release_date.lte",
"release_date.gte", "release_date.lte", "year",
"vote_count.gte", "vote_count.lte",
"vote_average.gte", "vote_average.lte",
"with_cast", "with_crew", "with_people",
"with_companies",
"with_genres", "without_genres",
"with_keywords", "without_keywords",
"with_runtime.gte", "with_runtime.lte"
]
discover_tv = [
"language", "with_original_language", "timezone", "sort_by",
"air_date.gte", "air_date.lte",
"first_air_date.gte", "first_air_date.lte", "first_air_date_year",
"vote_count.gte", "vote_count.lte",
"vote_average.gte", "vote_average.lte",
"with_genres", "without_genres",
"with_keywords", "without_keywords",
"with_networks", "with_companies",
"with_runtime.gte", "with_runtime.lte",
"include_null_first_air_dates",
"screened_theatrically"
]
discover_dates = [
"primary_release_date.gte", "primary_release_date.lte",
"release_date.gte", "release_date.lte",
"air_date.gte", "air_date.lte",
"first_air_date.gte", "first_air_date.lte"
]
discover_movie_sort = [
"popularity.asc", "popularity.desc",
"release_date.asc", "release_date.desc",
"revenue.asc", "revenue.desc",
"primary_release_date.asc", "primary_release_date.desc",
"original_title.asc", "original_title.desc",
"vote_average.asc", "vote_average.desc",
"vote_count.asc", "vote_count.desc"
]
discover_tv_sort = [
"vote_average.desc", "vote_average.asc",
"first_air_date.desc", "first_air_date.asc",
"popularity.desc", "popularity.asc"
]
def tab_new_lines(data): def tab_new_lines(data):
return str(data).replace("\n", "\n|\t ") if "\n" in str(data) else str(data) return str(data).replace("\n", "\n|\t ") if "\n" in str(data) else str(data)
@ -678,8 +240,8 @@ def check_number(value, method, number_type="int", minimum=None, maximum=None):
return num_value return num_value
def check_date(date_text, method, return_string=False, plex_date=False): def check_date(date_text, method, return_string=False, plex_date=False):
try: date_obg = datetime.strptime(str(date_text), "%Y/%m/%d" if plex_date else "%m/%d/%Y") try: date_obg = datetime.strptime(str(date_text), "%Y-%m-%d" if plex_date else "%m/%d/%Y")
except ValueError: raise Failed(f"Collection Error: {method}: {date_text} must match pattern {'YYYY/MM/DD e.g. 2020/12/25' if plex_date else 'MM/DD/YYYY e.g. 12/25/2020'}") except ValueError: raise Failed(f"Collection Error: {method}: {date_text} must match pattern {'YYYY-MM-DD e.g. 2020-12-25' if plex_date else 'MM/DD/YYYY e.g. 12/25/2020'}")
return str(date_text) if return_string else date_obg return str(date_text) if return_string else date_obg
def logger_input(prompt, timeout=60): def logger_input(prompt, timeout=60):

@ -13,6 +13,7 @@ parser.add_argument("--my-tests", dest="tests", help=argparse.SUPPRESS, action="
parser.add_argument("--debug", dest="debug", help=argparse.SUPPRESS, action="store_true", default=False) parser.add_argument("--debug", dest="debug", help=argparse.SUPPRESS, action="store_true", default=False)
parser.add_argument("-c", "--config", dest="config", help="Run with desired *.yml file", type=str) parser.add_argument("-c", "--config", dest="config", help="Run with desired *.yml file", type=str)
parser.add_argument("-t", "--time", dest="time", help="Time to update each day use format HH:MM (Default: 03:00)", default="03:00", type=str) parser.add_argument("-t", "--time", dest="time", help="Time to update each day use format HH:MM (Default: 03:00)", default="03:00", type=str)
parser.add_argument("-re", "--resume", dest="resume", help="Resume collection run from a specific collection", type=str)
parser.add_argument("-r", "--run", dest="run", help="Run without the scheduler", action="store_true", default=False) parser.add_argument("-r", "--run", dest="run", help="Run without the scheduler", action="store_true", default=False)
parser.add_argument("-rt", "--test", "--tests", "--run-test", "--run-tests", dest="test", help="Run in debug mode with only collections that have test: true", action="store_true", default=False) parser.add_argument("-rt", "--test", "--tests", "--run-test", "--run-tests", dest="test", help="Run in debug mode with only collections that have test: true", action="store_true", default=False)
parser.add_argument("-cl", "--collection", "--collections", dest="collections", help="Process only specified collections (comma-separated list)", type=str) parser.add_argument("-cl", "--collection", "--collections", dest="collections", help="Process only specified collections (comma-separated list)", type=str)
@ -37,6 +38,7 @@ test = check_bool("PMM_TEST", args.test)
debug = check_bool("PMM_DEBUG", args.debug) debug = check_bool("PMM_DEBUG", args.debug)
run = check_bool("PMM_RUN", args.run) run = check_bool("PMM_RUN", args.run)
collections = os.environ.get("PMM_COLLECTIONS") if os.environ.get("PMM_COLLECTIONS") else args.collections collections = os.environ.get("PMM_COLLECTIONS") if os.environ.get("PMM_COLLECTIONS") else args.collections
resume = os.environ.get("PMM_RESUME") if os.environ.get("PMM_RESUME") else args.resume
time_to_run = os.environ.get("PMM_TIME") if os.environ.get("PMM_TIME") else args.time time_to_run = os.environ.get("PMM_TIME") if os.environ.get("PMM_TIME") else args.time
if not re.match("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$", time_to_run): if not re.match("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$", time_to_run):
@ -87,14 +89,14 @@ util.centered("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` | | |\\/| |/ _` | '_
util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ") util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")
util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ") util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")
util.centered(" |___/ ") util.centered(" |___/ ")
util.centered(" Version: 1.6.4 ") util.centered(" Version: 1.7.0 ")
util.separator() util.separator()
if my_tests: if my_tests:
tests.run_tests(default_dir) tests.run_tests(default_dir)
sys.exit(0) sys.exit(0)
def start(config_path, is_test, daily, collections_to_run): def start(config_path, is_test, daily, collections_to_run, resume_from):
if daily: start_type = "Daily " if daily: start_type = "Daily "
elif is_test: start_type = "Test " elif is_test: start_type = "Test "
elif collections_to_run: start_type = "Collections " elif collections_to_run: start_type = "Collections "
@ -103,7 +105,7 @@ def start(config_path, is_test, daily, collections_to_run):
util.separator(f"Starting {start_type}Run") util.separator(f"Starting {start_type}Run")
try: try:
config = Config(default_dir, config_path) config = Config(default_dir, config_path)
config.update_libraries(is_test, collections_to_run) config.update_libraries(is_test, collections_to_run, resume_from)
except Exception as e: except Exception as e:
util.print_stacktrace() util.print_stacktrace()
logger.critical(e) logger.critical(e)
@ -111,11 +113,11 @@ def start(config_path, is_test, daily, collections_to_run):
util.separator(f"Finished {start_type}Run\nRun Time: {str(datetime.now() - start_time).split('.')[0]}") util.separator(f"Finished {start_type}Run\nRun Time: {str(datetime.now() - start_time).split('.')[0]}")
try: try:
if run or test or collections: if run or test or collections or resume:
start(config_file, test, False, collections) start(config_file, test, False, collections, resume)
else: else:
length = 0 length = 0
schedule.every().day.at(time_to_run).do(start, config_file, False, True, None) schedule.every().day.at(time_to_run).do(start, config_file, False, True, None, None)
while True: while True:
schedule.run_pending() schedule.run_pending()
current = datetime.now().strftime("%H:%M") current = datetime.now().strftime("%H:%M")

Loading…
Cancel
Save