Merge pull request #431 from meisnate12/develop

v1.13.0
pull/456/head v1.13.0
meisnate12 3 years ago committed by GitHub
commit 022857435f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,38 @@
name: Docker Develop Release
on:
push:
branches: [ develop ]
pull_request:
branches: [ develop ]
jobs:
docker-develop:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
with:
ref: develop
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/plex-meta-manager:develop

@ -0,0 +1,36 @@
name: Docker Latest Release
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
docker-latest:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/plex-meta-manager:latest

@ -0,0 +1,18 @@
name: Tag
on:
push:
branches: [ master ]
jobs:
tag-new-versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
token: ${{ secrets.PAT }}
fetch-depth: 2
- uses: salsify/action-detect-and-tag-new-version@v1.0.3
with:
version-command: |
cat VERSION

@ -0,0 +1,39 @@
name: Docker Version Release
on:
create:
tags:
- v*
jobs:
docker-develop:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/plex-meta-manager:${{ steps.get_version.outputs.VERSION }}

@ -1,9 +1,10 @@
# Plex Meta Manager # Plex Meta Manager
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/meisnate12/Plex-Meta-Manager?style=plastic)](https://github.com/meisnate12/Plex-Meta-Manager/releases) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/meisnate12/Plex-Meta-Manager?style=plastic)](https://github.com/meisnate12/Plex-Meta-Manager/releases)
[![GitHub commits since latest release (by SemVer)](https://img.shields.io/github/commits-since/meisnate12/plex-meta-manager/latest/develop?label=Number%20of%20Commits%20in%20Develop&style=plastic)](https://github.com/meisnate12/Plex-Meta-Manager/tree/develop) [![GitHub commits since latest release (by SemVer)](https://img.shields.io/github/commits-since/meisnate12/plex-meta-manager/latest/develop?label=Commits%20in%20Develop&style=plastic)](https://github.com/meisnate12/Plex-Meta-Manager/tree/develop)
[![Docker Image Version (latest semver)](https://img.shields.io/docker/v/meisnate12/plex-meta-manager?label=docker&sort=semver&style=plastic)](https://hub.docker.com/r/meisnate12/plex-meta-manager) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/meisnate12/plex-meta-manager?label=docker&sort=semver&style=plastic)](https://hub.docker.com/r/meisnate12/plex-meta-manager)
[![Docker Cloud Build Status](https://img.shields.io/docker/cloud/build/meisnate12/plex-meta-manager?style=plastic)](https://hub.docker.com/r/meisnate12/plex-meta-manager) [![Docker Cloud Build Status](https://img.shields.io/docker/cloud/build/meisnate12/plex-meta-manager?style=plastic)](https://hub.docker.com/r/meisnate12/plex-meta-manager)
[![Docker Pulls](https://img.shields.io/docker/pulls/meisnate12/plex-meta-manager?style=plastic)](https://hub.docker.com/r/meisnate12/plex-meta-manager)
[![Discord](https://img.shields.io/discord/822460010649878528?label=Discord&style=plastic)](https://discord.gg/TsdpsFYqqm) [![Discord](https://img.shields.io/discord/822460010649878528?label=Discord&style=plastic)](https://discord.gg/TsdpsFYqqm)
[![Sponsor or Donate](https://img.shields.io/badge/-Sponsor_or_Donate-blueviolet?style=plastic)](https://github.com/sponsors/meisnate12) [![Sponsor or Donate](https://img.shields.io/badge/-Sponsor_or_Donate-blueviolet?style=plastic)](https://github.com/sponsors/meisnate12)
@ -11,7 +12,7 @@ The original concept for Plex Meta Manager is [Plex Auto Collections](https://gi
The script can update many metadata fields for movies, shows, collections, seasons, and episodes and can act as a backup if your plex DB goes down. It can even update metadata the plex UI can't like Season Names. If the time is put into the metadata configuration file you can have a way to recreate your library and all its metadata changes with the click of a button. The script can update many metadata fields for movies, shows, collections, seasons, and episodes and can act as a backup if your plex DB goes down. It can even update metadata the plex UI can't like Season Names. If the time is put into the metadata configuration file you can have a way to recreate your library and all its metadata changes with the click of a button.
The script is designed to work with most Metadata agents including the new Plex Movie Agent, New Plex TV Agent, [Hama Anime Agent](https://github.com/ZeroQI/Hama.bundle), and [MyAnimeList Anime Agent](https://github.com/Fribb/MyAnimeList.bundle). The script works with most Metadata agents including the new Plex Movie Agent, New Plex TV Agent, [Hama Anime Agent](https://github.com/ZeroQI/Hama.bundle), and [MyAnimeList Anime Agent](https://github.com/Fribb/MyAnimeList.bundle).
## Getting Started ## Getting Started
@ -23,7 +24,7 @@ The script is designed to work with most Metadata agents including the new Plex
## Support ## Support
* Before posting on Github about an enhancement, error, or configuration question please visit the [Plex Meta Manager Discord Server](https://discord.gg/TsdpsFYqqm). * Before posting on GitHub about an enhancement, error, or configuration question please visit the [Plex Meta Manager Discord Server](https://discord.gg/TsdpsFYqqm).
* 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 post in 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 to 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).

@ -0,0 +1 @@
1.12.2-develop1115

@ -3,32 +3,40 @@
libraries: # Library mappings must have a colon (:) placed after them libraries: # Library mappings must have a colon (:) placed after them
Movies: Movies:
metadata_path: metadata_path:
- file: config/Movies.yml # You have to create this file the other are online - file: config/Movies.yml # You have to create this file the other is online
- git: meisnate12/MovieCharts - git: meisnate12/MovieCharts
- git: meisnate12/Studios
- git: meisnate12/IMDBGenres
- git: meisnate12/People
TV Shows: TV Shows:
metadata_path: metadata_path:
- file: config/TV Shows.yml # You have to create this file the other are online - file: config/TV Shows.yml # You have to create this file the other is online
- git: meisnate12/ShowCharts - git: meisnate12/ShowCharts
- git: meisnate12/Networks
Anime: Anime:
metadata_path: metadata_path:
- file: config/Anime.yml # You have to create this file the other are online - file: config/Anime.yml # You have to create this file the other is online
- git: meisnate12/AnimeCharts - git: meisnate12/AnimeCharts
settings: # Can be individually specified per library as well settings: # Can be individually specified per library as well
cache: true cache: true
cache_expiration: 60 cache_expiration: 60
asset_directory: config/assets asset_directory: config/assets
asset_folders: true asset_folders: true
assets_for_all: false
sync_mode: append sync_mode: append
show_unmanaged: true show_unmanaged: true
show_filtered: false show_filtered: false
show_missing: true show_missing: true
save_missing: true save_missing: true
run_again_delay: 2 run_again_delay: 2
released_missing_only: false
create_asset_folders: false
missing_only_released: false
collection_minimum: 1
delete_below_minimum: true
tvdb_language: eng
webhooks: # Can be individually specified per library as well
error:
run_start:
run_end:
collection_creation:
collection_addition:
collection_removal:
plex: # Can be individually specified per library as well plex: # Can be individually specified per library as well
url: http://192.168.1.12:32400 url: http://192.168.1.12:32400
token: #################### token: ####################
@ -42,10 +50,16 @@ tmdb:
tautulli: # Can be individually specified per library as well tautulli: # Can be individually specified per library as well
url: http://192.168.1.12:8181 url: http://192.168.1.12:8181
apikey: ################################ apikey: ################################
omdb:
apikey: ########
notifiarr:
apikey: ####################################
anidb: # Not required for AniDB builders unless you want mature content
username: ######
password: ######
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: v3
add: false add: false
root_folder_path: S:/Movies root_folder_path: S:/Movies
monitor: true monitor: true
@ -56,7 +70,6 @@ radarr: # Can be individually specified
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: v3
add: false add: false
root_folder_path: "S:/TV Shows" root_folder_path: "S:/TV Shows"
monitor: all monitor: all
@ -67,8 +80,6 @@ sonarr: # Can be individually specified
tag: tag:
search: false search: false
cutoff_search: false cutoff_search: false
omdb:
apikey: ########
trakt: trakt:
client_id: ################################################################ client_id: ################################################################
client_secret: ################################################################ client_secret: ################################################################
@ -89,6 +100,3 @@ mal:
token_type: token_type:
expires_in: expires_in:
refresh_token: refresh_token:
anidb: # Optional
username: ######
password: ######

@ -22,15 +22,21 @@ class AniDB:
if params and not self._login(self.username, self.password).xpath("//li[@class='sub-menu my']/@title"): if params and not self._login(self.username, self.password).xpath("//li[@class='sub-menu my']/@title"):
raise Failed("AniDB Error: Login failed") raise Failed("AniDB Error: Login failed")
def _request(self, url, language=None, post=None): def _request(self, url, language=None, data=None):
if post: if self.config.trace_mode:
return self.config.post_html(url, post, headers=util.header(language)) logger.debug(f"URL: {url}")
if data:
return self.config.post_html(url, data=data, headers=util.header(language))
else: else:
return self.config.get_html(url, headers=util.header(language)) return self.config.get_html(url, headers=util.header(language))
def _login(self, username, password): def _login(self, username, password):
data = {"show": "main", "xuser": username, "xpass": password, "xdoautologin": "on"} return self._request(urls["login"], data={
return self._request(urls["login"], post=data) "show": "main",
"xuser": username,
"xpass": password,
"xdoautologin": "on"
})
def _popular(self, language): def _popular(self, language):
response = self._request(urls["popular"], language=language) response = self._request(urls["popular"], language=language)

@ -61,8 +61,13 @@ class AniList:
self.options["Tag Category"][media_tag["category"].lower().replace(" ", "-")] = media_tag["category"] self.options["Tag Category"][media_tag["category"].lower().replace(" ", "-")] = media_tag["category"]
def _request(self, query, variables, level=1): def _request(self, query, variables, level=1):
if self.config.trace_mode:
logger.debug(f"Query: {query}")
logger.debug(f"Variables: {variables}")
response = self.config.post(base_url, json={"query": query, "variables": variables}) response = self.config.post(base_url, json={"query": query, "variables": variables})
json_obj = response.json() json_obj = response.json()
if self.config.trace_mode:
logger.debug(f"Response: {json_obj}")
if "errors" in json_obj: if "errors" in json_obj:
if json_obj['errors'][0]['message'] == "Too Many Requests.": if json_obj['errors'][0]['message'] == "Too Many Requests.":
wait_time = int(response.headers["Retry-After"]) if "Retry-After" in response.headers else 0 wait_time = int(response.headers["Retry-After"]) if "Retry-After" in response.headers else 0

@ -1,7 +1,7 @@
import logging, os, re import logging, os, re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from modules import anidb, anilist, icheckmovies, imdb, letterboxd, mal, plex, radarr, sonarr, stevenlu, tautulli, tmdb, trakt, tvdb, util from modules import anidb, anilist, icheckmovies, imdb, letterboxd, mal, plex, radarr, sonarr, stevenlu, tautulli, tmdb, trakt, tvdb, util
from modules.util import Failed, ImageData from modules.util import Failed, ImageData, NotScheduled
from PIL import Image from PIL import Image
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
from plexapi.video import Movie, Show, Season, Episode from plexapi.video import Movie, Show, Season, Episode
@ -41,7 +41,8 @@ method_alias = {
"seasonyear": "year", "isadult": "adult", "startdate": "start", "enddate": "end", "averagescore": "score", "seasonyear": "year", "isadult": "adult", "startdate": "start", "enddate": "end", "averagescore": "score",
"minimum_tag_percentage": "min_tag_percent", "minimumtagrank": "min_tag_percent", "minimum_tag_rank": "min_tag_percent", "minimum_tag_percentage": "min_tag_percent", "minimumtagrank": "min_tag_percent", "minimum_tag_rank": "min_tag_percent",
"anilist_tag": "anilist_search", "anilist_genre": "anilist_search", "anilist_season": "anilist_search", "anilist_tag": "anilist_search", "anilist_genre": "anilist_search", "anilist_season": "anilist_search",
"mal_producer": "mal_studio", "mal_licensor": "mal_studio" "mal_producer": "mal_studio", "mal_licensor": "mal_studio",
"trakt_recommended": "trakt_recommended_weekly", "trakt_watched": "trakt_watched_weekly", "trakt_collected": "trakt_collected_weekly"
} }
filter_translation = { filter_translation = {
"actor": "actors", "actor": "actors",
@ -68,7 +69,7 @@ show_only_builders = ["tmdb_network", "tmdb_show", "tmdb_show_details", "tvdb_sh
movie_only_builders = [ movie_only_builders = [
"letterboxd_list", "letterboxd_list_details", "icheckmovies_list", "icheckmovies_list_details", "stevenlu_popular", "letterboxd_list", "letterboxd_list_details", "icheckmovies_list", "icheckmovies_list_details", "stevenlu_popular",
"tmdb_collection", "tmdb_collection_details", "tmdb_movie", "tmdb_movie_details", "tmdb_now_playing", "tmdb_collection", "tmdb_collection_details", "tmdb_movie", "tmdb_movie_details", "tmdb_now_playing",
"tvdb_movie", "tvdb_movie_details" "tvdb_movie", "tvdb_movie_details", "trakt_boxoffice"
] ]
summary_details = [ summary_details = [
"summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary", "summary", "tmdb_summary", "tmdb_description", "tmdb_biography", "tvdb_summary",
@ -76,16 +77,20 @@ summary_details = [
] ]
poster_details = ["url_poster", "tmdb_poster", "tmdb_profile", "tvdb_poster", "file_poster"] poster_details = ["url_poster", "tmdb_poster", "tmdb_profile", "tvdb_poster", "file_poster"]
background_details = ["url_background", "tmdb_background", "tvdb_background", "file_background"] background_details = ["url_background", "tmdb_background", "tvdb_background", "file_background"]
boolean_details = ["visible_library", "visible_home", "visible_shared", "show_filtered", "show_missing", "save_missing", "item_assets", "missing_only_released", "revert_overlay", "delete_below_minimum"] boolean_details = [
"visible_library", "visible_home", "visible_shared", "show_filtered", "show_missing", "save_missing",
"missing_only_released", "delete_below_minimum"
]
string_details = ["sort_title", "content_rating", "name_mapping"] string_details = ["sort_title", "content_rating", "name_mapping"]
ignored_details = [ ignored_details = [
"smart_filter", "smart_label", "smart_url", "run_again", "schedule", "sync_mode", "template", "test", "smart_filter", "smart_label", "smart_url", "run_again", "schedule", "sync_mode", "template", "test",
"tmdb_person", "build_collection", "collection_order", "collection_level", "validate_builders", "collection_name" "tmdb_person", "build_collection", "collection_order", "collection_level", "validate_builders", "collection_name"
] ]
details = ["collection_mode", "collection_order", "collection_level", "collection_minimum", "label"] + boolean_details + string_details notification_details = ["collection_creation_webhooks", "collection_addition_webhooks", "collection_removal_webhooks"]
details = ["collection_mode", "collection_order", "collection_level", "collection_minimum", "label"] + boolean_details + string_details + notification_details
collectionless_details = ["collection_order", "plex_collectionless", "label", "label_sync_mode", "test"] + \ collectionless_details = ["collection_order", "plex_collectionless", "label", "label_sync_mode", "test"] + \
poster_details + background_details + summary_details + string_details poster_details + background_details + summary_details + string_details
item_details = ["item_label", "item_radarr_tag", "item_sonarr_tag", "item_overlay"] + list(plex.item_advance_keys.keys()) item_details = ["item_label", "item_radarr_tag", "item_sonarr_tag", "item_overlay", "item_assets", "revert_overlay", "item_refresh"] + list(plex.item_advance_keys.keys())
radarr_details = ["radarr_add", "radarr_add_existing", "radarr_folder", "radarr_monitor", "radarr_search", "radarr_availability", "radarr_quality", "radarr_tag"] radarr_details = ["radarr_add", "radarr_add_existing", "radarr_folder", "radarr_monitor", "radarr_search", "radarr_availability", "radarr_quality", "radarr_tag"]
sonarr_details = [ sonarr_details = [
"sonarr_add", "sonarr_add_existing", "sonarr_folder", "sonarr_monitor", "sonarr_language", "sonarr_series", "sonarr_add", "sonarr_add_existing", "sonarr_folder", "sonarr_monitor", "sonarr_language", "sonarr_series",
@ -142,7 +147,10 @@ custom_sort_builders = [
"tmdb_list", "tmdb_popular", "tmdb_now_playing", "tmdb_top_rated", "tmdb_list", "tmdb_popular", "tmdb_now_playing", "tmdb_top_rated",
"tmdb_trending_daily", "tmdb_trending_weekly", "tmdb_discover", "tmdb_trending_daily", "tmdb_trending_weekly", "tmdb_discover",
"tvdb_list", "imdb_list", "stevenlu_popular", "anidb_popular", "tvdb_list", "imdb_list", "stevenlu_popular", "anidb_popular",
"trakt_list", "trakt_trending", "trakt_popular", "trakt_recommended", "trakt_watched", "trakt_collected", "trakt_list", "trakt_trending", "trakt_popular", "trakt_boxoffice",
"trakt_collected_daily", "trakt_collected_weekly", "trakt_collected_monthly", "trakt_collected_yearly", "trakt_collected_all",
"trakt_recommended_daily", "trakt_recommended_weekly", "trakt_recommended_monthly", "trakt_recommended_yearly", "trakt_recommended_all",
"trakt_watched_daily", "trakt_watched_weekly", "trakt_watched_monthly", "trakt_watched_yearly", "trakt_watched_all",
"tautulli_popular", "tautulli_watched", "letterboxd_list", "icheckmovies_list", "tautulli_popular", "tautulli_watched", "letterboxd_list", "icheckmovies_list",
"anilist_top_rated", "anilist_popular", "anilist_season", "anilist_studio", "anilist_genre", "anilist_tag", "anilist_search", "anilist_top_rated", "anilist_popular", "anilist_season", "anilist_studio", "anilist_genre", "anilist_tag", "anilist_search",
"mal_all", "mal_airing", "mal_upcoming", "mal_tv", "mal_movie", "mal_ova", "mal_special", "mal_all", "mal_airing", "mal_upcoming", "mal_tv", "mal_movie", "mal_ova", "mal_special",
@ -168,7 +176,10 @@ class CollectionBuilder:
"save_missing": self.library.save_missing, "save_missing": self.library.save_missing,
"missing_only_released": self.library.missing_only_released, "missing_only_released": self.library.missing_only_released,
"create_asset_folders": self.library.create_asset_folders, "create_asset_folders": self.library.create_asset_folders,
"item_assets": False "delete_below_minimum": self.library.delete_below_minimum,
"collection_creation_webhooks": self.library.collection_creation_webhooks,
"collection_addition_webhooks": self.library.collection_addition_webhooks,
"collection_removal_webhooks": self.library.collection_removal_webhooks,
} }
self.item_details = {} self.item_details = {}
self.radarr_details = {} self.radarr_details = {}
@ -183,14 +194,18 @@ class CollectionBuilder:
self.filtered_keys = {} self.filtered_keys = {}
self.run_again_movies = [] self.run_again_movies = []
self.run_again_shows = [] self.run_again_shows = []
self.notification_additions = []
self.notification_removals = []
self.items = []
self.posters = {} self.posters = {}
self.backgrounds = {} self.backgrounds = {}
self.summaries = {} self.summaries = {}
self.schedule = "" self.schedule = ""
self.minimum = self.library.collection_minimum self.minimum = self.library.collection_minimum
self.delete_below_minimum = self.library.delete_below_minimum
self.current_time = datetime.now() self.current_time = datetime.now()
self.current_year = self.current_time.year self.current_year = self.current_time.year
self.exists = False
self.created = False
methods = {m.lower(): m for m in self.data} methods = {m.lower(): m for m in self.data}
@ -381,7 +396,7 @@ class CollectionBuilder:
if len(self.schedule) == 0: if len(self.schedule) == 0:
skip_collection = False skip_collection = False
if skip_collection: if skip_collection:
raise Failed(f"{self.schedule}\n\nCollection {self.name} not scheduled to run") raise NotScheduled(f"{self.schedule}\n\nCollection {self.name} not scheduled to run")
self.collectionless = "plex_collectionless" in methods self.collectionless = "plex_collectionless" in methods
@ -617,6 +632,8 @@ class CollectionBuilder:
if self.sync and self.obj: if self.sync and self.obj:
for item in self.library.get_collection_items(self.obj, self.smart_label_collection): for item in self.library.get_collection_items(self.obj, self.smart_label_collection):
self.plex_map[item.ratingKey] = item self.plex_map[item.ratingKey] = item
if self.obj:
self.exists = True
else: else:
self.obj = None self.obj = None
self.sync = False self.sync = False
@ -634,9 +651,9 @@ class CollectionBuilder:
elif method_name == "tmdb_biography": elif method_name == "tmdb_biography":
self.summaries[method_name] = self.config.TMDb.get_person(util.regex_first_int(method_data, "TMDb Person ID")).biography self.summaries[method_name] = self.config.TMDb.get_person(util.regex_first_int(method_data, "TMDb Person ID")).biography
elif method_name == "tvdb_summary": elif method_name == "tvdb_summary":
self.summaries[method_name] = self.config.TVDb.get_movie_or_show(method_data, self.language, self.library.is_movie).summary self.summaries[method_name] = self.config.TVDb.get_item(method_data, self.library.is_movie).summary
elif method_name == "tvdb_description": elif method_name == "tvdb_description":
self.summaries[method_name] = self.config.TVDb.get_list_description(method_data, self.language) self.summaries[method_name] = self.config.TVDb.get_list_description(method_data)
elif method_name == "trakt_description": elif method_name == "trakt_description":
self.summaries[method_name] = self.config.Trakt.list_description(self.config.Trakt.validate_trakt(method_data, self.library.is_movie)[0]) self.summaries[method_name] = self.config.Trakt.list_description(self.config.Trakt.validate_trakt(method_data, self.library.is_movie)[0])
elif method_name == "letterboxd_description": elif method_name == "letterboxd_description":
@ -654,7 +671,7 @@ class CollectionBuilder:
url_slug = self.config.TMDb.get_person(util.regex_first_int(method_data, 'TMDb Person ID')).profile_path url_slug = self.config.TMDb.get_person(util.regex_first_int(method_data, 'TMDb Person ID')).profile_path
self.posters[method_name] = f"{self.config.TMDb.image_url}{url_slug}" self.posters[method_name] = f"{self.config.TMDb.image_url}{url_slug}"
elif method_name == "tvdb_poster": elif method_name == "tvdb_poster":
self.posters[method_name] = f"{self.config.TVDb.get_item(method_data, self.language, self.library.is_movie).poster_path}" self.posters[method_name] = f"{self.config.TVDb.get_item(method_data, self.library.is_movie).poster_path}"
elif method_name == "file_poster": elif method_name == "file_poster":
if os.path.exists(method_data): if os.path.exists(method_data):
self.posters[method_name] = os.path.abspath(method_data) self.posters[method_name] = os.path.abspath(method_data)
@ -668,7 +685,7 @@ class CollectionBuilder:
url_slug = self.config.TMDb.get_movie_show_or_collection(util.regex_first_int(method_data, 'TMDb ID'), self.library.is_movie).poster_path url_slug = self.config.TMDb.get_movie_show_or_collection(util.regex_first_int(method_data, 'TMDb ID'), self.library.is_movie).poster_path
self.backgrounds[method_name] = f"{self.config.TMDb.image_url}{url_slug}" self.backgrounds[method_name] = f"{self.config.TMDb.image_url}{url_slug}"
elif method_name == "tvdb_background": elif method_name == "tvdb_background":
self.posters[method_name] = f"{self.config.TVDb.get_item(method_data, self.language, self.library.is_movie).background_path}" self.posters[method_name] = f"{self.config.TVDb.get_item(method_data, self.library.is_movie).background_path}"
elif method_name == "file_background": elif method_name == "file_background":
if os.path.exists(method_data): if os.path.exists(method_data):
self.backgrounds[method_name] = os.path.abspath(method_data) self.backgrounds[method_name] = os.path.abspath(method_data)
@ -692,6 +709,8 @@ class CollectionBuilder:
self.details["label.sync"] = util.get_list(method_data) self.details["label.sync"] = util.get_list(method_data)
else: else:
self.details[method_final] = util.get_list(method_data) self.details[method_final] = util.get_list(method_data)
elif method_name in notification_details:
self.details[method_name] = util.parse(method_name, method_data, datatype="list")
elif method_name in boolean_details: elif method_name in boolean_details:
default = self.details[method_name] if method_name in self.details else None default = self.details[method_name] if method_name in self.details else None
self.details[method_name] = util.parse(method_name, method_data, datatype="bool", default=default) self.details[method_name] = util.parse(method_name, method_data, datatype="bool", default=default)
@ -722,6 +741,9 @@ class CollectionBuilder:
raise Failed("Each Overlay can only be used once per Library") raise Failed("Each Overlay can only be used once per Library")
self.library.overlays.append(method_data) self.library.overlays.append(method_data)
self.item_details[method_name] = method_data self.item_details[method_name] = method_data
elif method_name in ["item_assets", "revert_overlay", "item_refresh"]:
if util.parse(method_name, method_data, datatype="bool", default=False):
self.item_details[method_name] = True
elif method_name in plex.item_advance_keys: elif method_name in plex.item_advance_keys:
key, options = plex.item_advance_keys[method_name] key, options = plex.item_advance_keys[method_name]
if method_name in advance_new_agent and self.library.agent not in plex.new_plex_agents: if method_name in advance_new_agent and self.library.agent not in plex.new_plex_agents:
@ -745,7 +767,6 @@ class CollectionBuilder:
else: else:
raise Failed(f"Collection Error: {method_name} attribute must be either announced, cinemas, released or db") raise Failed(f"Collection Error: {method_name} attribute must be either announced, cinemas, released or db")
elif method_name == "radarr_quality": elif method_name == "radarr_quality":
self.library.Radarr.get_profile_id(method_data)
self.radarr_details["quality"] = method_data self.radarr_details["quality"] = method_data
elif method_name == "radarr_tag": elif method_name == "radarr_tag":
self.radarr_details["tag"] = util.get_list(method_data) self.radarr_details["tag"] = util.get_list(method_data)
@ -753,19 +774,13 @@ class CollectionBuilder:
def _sonarr(self, method_name, method_data): def _sonarr(self, method_name, method_data):
if method_name in ["sonarr_add", "sonarr_add_existing", "sonarr_season", "sonarr_search", "sonarr_cutoff_search"]: if method_name in ["sonarr_add", "sonarr_add_existing", "sonarr_season", "sonarr_search", "sonarr_cutoff_search"]:
self.sonarr_details[method_name[7:]] = util.parse(method_name, method_data, datatype="bool") self.sonarr_details[method_name[7:]] = util.parse(method_name, method_data, datatype="bool")
elif method_name == "sonarr_folder": elif method_name in ["sonarr_folder", "sonarr_quality", "sonarr_language"]:
self.sonarr_details["folder"] = method_data self.sonarr_details[method_name[7:]] = method_data
elif method_name == "sonarr_monitor": elif method_name == "sonarr_monitor":
if str(method_data).lower() in sonarr.monitor_translation: if str(method_data).lower() in sonarr.monitor_translation:
self.sonarr_details["monitor"] = str(method_data).lower() self.sonarr_details["monitor"] = str(method_data).lower()
else: else:
raise Failed(f"Collection Error: {method_name} attribute must be either all, future, missing, existing, pilot, first, latest or none") 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_details["quality"] = method_data
elif method_name == "sonarr_language":
self.library.Sonarr.get_profile_id(method_data, "language_profile")
self.sonarr_details["language"] = method_data
elif method_name == "sonarr_series": elif method_name == "sonarr_series":
if str(method_data).lower() in sonarr.series_type: if str(method_data).lower() in sonarr.series_type:
self.sonarr_details["series"] = str(method_data).lower() self.sonarr_details["series"] = str(method_data).lower()
@ -1018,17 +1033,22 @@ class CollectionBuilder:
self.builders.append(("trakt_list", trakt_list)) self.builders.append(("trakt_list", trakt_list))
if method_name.endswith("_details"): if method_name.endswith("_details"):
self.summaries[method_name] = self.config.Trakt.list_description(trakt_lists[0]) self.summaries[method_name] = self.config.Trakt.list_description(trakt_lists[0])
elif method_name in ["trakt_trending", "trakt_popular", "trakt_recommended", "trakt_watched", "trakt_collected"]:
self.builders.append((method_name, util.parse(method_name, method_data, datatype="int", default=10)))
elif method_name in ["trakt_watchlist", "trakt_collection"]: elif method_name in ["trakt_watchlist", "trakt_collection"]:
for trakt_list in self.config.Trakt.validate_trakt(method_data, self.library.is_movie, trakt_type=method_name[6:]): for trakt_list in self.config.Trakt.validate_trakt(method_data, self.library.is_movie, trakt_type=method_name[6:]):
self.builders.append((method_name, trakt_list)) self.builders.append((method_name, trakt_list))
elif method_name == "trakt_boxoffice":
if util.parse(method_name, method_data, datatype="bool", default=False):
self.builders.append((method_name, 10))
else:
raise Failed(f"Collection Error: {method_name} must be set to true")
elif method_name in trakt.builders:
self.builders.append((method_name, util.parse(method_name, method_data, datatype="int", default=10)))
def _tvdb(self, method_name, method_data): def _tvdb(self, method_name, method_data):
values = util.get_list(method_data) values = util.get_list(method_data)
if method_name.endswith("_details"): if method_name.endswith("_details"):
if method_name.startswith(("tvdb_movie", "tvdb_show")): if method_name.startswith(("tvdb_movie", "tvdb_show")):
item = self.config.TVDb.get_item(self.language, values[0], method_name.startswith("tvdb_movie")) item = self.config.TVDb.get_item(values[0], method_name.startswith("tvdb_movie"))
if hasattr(item, "description") and item.description: if hasattr(item, "description") and item.description:
self.summaries[method_name] = item.description self.summaries[method_name] = item.description
if hasattr(item, "background_path") and item.background_path: if hasattr(item, "background_path") and item.background_path:
@ -1036,7 +1056,7 @@ class CollectionBuilder:
if hasattr(item, "poster_path") and item.poster_path: if hasattr(item, "poster_path") and item.poster_path:
self.posters[method_name] = f"{self.config.TMDb.image_url}{item.poster_path}" self.posters[method_name] = f"{self.config.TMDb.image_url}{item.poster_path}"
elif method_name.startswith("tvdb_list"): elif method_name.startswith("tvdb_list"):
self.summaries[method_name] = self.config.TVDb.get_list_description(values[0], self.language) self.summaries[method_name] = self.config.TVDb.get_list_description(values[0])
for value in values: for value in values:
self.builders.append((method_name[:-8] if method_name.endswith("_details") else method_name, value)) self.builders.append((method_name[:-8] if method_name.endswith("_details") else method_name, value))
@ -1091,7 +1111,7 @@ class CollectionBuilder:
mal_ids = self.config.MyAnimeList.get_mal_ids(method, value) mal_ids = self.config.MyAnimeList.get_mal_ids(method, value)
ids = self.config.Convert.myanimelist_to_ids(mal_ids, self.library) ids = self.config.Convert.myanimelist_to_ids(mal_ids, self.library)
elif "tvdb" in method: elif "tvdb" in method:
ids = self.config.TVDb.get_tvdb_ids(method, value, self.language) ids = self.config.TVDb.get_tvdb_ids(method, value)
elif "imdb" in method: elif "imdb" in method:
ids = self.config.IMDb.get_imdb_ids(method, value, self.language) ids = self.config.IMDb.get_imdb_ids(method, value, self.language)
elif "icheckmovies" in method: elif "icheckmovies" in method:
@ -1119,7 +1139,7 @@ class CollectionBuilder:
rating_keys.append(input_id) rating_keys.append(input_id)
elif id_type == "tmdb" and not self.parts_collection: elif id_type == "tmdb" and not self.parts_collection:
if input_id in self.library.movie_map: if input_id in self.library.movie_map:
rating_keys.append(self.library.movie_map[input_id][0]) rating_keys.extend(self.library.movie_map[input_id])
elif input_id not in self.missing_movies: elif input_id not in self.missing_movies:
self.missing_movies.append(input_id) self.missing_movies.append(input_id)
elif id_type in ["tvdb", "tmdb_show"] and not self.parts_collection: elif id_type in ["tvdb", "tmdb_show"] and not self.parts_collection:
@ -1130,12 +1150,12 @@ class CollectionBuilder:
logger.error(e) logger.error(e)
continue continue
if input_id in self.library.show_map: if input_id in self.library.show_map:
rating_keys.append(self.library.show_map[input_id][0]) rating_keys.extend(self.library.show_map[input_id])
elif input_id not in self.missing_shows: elif input_id not in self.missing_shows:
self.missing_shows.append(input_id) self.missing_shows.append(input_id)
elif id_type == "imdb" and not self.parts_collection: elif id_type == "imdb" and not self.parts_collection:
if input_id in self.library.imdb_map: if input_id in self.library.imdb_map:
rating_keys.append(self.library.imdb_map[input_id][0]) rating_keys.extend(self.library.imdb_map[input_id])
else: else:
if self.do_missing: if self.do_missing:
try: try:
@ -1318,11 +1338,12 @@ class CollectionBuilder:
bool_mod = "" if validation else "!" bool_mod = "" if validation else "!"
bool_arg = "true" if validation else "false" bool_arg = "true" if validation else "false"
results, display_add = build_url_arg(1, mod=bool_mod, arg_s=bool_arg, mod_s="is") results, display_add = build_url_arg(1, mod=bool_mod, arg_s=bool_arg, mod_s="is")
elif (attr in ["title", "episode_title", "studio", "decade", "year", "episode_year"] or attr in plex.tags) and modifier in ["", ".not", ".begins", ".ends"]: elif (attr in ["title", "episode_title", "studio", "decade", "year", "episode_year"] or attr in plex.tags) and modifier in ["", ".is", ".isnot", ".not", ".begins", ".ends"]:
results = "" results = ""
display_add = "" display_add = ""
for og_value, result in validation: for og_value, result in validation:
built_arg = build_url_arg(quote(result) if attr in string_filters else result, arg_s=og_value) print(og_value, result)
built_arg = build_url_arg(quote(str(result)) if attr in string_filters else result, arg_s=og_value)
display_add += built_arg[1] display_add += built_arg[1]
results += f"{conjunction if len(results) > 0 else ''}{built_arg[0]}" results += f"{conjunction if len(results) > 0 else ''}{built_arg[0]}"
else: else:
@ -1471,6 +1492,7 @@ class CollectionBuilder:
def add_to_collection(self): def add_to_collection(self):
name, collection_items = self.library.get_collection_name_and_items(self.obj if self.obj else self.name, self.smart_label_collection) name, collection_items = self.library.get_collection_name_and_items(self.obj if self.obj else self.name, self.smart_label_collection)
total = len(self.rating_keys) total = len(self.rating_keys)
amount_added = 0
for i, item in enumerate(self.rating_keys, 1): for i, item in enumerate(self.rating_keys, 1):
try: try:
current = self.fetch_item(item) current = self.fetch_item(item)
@ -1481,13 +1503,46 @@ class CollectionBuilder:
logger.info(util.adjust_space(f"{name} Collection | {current_operation} | {self.item_title(current)}")) logger.info(util.adjust_space(f"{name} Collection | {current_operation} | {self.item_title(current)}"))
if current in collection_items: if current in collection_items:
self.plex_map[current.ratingKey] = None self.plex_map[current.ratingKey] = None
elif self.smart_label_collection:
self.library.query_data(current.addLabel, name)
else: else:
self.library.query_data(current.addCollection, name) self.library.alter_collection(current, name, smart_label_collection=self.smart_label_collection)
amount_added += 1
if self.details["collection_addition_webhooks"]:
if self.library.is_movie and current.ratingKey in self.library.movie_rating_key_map:
add_id = self.library.movie_rating_key_map[current.ratingKey]
elif self.library.is_show and current.ratingKey in self.library.show_rating_key_map:
add_id = self.library.show_rating_key_map[current.ratingKey]
else:
add_id = None
self.notification_additions.append({"title": current.title, "id": add_id})
util.print_end() util.print_end()
logger.info("") logger.info("")
logger.info(f"{total} {self.collection_level.capitalize()}{'s' if total > 1 else ''} Processed") logger.info(f"{total} {self.collection_level.capitalize()}{'s' if total > 1 else ''} Processed")
return amount_added
def sync_collection(self):
amount_removed = 0
for ratingKey, item in self.plex_map.items():
if item is not None:
if amount_removed == 0:
logger.info("")
util.separator(f"Removed from {self.name} Collection", space=False, border=False)
logger.info("")
self.library.reload(item)
logger.info(f"{self.name} Collection | - | {self.item_title(item)}")
self.library.alter_collection(item, self.name, smart_label_collection=self.smart_label_collection, add=False)
if self.details["collection_removal_webhooks"]:
if self.library.is_movie and item.ratingKey in self.library.movie_rating_key_map:
remove_id = self.library.movie_rating_key_map[item.ratingKey]
elif self.library.is_show and item.ratingKey in self.library.show_rating_key_map:
remove_id = self.library.show_rating_key_map[item.ratingKey]
else:
remove_id = None
self.notification_removals.append({"title": item.title, "id": remove_id})
amount_removed += 1
if amount_removed > 0:
logger.info("")
logger.info(f"{amount_removed} {self.collection_level.capitalize()}{'s' if amount_removed == 1 else ''} Removed")
return amount_removed
def check_tmdb_filter(self, item_id, is_movie, item=None, check_released=False): def check_tmdb_filter(self, item_id, is_movie, item=None, check_released=False):
if self.tmdb_filters or check_released: if self.tmdb_filters or check_released:
@ -1609,6 +1664,8 @@ class CollectionBuilder:
return True return True
def run_missing(self): def run_missing(self):
added_to_radarr = 0
added_to_sonarr = 0
if len(self.missing_movies) > 0: if len(self.missing_movies) > 0:
missing_movies_with_names = [] missing_movies_with_names = []
for missing_id in self.missing_movies: for missing_id in self.missing_movies:
@ -1635,7 +1692,7 @@ class CollectionBuilder:
if self.library.Radarr: if self.library.Radarr:
if self.radarr_details["add"]: if self.radarr_details["add"]:
try: try:
self.library.Radarr.add_tmdb(missing_tmdb_ids, **self.radarr_details) added_to_radarr += self.library.Radarr.add_tmdb(missing_tmdb_ids, **self.radarr_details)
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
if "item_radarr_tag" in self.item_details: if "item_radarr_tag" in self.item_details:
@ -1649,18 +1706,17 @@ class CollectionBuilder:
missing_shows_with_names = [] missing_shows_with_names = []
for missing_id in self.missing_shows: for missing_id in self.missing_shows:
try: try:
show = self.config.TVDb.get_series(self.language, missing_id) show = self.config.TVDb.get_series(missing_id)
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
continue continue
current_title = str(show.title.encode("ascii", "replace").decode())
if self.check_tmdb_filter(missing_id, False, check_released=self.details["missing_only_released"]): if self.check_tmdb_filter(missing_id, False, check_released=self.details["missing_only_released"]):
missing_shows_with_names.append((current_title, missing_id)) missing_shows_with_names.append((show.title, missing_id))
if self.details["show_missing"] is True: if self.details["show_missing"] is True:
logger.info(f"{self.name} Collection | ? | {current_title} (TVDB: {missing_id})") logger.info(f"{self.name} Collection | ? | {show.title} (TVDB: {missing_id})")
else: else:
if self.details["show_filtered"] is True and self.details["show_missing"] is True: if self.details["show_filtered"] is True and self.details["show_missing"] is True:
logger.info(f"{self.name} Collection | X | {current_title} (TVDb: {missing_id})") logger.info(f"{self.name} Collection | X | {show.title} (TVDb: {missing_id})")
logger.info("") logger.info("")
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 len(missing_shows_with_names) > 0: if len(missing_shows_with_names) > 0:
@ -1671,7 +1727,7 @@ class CollectionBuilder:
if self.library.Sonarr: if self.library.Sonarr:
if self.sonarr_details["add"]: if self.sonarr_details["add"]:
try: try:
self.library.Sonarr.add_tvdb(missing_tvdb_ids, **self.sonarr_details) added_to_sonarr += self.library.Sonarr.add_tvdb(missing_tvdb_ids, **self.sonarr_details)
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
if "item_sonarr_tag" in self.item_details: if "item_sonarr_tag" in self.item_details:
@ -1684,6 +1740,7 @@ class CollectionBuilder:
if len(self.missing_parts) > 0 and self.library.is_show and self.details["save_missing"] is True: if len(self.missing_parts) > 0 and self.library.is_show and self.details["save_missing"] is True:
for missing in self.missing_parts: for missing in self.missing_parts:
logger.info(f"{self.name} Collection | X | {missing}") logger.info(f"{self.name} Collection | X | {missing}")
return added_to_radarr, added_to_sonarr
def item_title(self, item): def item_title(self, item):
if self.collection_level == "season": if self.collection_level == "season":
@ -1702,34 +1759,10 @@ class CollectionBuilder:
else: else:
return item.title return item.title
def sync_collection(self): def load_collection_items(self):
count_removed = 0 if self.build_collection and self.obj:
for ratingKey, item in self.plex_map.items(): self.items = self.library.get_collection_items(self.obj, self.smart_label_collection)
if item is not None: elif not self.build_collection:
if count_removed == 0:
logger.info("")
util.separator(f"Removed from {self.name} Collection", space=False, border=False)
logger.info("")
self.library.reload(item)
logger.info(f"{self.name} Collection | - | {self.item_title(item)}")
if self.smart_label_collection:
self.library.query_data(item.removeLabel, self.name)
else:
self.library.query_data(item.removeCollection, self.name)
count_removed += 1
if count_removed > 0:
logger.info("")
logger.info(f"{count_removed} {self.collection_level.capitalize()}{'s' if count_removed == 1 else ''} Removed")
def update_item_details(self):
add_tags = self.item_details["item_label"] if "item_label" in self.item_details else None
remove_tags = self.item_details["item_label.remove"] if "item_label.remove" in self.item_details else None
sync_tags = self.item_details["item_label.sync"] if "item_label.sync" in self.item_details else None
if self.build_collection:
items = self.library.get_collection_items(self.obj, self.smart_label_collection)
else:
items = []
logger.info("") logger.info("")
util.separator(f"Items Found for {self.name} Collection", space=False, border=False) util.separator(f"Items Found for {self.name} Collection", space=False, border=False)
logger.info("") logger.info("")
@ -1737,10 +1770,13 @@ class CollectionBuilder:
try: try:
item = self.fetch_item(rk) item = self.fetch_item(rk)
logger.info(f"{item.title} (Rating Key: {rk})") logger.info(f"{item.title} (Rating Key: {rk})")
items.append(item) self.items.append(item)
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
if not self.items:
raise Failed(f"Plex Error: No Collection items found")
def update_item_details(self):
logger.info("") logger.info("")
util.separator(f"Updating Details of the Items in {self.name} Collection", space=False, border=False) util.separator(f"Updating Details of the Items in {self.name} Collection", space=False, border=False)
logger.info("") logger.info("")
@ -1759,6 +1795,7 @@ class CollectionBuilder:
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
continue continue
if isinstance(item, (Movie, Show)):
self.library.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"]) self.library.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"])
self.config.Cache.update_remove_overlay(self.library.image_table_name, overlay_name) self.config.Cache.update_remove_overlay(self.library.image_table_name, overlay_name)
rating_keys = [int(item.ratingKey) for item in self.library.get_labeled_items(f"{overlay_name} Overlay")] rating_keys = [int(item.ratingKey) for item in self.library.get_labeled_items(f"{overlay_name} Overlay")]
@ -1767,16 +1804,20 @@ class CollectionBuilder:
temp_image = os.path.join(overlay_folder, f"temp.png") temp_image = os.path.join(overlay_folder, f"temp.png")
overlay = (overlay_name, overlay_folder, overlay_image, temp_image) overlay = (overlay_name, overlay_folder, overlay_image, temp_image)
revert = "revert_overlay" in self.details and self.details["revert_overlay"] revert = "revert_overlay" in self.item_details
if revert: if revert:
overlay = None overlay = None
add_tags = self.item_details["item_label"] if "item_label" in self.item_details else None
remove_tags = self.item_details["item_label.remove"] if "item_label.remove" in self.item_details else None
sync_tags = self.item_details["item_label.sync"] if "item_label.sync" in self.item_details else None
tmdb_ids = [] tmdb_ids = []
tvdb_ids = [] tvdb_ids = []
for item in items: for item in self.items:
if int(item.ratingKey) in rating_keys and not revert: if int(item.ratingKey) in rating_keys and not revert:
rating_keys.remove(int(item.ratingKey)) rating_keys.remove(int(item.ratingKey))
if self.details["item_assets"] or overlay is not None: if "item_assets" in self.item_details or overlay is not None:
try: try:
self.library.update_item_from_assets(item, overlay=overlay) self.library.update_item_from_assets(item, overlay=overlay)
except Failed as e: except Failed as e:
@ -1793,6 +1834,8 @@ class CollectionBuilder:
if getattr(item, key) != options[method_data]: if getattr(item, key) != options[method_data]:
advance_edits[key] = options[method_data] advance_edits[key] = options[method_data]
self.library.edit_item(item, item.title, self.collection_level.capitalize(), advance_edits, advanced=True) self.library.edit_item(item, item.title, self.collection_level.capitalize(), advance_edits, advanced=True)
if "item_refresh" in self.item_details:
item.refresh()
if len(tmdb_ids) > 0: if len(tmdb_ids) > 0:
if "item_radarr_tag" in self.item_details: if "item_radarr_tag" in self.item_details:
@ -1823,7 +1866,7 @@ class CollectionBuilder:
if self.obj: if self.obj:
self.library.query(self.obj.delete) self.library.query(self.obj.delete)
def update_details(self): def load_collection(self):
if not self.obj and self.smart_url: if not self.obj and self.smart_url:
self.library.create_smart_collection(self.name, self.smart_type_key, self.smart_url) self.library.create_smart_collection(self.name, self.smart_type_key, self.smart_url)
elif self.smart_label_collection: elif self.smart_label_collection:
@ -1834,7 +1877,13 @@ class CollectionBuilder:
except Failed: except Failed:
raise Failed(f"Collection Error: Label: {self.name} was not added to any items in the Library") raise Failed(f"Collection Error: Label: {self.name} was not added to any items in the Library")
self.obj = self.library.get_collection(self.name) self.obj = self.library.get_collection(self.name)
if not self.exists:
self.created = True
def update_details(self):
logger.info("")
util.separator(f"Updating Details of {self.name} Collection", space=False, border=False)
logger.info("")
if self.smart_url and self.smart_url != self.library.smart_filter(self.obj): if self.smart_url and self.smart_url != self.library.smart_filter(self.obj):
self.library.update_smart_collection(self.obj, self.smart_url) self.library.update_smart_collection(self.obj, self.smart_url)
logger.info(f"Detail: Smart Filter updated to {self.smart_url}") logger.info(f"Detail: Smart Filter updated to {self.smart_url}")
@ -1983,6 +2032,9 @@ class CollectionBuilder:
self.library.upload_images(self.obj, poster=poster, background=background) self.library.upload_images(self.obj, poster=poster, background=background)
def sort_collection(self): def sort_collection(self):
logger.info("")
util.separator(f"Sorting {self.name} Collection", space=False, border=False)
logger.info("")
items = self.library.get_collection_items(self.obj, self.smart_label_collection) items = self.library.get_collection_items(self.obj, self.smart_label_collection)
keys = {item.ratingKey: item for item in items} keys = {item.ratingKey: item for item in items}
previous = None previous = None
@ -1994,10 +2046,27 @@ class CollectionBuilder:
self.library.move_item(self.obj, key, after=previous) self.library.move_item(self.obj, key, after=previous)
previous = key previous = key
def send_notifications(self):
if self.obj and (
(self.details["collection_creation_webhooks"] and self.created) or
(self.details["collection_addition_webhooks"] and len(self.notification_additions) > 0) or
(self.details["collection_removal_webhooks"] and len(self.notification_removals) > 0)
):
self.obj.reload()
self.library.Webhooks.collection_hooks(
self.details["collection_creation_webhooks"] + self.details["collection_addition_webhooks"] + self.details["collection_removal_webhooks"],
self.obj,
created=self.created,
additions=self.notification_additions,
removals=self.notification_removals
)
def run_collections_again(self): def run_collections_again(self):
self.obj = self.library.get_collection(self.name) self.obj = self.library.get_collection(self.name)
name, collection_items = self.library.get_collection_name_and_items(self.obj, self.smart_label_collection) name, collection_items = self.library.get_collection_name_and_items(self.obj, self.smart_label_collection)
self.created = False
rating_keys = [] rating_keys = []
self.notification_additions = []
for mm in self.run_again_movies: for mm in self.run_again_movies:
if mm in self.library.movie_map: if mm in self.library.movie_map:
rating_keys.extend(self.library.movie_map[mm]) rating_keys.extend(self.library.movie_map[mm])
@ -2015,8 +2084,16 @@ class CollectionBuilder:
if current in collection_items: if current in collection_items:
logger.info(f"{name} Collection | = | {self.item_title(current)}") logger.info(f"{name} Collection | = | {self.item_title(current)}")
else: else:
self.library.query_data(current.addLabel if self.smart_label_collection else current.addCollection, name) self.library.alter_collection(current, name, smart_label_collection=self.smart_label_collection)
logger.info(f"{name} Collection | + | {self.item_title(current)}") logger.info(f"{name} Collection | + | {self.item_title(current)}")
if self.library.is_movie and current.ratingKey in self.library.movie_rating_key_map:
add_id = self.library.movie_rating_key_map[current.ratingKey]
elif self.library.is_show and current.ratingKey in self.library.show_rating_key_map:
add_id = self.library.show_rating_key_map[current.ratingKey]
else:
add_id = None
self.notification_additions.append({"title": current.title, "id": add_id})
self.send_notifications()
logger.info(f"{len(rating_keys)} {self.collection_level.capitalize()}{'s' if len(rating_keys) > 1 else ''} Processed") logger.info(f"{len(rating_keys)} {self.collection_level.capitalize()}{'s' if len(rating_keys) > 1 else ''} Processed")
if len(self.run_again_movies) > 0: if len(self.run_again_movies) > 0:
@ -2039,7 +2116,7 @@ class CollectionBuilder:
for missing_id in self.run_again_shows: for missing_id in self.run_again_shows:
if missing_id not in self.library.show_map: if missing_id not in self.library.show_map:
try: try:
title = str(self.config.TVDb.get_series(self.language, missing_id).title.encode("ascii", "replace").decode()) title = self.config.TVDb.get_series(missing_id).title
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
continue continue

@ -1,4 +1,4 @@
import logging, os, requests import base64, logging, os, requests
from datetime import datetime from datetime import datetime
from lxml import html from lxml import html
from modules import util, radarr, sonarr from modules import util, radarr, sonarr
@ -10,6 +10,7 @@ from modules.icheckmovies import ICheckMovies
from modules.imdb import IMDb from modules.imdb import IMDb
from modules.letterboxd import Letterboxd from modules.letterboxd import Letterboxd
from modules.mal import MyAnimeList from modules.mal import MyAnimeList
from modules.notifiarr import Notifiarr
from modules.omdb import OMDb from modules.omdb import OMDb
from modules.plex import Plex from modules.plex import Plex
from modules.radarr import Radarr from modules.radarr import Radarr
@ -20,6 +21,7 @@ from modules.tmdb import TMDb
from modules.trakt import Trakt from modules.trakt import Trakt
from modules.tvdb import TVDb from modules.tvdb import TVDb
from modules.util import Failed from modules.util import Failed
from modules.webhooks import Webhooks
from retrying import retry from retrying import retry
from ruamel import yaml from ruamel import yaml
@ -29,21 +31,23 @@ sync_modes = {"append": "Only Add Items to the Collection", "sync": "Add & Remov
mass_update_options = {"tmdb": "Use TMDb Metadata", "omdb": "Use IMDb Metadata through OMDb"} mass_update_options = {"tmdb": "Use TMDb Metadata", "omdb": "Use IMDb Metadata through OMDb"}
class Config: class Config:
def __init__(self, default_dir, config_path=None, is_test=False, time_scheduled=None, requested_collections=None, requested_libraries=None, resume_from=None): def __init__(self, default_dir, attrs):
logger.info("Locating config...") logger.info("Locating config...")
if config_path and os.path.exists(config_path): self.config_path = os.path.abspath(config_path) config_file = attrs["config_file"]
elif config_path and not os.path.exists(config_path): raise Failed(f"Config Error: config not found at {os.path.abspath(config_path)}") if config_file and os.path.exists(config_file): self.config_path = os.path.abspath(config_file)
elif config_file and not os.path.exists(config_file): raise Failed(f"Config Error: config not found at {os.path.abspath(config_file)}")
elif os.path.exists(os.path.join(default_dir, "config.yml")): self.config_path = os.path.abspath(os.path.join(default_dir, "config.yml")) elif os.path.exists(os.path.join(default_dir, "config.yml")): self.config_path = os.path.abspath(os.path.join(default_dir, "config.yml"))
else: raise Failed(f"Config Error: config not found at {os.path.abspath(default_dir)}") else: raise Failed(f"Config Error: config not found at {os.path.abspath(default_dir)}")
logger.info(f"Using {self.config_path} as config") logger.info(f"Using {self.config_path} as config")
self.default_dir = default_dir self.default_dir = default_dir
self.test_mode = is_test self.test_mode = attrs["test"] if "test" in attrs else False
self.run_start_time = time_scheduled self.trace_mode = attrs["trace"] if "trace" in attrs else False
self.run_hour = datetime.strptime(time_scheduled, "%H:%M").hour self.run_start_time = attrs["time"]
self.requested_collections = util.get_list(requested_collections) self.run_hour = datetime.strptime(attrs["time"], "%H:%M").hour
self.requested_libraries = util.get_list(requested_libraries) self.requested_collections = util.get_list(attrs["collections"]) if "collections" in attrs else None
self.resume_from = resume_from self.requested_libraries = util.get_list(attrs["libraries"]) if "libraries" in attrs else None
self.resume_from = attrs["resume"] if "resume" in attrs else None
yaml.YAML().allow_duplicate_keys = True yaml.YAML().allow_duplicate_keys = True
try: try:
@ -81,15 +85,17 @@ class Config:
replace_attr(new_config["libraries"][library], "save_missing", "plex") replace_attr(new_config["libraries"][library], "save_missing", "plex")
if "libraries" in new_config: new_config["libraries"] = new_config.pop("libraries") if "libraries" in new_config: new_config["libraries"] = new_config.pop("libraries")
if "settings" in new_config: new_config["settings"] = new_config.pop("settings") if "settings" in new_config: new_config["settings"] = new_config.pop("settings")
if "webhooks" in new_config: new_config["webhooks"] = new_config.pop("webhooks")
if "plex" in new_config: new_config["plex"] = new_config.pop("plex") if "plex" in new_config: new_config["plex"] = new_config.pop("plex")
if "tmdb" in new_config: new_config["tmdb"] = new_config.pop("tmdb") if "tmdb" in new_config: new_config["tmdb"] = new_config.pop("tmdb")
if "tautulli" in new_config: new_config["tautulli"] = new_config.pop("tautulli") if "tautulli" in new_config: new_config["tautulli"] = new_config.pop("tautulli")
if "omdb" in new_config: new_config["omdb"] = new_config.pop("omdb")
if "notifiarr" in new_config: new_config["notifiarr"] = new_config.pop("notifiarr")
if "anidb" in new_config: new_config["anidb"] = new_config.pop("anidb")
if "radarr" in new_config: new_config["radarr"] = new_config.pop("radarr") if "radarr" in new_config: new_config["radarr"] = new_config.pop("radarr")
if "sonarr" in new_config: new_config["sonarr"] = new_config.pop("sonarr") if "sonarr" in new_config: new_config["sonarr"] = new_config.pop("sonarr")
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")
if "anidb" in new_config: new_config["anidb"] = new_config.pop("anidb")
yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=None, block_seq_indent=2) yaml.round_trip_dump(new_config, open(self.config_path, "w", encoding="utf-8"), indent=None, block_seq_indent=2)
self.data = new_config self.data = new_config
except yaml.scanner.ScannerError as e: except yaml.scanner.ScannerError as e:
@ -132,9 +138,9 @@ class Config:
elif var_type == "path": elif var_type == "path":
if os.path.exists(os.path.abspath(data[attribute])): return data[attribute] if os.path.exists(os.path.abspath(data[attribute])): return data[attribute]
else: message = f"Path {os.path.abspath(data[attribute])} does not exist" else: message = f"Path {os.path.abspath(data[attribute])} does not exist"
elif var_type == "list": return util.get_list(data[attribute]) elif var_type == "list": return util.get_list(data[attribute], split=False)
elif var_type == "list_path": elif var_type == "list_path":
temp_list = [p for p in util.get_list(data[attribute], split=True) if os.path.exists(os.path.abspath(p))] temp_list = [p for p in util.get_list(data[attribute], split=False) if os.path.exists(os.path.abspath(p))]
if len(temp_list) > 0: return temp_list if len(temp_list) > 0: return temp_list
else: message = "No Paths exist" else: message = "No Paths exist"
elif var_type == "lower_list": return util.get_list(data[attribute], lower=True) elif var_type == "lower_list": return util.get_list(data[attribute], lower=True)
@ -176,17 +182,27 @@ class Config:
"cache_expiration": check_for_attribute(self.data, "cache_expiration", parent="settings", var_type="int", default=60), "cache_expiration": check_for_attribute(self.data, "cache_expiration", parent="settings", var_type="int", default=60),
"asset_directory": check_for_attribute(self.data, "asset_directory", parent="settings", var_type="list_path", default=[os.path.join(default_dir, "assets")]), "asset_directory": check_for_attribute(self.data, "asset_directory", parent="settings", var_type="list_path", default=[os.path.join(default_dir, "assets")]),
"asset_folders": check_for_attribute(self.data, "asset_folders", parent="settings", var_type="bool", default=True), "asset_folders": check_for_attribute(self.data, "asset_folders", parent="settings", var_type="bool", default=True),
"assets_for_all": check_for_attribute(self.data, "assets_for_all", parent="settings", var_type="bool", default=False), "assets_for_all": check_for_attribute(self.data, "assets_for_all", parent="settings", var_type="bool", default=False, save=False, do_print=False),
"sync_mode": check_for_attribute(self.data, "sync_mode", parent="settings", default="append", test_list=sync_modes), "sync_mode": check_for_attribute(self.data, "sync_mode", parent="settings", default="append", test_list=sync_modes),
"run_again_delay": check_for_attribute(self.data, "run_again_delay", parent="settings", var_type="int", default=0), "run_again_delay": check_for_attribute(self.data, "run_again_delay", parent="settings", var_type="int", default=0),
"show_unmanaged": check_for_attribute(self.data, "show_unmanaged", parent="settings", var_type="bool", default=True), "show_unmanaged": check_for_attribute(self.data, "show_unmanaged", parent="settings", var_type="bool", default=True),
"show_filtered": check_for_attribute(self.data, "show_filtered", parent="settings", var_type="bool", default=False), "show_filtered": check_for_attribute(self.data, "show_filtered", parent="settings", var_type="bool", default=False),
"show_missing": check_for_attribute(self.data, "show_missing", parent="settings", var_type="bool", default=True), "show_missing": check_for_attribute(self.data, "show_missing", parent="settings", var_type="bool", default=True),
"show_missing_assets": check_for_attribute(self.data, "show_missing_assets", parent="settings", var_type="bool", default=True),
"save_missing": check_for_attribute(self.data, "save_missing", parent="settings", var_type="bool", default=True), "save_missing": check_for_attribute(self.data, "save_missing", parent="settings", var_type="bool", default=True),
"missing_only_released": check_for_attribute(self.data, "missing_only_released", parent="settings", var_type="bool", default=False), "missing_only_released": check_for_attribute(self.data, "missing_only_released", parent="settings", var_type="bool", default=False),
"create_asset_folders": check_for_attribute(self.data, "create_asset_folders", parent="settings", var_type="bool", default=False), "create_asset_folders": check_for_attribute(self.data, "create_asset_folders", parent="settings", var_type="bool", default=False),
"collection_minimum": check_for_attribute(self.data, "collection_minimum", parent="settings", var_type="int", default=1), "collection_minimum": check_for_attribute(self.data, "collection_minimum", parent="settings", var_type="int", default=1),
"delete_below_minimum": check_for_attribute(self.data, "delete_below_minimum", parent="settings", var_type="bool", default=False) "delete_below_minimum": check_for_attribute(self.data, "delete_below_minimum", parent="settings", var_type="bool", default=False),
"tvdb_language": check_for_attribute(self.data, "tvdb_language", parent="settings", default="default")
}
self.webhooks = {
"error": check_for_attribute(self.data, "error", parent="webhooks", var_type="list", default_is_none=True),
"run_start": check_for_attribute(self.data, "run_start", parent="webhooks", var_type="list", default_is_none=True),
"run_end": check_for_attribute(self.data, "run_end", parent="webhooks", var_type="list", default_is_none=True),
"collection_creation": check_for_attribute(self.data, "collection_creation", parent="webhooks", var_type="list", default_is_none=True),
"collection_addition": check_for_attribute(self.data, "collection_addition", parent="webhooks", var_type="list", default_is_none=True),
"collection_removal": check_for_attribute(self.data, "collection_removal", parent="webhooks", var_type="list", default_is_none=True),
} }
if self.general["cache"]: if self.general["cache"]:
util.separator() util.separator()
@ -196,6 +212,29 @@ class Config:
util.separator() util.separator()
self.NotifiarrFactory = None
if "notifiarr" in self.data:
logger.info("Connecting to Notifiarr...")
try:
self.NotifiarrFactory = Notifiarr(self, {
"apikey": check_for_attribute(self.data, "apikey", parent="notifiarr", throw=True),
"develop": check_for_attribute(self.data, "develop", parent="notifiarr", var_type="bool", default=False, do_print=False, save=False),
"test": check_for_attribute(self.data, "test", parent="notifiarr", var_type="bool", default=False, do_print=False, save=False)
})
except Failed as e:
logger.error(e)
logger.info(f"Notifiarr Connection {'Failed' if self.NotifiarrFactory is None else 'Successful'}")
else:
logger.warning("notifiarr attribute not found")
self.Webhooks = Webhooks(self, self.webhooks, notifiarr=self.NotifiarrFactory)
self.Webhooks.start_time_hooks(self.run_start_time)
self.errors = []
util.separator()
try:
self.TMDb = None self.TMDb = None
if "tmdb" in self.data: if "tmdb" in self.data:
logger.info("Connecting to TMDb...") logger.info("Connecting to TMDb...")
@ -215,6 +254,7 @@ class Config:
try: try:
self.OMDb = OMDb(self, {"apikey": check_for_attribute(self.data, "apikey", parent="omdb", throw=True)}) self.OMDb = OMDb(self, {"apikey": check_for_attribute(self.data, "apikey", parent="omdb", throw=True)})
except Failed as e: except Failed as e:
self.errors.append(e)
logger.error(e) logger.error(e)
logger.info(f"OMDb Connection {'Failed' if self.OMDb is None else 'Successful'}") logger.info(f"OMDb Connection {'Failed' if self.OMDb is None else 'Successful'}")
else: else:
@ -233,6 +273,7 @@ class Config:
"authorization": self.data["trakt"]["authorization"] if "authorization" in self.data["trakt"] else None "authorization": self.data["trakt"]["authorization"] if "authorization" in self.data["trakt"] else None
}) })
except Failed as e: except Failed as e:
self.errors.append(e)
logger.error(e) logger.error(e)
logger.info(f"Trakt Connection {'Failed' if self.Trakt is None else 'Successful'}") logger.info(f"Trakt Connection {'Failed' if self.Trakt is None else 'Successful'}")
else: else:
@ -251,6 +292,7 @@ class Config:
"authorization": self.data["mal"]["authorization"] if "authorization" in self.data["mal"] else None "authorization": self.data["mal"]["authorization"] if "authorization" in self.data["mal"] else None
}) })
except Failed as e: except Failed as e:
self.errors.append(e)
logger.error(e) logger.error(e)
logger.info(f"My Anime List Connection {'Failed' if self.MyAnimeList is None else 'Successful'}") logger.info(f"My Anime List Connection {'Failed' if self.MyAnimeList is None else 'Successful'}")
else: else:
@ -268,12 +310,13 @@ class Config:
"password": check_for_attribute(self.data, "password", parent="anidb", throw=True) "password": check_for_attribute(self.data, "password", parent="anidb", throw=True)
}) })
except Failed as e: except Failed as e:
self.errors.append(e)
logger.error(e) logger.error(e)
logger.info(f"My Anime List Connection {'Failed Continuing as Guest ' if self.MyAnimeList is None else 'Successful'}") logger.info(f"My Anime List Connection {'Failed Continuing as Guest ' if self.MyAnimeList is None else 'Successful'}")
if self.AniDB is None: if self.AniDB is None:
self.AniDB = AniDB(self, None) self.AniDB = AniDB(self, None)
self.TVDb = TVDb(self) self.TVDb = TVDb(self, self.general["tvdb_language"])
self.IMDb = IMDb(self) self.IMDb = IMDb(self)
self.Convert = Convert(self) self.Convert = Convert(self)
self.AniList = AniList(self) self.AniList = AniList(self)
@ -347,40 +390,70 @@ 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")
params["asset_folders"] = check_for_attribute(lib, "asset_folders", parent="settings", var_type="bool", default=self.general["asset_folders"], do_print=False, save=False) params["asset_folders"] = check_for_attribute(lib, "asset_folders", parent="settings", var_type="bool", default=self.general["asset_folders"], do_print=False, save=False)
params["assets_for_all"] = check_for_attribute(lib, "assets_for_all", parent="settings", var_type="bool", default=self.general["assets_for_all"], 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) 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)
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)
params["show_filtered"] = check_for_attribute(lib, "show_filtered", parent="settings", var_type="bool", default=self.general["show_filtered"], do_print=False, save=False) params["show_filtered"] = check_for_attribute(lib, "show_filtered", parent="settings", var_type="bool", default=self.general["show_filtered"], do_print=False, save=False)
params["show_missing"] = check_for_attribute(lib, "show_missing", parent="settings", var_type="bool", default=self.general["show_missing"], do_print=False, save=False) params["show_missing"] = check_for_attribute(lib, "show_missing", parent="settings", var_type="bool", default=self.general["show_missing"], do_print=False, save=False)
params["show_missing_assets"] = check_for_attribute(lib, "show_missing_assets", parent="settings", var_type="bool", default=self.general["show_missing_assets"], do_print=False, save=False)
params["save_missing"] = check_for_attribute(lib, "save_missing", parent="settings", var_type="bool", default=self.general["save_missing"], do_print=False, save=False) params["save_missing"] = check_for_attribute(lib, "save_missing", parent="settings", var_type="bool", default=self.general["save_missing"], do_print=False, save=False)
params["missing_only_released"] = check_for_attribute(lib, "missing_only_released", parent="settings", var_type="bool", default=self.general["missing_only_released"], do_print=False, save=False) params["missing_only_released"] = check_for_attribute(lib, "missing_only_released", parent="settings", var_type="bool", default=self.general["missing_only_released"], do_print=False, save=False)
params["create_asset_folders"] = check_for_attribute(lib, "create_asset_folders", parent="settings", var_type="bool", default=self.general["create_asset_folders"], do_print=False, save=False) params["create_asset_folders"] = check_for_attribute(lib, "create_asset_folders", parent="settings", var_type="bool", default=self.general["create_asset_folders"], do_print=False, save=False)
params["collection_minimum"] = check_for_attribute(lib, "collection_minimum", parent="settings", var_type="int", default=self.general["collection_minimum"], do_print=False, save=False) params["collection_minimum"] = check_for_attribute(lib, "collection_minimum", parent="settings", var_type="int", default=self.general["collection_minimum"], do_print=False, save=False)
params["delete_below_minimum"] = check_for_attribute(lib, "delete_below_minimum", parent="settings", var_type="bool", default=self.general["delete_below_minimum"], do_print=False, save=False) params["delete_below_minimum"] = check_for_attribute(lib, "delete_below_minimum", parent="settings", var_type="bool", default=self.general["delete_below_minimum"], do_print=False, save=False)
params["delete_unmanaged_collections"] = check_for_attribute(lib, "delete_unmanaged_collections", parent="settings", var_type="bool", default=False, do_print=False, save=False)
params["delete_collections_with_less"] = check_for_attribute(lib, "delete_collections_with_less", parent="settings", var_type="int", default_is_none=True, do_print=False, save=False)
params["error_webhooks"] = check_for_attribute(lib, "error", parent="webhooks", var_type="list", default=self.webhooks["error"], do_print=False, save=False, default_is_none=True)
params["collection_creation_webhooks"] = check_for_attribute(lib, "collection_creation", parent="webhooks", var_type="list", default=self.webhooks["collection_creation"], do_print=False, save=False, default_is_none=True)
params["collection_addition_webhooks"] = check_for_attribute(lib, "collection_addition", parent="webhooks", var_type="list", default=self.webhooks["collection_addition"], do_print=False, save=False, default_is_none=True)
params["collection_removal_webhooks"] = check_for_attribute(lib, "collection_removal", parent="webhooks", var_type="list", default=self.webhooks["collection_removal"], do_print=False, save=False, default_is_none=True)
params["assets_for_all"] = check_for_attribute(lib, "assets_for_all", parent="settings", var_type="bool", default=self.general["assets_for_all"], do_print=False, save=False)
params["mass_genre_update"] = check_for_attribute(lib, "mass_genre_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=False)
params["mass_audience_rating_update"] = check_for_attribute(lib, "mass_audience_rating_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=False)
params["mass_critic_rating_update"] = check_for_attribute(lib, "mass_critic_rating_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=False)
params["mass_trakt_rating_update"] = check_for_attribute(lib, "mass_trakt_rating_update", var_type="bool", default=False, save=False, do_print=False)
params["split_duplicates"] = check_for_attribute(lib, "split_duplicates", var_type="bool", default=False, save=False, do_print=False)
params["radarr_add_all"] = check_for_attribute(lib, "radarr_add_all", var_type="bool", default=False, save=False, do_print=False)
params["sonarr_add_all"] = check_for_attribute(lib, "sonarr_add_all", var_type="bool", default=False, save=False, do_print=False)
if lib and "operations" in lib and lib["operations"]:
if isinstance(lib["operations"], dict):
if "assets_for_all" in lib["operations"]:
params["assets_for_all"] = check_for_attribute(lib["operations"], "assets_for_all", var_type="bool", default=False, save=False)
if "delete_unmanaged_collections" in lib["operations"]:
params["delete_unmanaged_collections"] = check_for_attribute(lib["operations"], "delete_unmanaged_collections", var_type="bool", default=False, save=False)
if "delete_collections_with_less" in lib["operations"]:
params["delete_collections_with_less"] = check_for_attribute(lib["operations"], "delete_collections_with_less", var_type="int", default_is_none=True, save=False)
if "mass_genre_update" in lib["operations"]:
params["mass_genre_update"] = check_for_attribute(lib["operations"], "mass_genre_update", test_list=mass_update_options, default_is_none=True, save=False)
if "mass_audience_rating_update" in lib["operations"]:
params["mass_audience_rating_update"] = check_for_attribute(lib["operations"], "mass_audience_rating_update", test_list=mass_update_options, default_is_none=True, save=False)
if "mass_critic_rating_update" in lib["operations"]:
params["mass_critic_rating_update"] = check_for_attribute(lib["operations"], "mass_critic_rating_update", test_list=mass_update_options, default_is_none=True, save=False)
if "mass_trakt_rating_update" in lib["operations"]:
params["mass_trakt_rating_update"] = check_for_attribute(lib["operations"], "mass_trakt_rating_update", var_type="bool", default=False, save=False)
if "split_duplicates" in lib["operations"]:
params["split_duplicates"] = check_for_attribute(lib["operations"], "split_duplicates", var_type="bool", default=False, save=False)
if "radarr_add_all" in lib["operations"]:
params["radarr_add_all"] = check_for_attribute(lib["operations"], "radarr_add_all", var_type="bool", default=False, save=False)
if "sonarr_add_all" in lib["operations"]:
params["sonarr_add_all"] = check_for_attribute(lib["operations"], "sonarr_add_all", var_type="bool", default=False, save=False)
else:
logger.error("Config Error: operations must be a dictionary")
params["mass_genre_update"] = check_for_attribute(lib, "mass_genre_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=lib and "mass_genre_update" in lib) def error_check(attr, service):
if self.OMDb is None and params["mass_genre_update"] == "omdb": params[attr] = None
params["mass_genre_update"] = None err = f"Config Error: {attr} cannot be omdb without a successful {service} Connection"
logger.error("Config Error: mass_genre_update cannot be omdb without a successful OMDb Connection") self.errors.append(err)
logger.error(err)
params["mass_audience_rating_update"] = check_for_attribute(lib, "mass_audience_rating_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=lib and "mass_audience_rating_update" in lib) if self.OMDb is None and params["mass_genre_update"] == "omdb":
error_check("mass_genre_update", "OMDb")
if self.OMDb is None and params["mass_audience_rating_update"] == "omdb": if self.OMDb is None and params["mass_audience_rating_update"] == "omdb":
params["mass_audience_rating_update"] = None error_check("mass_audience_rating_update", "OMDb")
logger.error("Config Error: mass_audience_rating_update cannot be omdb without a successful OMDb Connection")
params["mass_critic_rating_update"] = check_for_attribute(lib, "mass_critic_rating_update", test_list=mass_update_options, default_is_none=True, save=False, do_print=lib and "mass_audience_rating_update" in lib)
if self.OMDb is None and params["mass_critic_rating_update"] == "omdb": if self.OMDb is None and params["mass_critic_rating_update"] == "omdb":
params["mass_critic_rating_update"] = None error_check("mass_critic_rating_update", "OMDb")
logger.error("Config Error: mass_critic_rating_update cannot be omdb without a successful OMDb Connection")
params["mass_trakt_rating_update"] = check_for_attribute(lib, "mass_trakt_rating_update", var_type="bool", default=False, save=False, do_print=lib and "mass_trakt_rating_update" in lib)
if self.Trakt is None and params["mass_trakt_rating_update"]: if self.Trakt is None and params["mass_trakt_rating_update"]:
params["mass_trakt_rating_update"] = None error_check("mass_trakt_rating_update", "Trakt")
logger.error("Config Error: mass_trakt_rating_update cannot run without a successful Trakt Connection")
params["split_duplicates"] = check_for_attribute(lib, "split_duplicates", var_type="bool", default=False, save=False, do_print=lib and "split_duplicates" in lib)
params["radarr_add_all"] = check_for_attribute(lib, "radarr_add_all", var_type="bool", default=False, save=False, do_print=lib and "radarr_add_all" in lib)
params["sonarr_add_all"] = check_for_attribute(lib, "sonarr_add_all", var_type="bool", default=False, save=False, do_print=lib and "sonarr_add_all" in lib)
try: try:
if lib and "metadata_path" in lib: if lib and "metadata_path" in lib:
@ -393,7 +466,9 @@ class Config:
def check_dict(attr, name): def check_dict(attr, name):
if attr in path: if attr in path:
if path[attr] is None: if path[attr] is None:
logger.error(f"Config Error: metadata_path {attr} is blank") err = f"Config Error: metadata_path {attr} is blank"
self.errors.append(err)
logger.error(err)
else: else:
params["metadata_path"].append((name, path[attr])) params["metadata_path"].append((name, path[attr]))
check_dict("url", "URL") check_dict("url", "URL")
@ -417,6 +492,7 @@ class Config:
logger.info("") logger.info("")
logger.info(f"{display_name} Library Connection Successful") logger.info(f"{display_name} Library Connection Successful")
except Failed as e: except Failed as e:
self.errors.append(e)
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.info(f"{display_name} Library Connection Failed") logger.info(f"{display_name} Library Connection Failed")
@ -442,6 +518,7 @@ class Config:
"search": check_for_attribute(lib, "search", parent="radarr", var_type="bool", default=self.general["radarr"]["search"], save=False) "search": check_for_attribute(lib, "search", parent="radarr", var_type="bool", default=self.general["radarr"]["search"], save=False)
}) })
except Failed as e: except Failed as e:
self.errors.append(e)
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.info("") logger.info("")
@ -470,6 +547,7 @@ class Config:
"cutoff_search": check_for_attribute(lib, "cutoff_search", parent="sonarr", var_type="bool", default=self.general["sonarr"]["cutoff_search"], save=False) "cutoff_search": check_for_attribute(lib, "cutoff_search", parent="sonarr", var_type="bool", default=self.general["sonarr"]["cutoff_search"], save=False)
}) })
except Failed as e: except Failed as e:
self.errors.append(e)
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.info("") logger.info("")
@ -487,11 +565,14 @@ class Config:
"apikey": check_for_attribute(lib, "apikey", parent="tautulli", default=self.general["tautulli"]["apikey"], req_default=True, save=False) "apikey": check_for_attribute(lib, "apikey", parent="tautulli", default=self.general["tautulli"]["apikey"], req_default=True, save=False)
}) })
except Failed as e: except Failed as e:
self.errors.append(e)
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.info("") logger.info("")
logger.info(f"{display_name} library's Tautulli Connection {'Failed' if library.Tautulli is None else 'Successful'}") logger.info(f"{display_name} library's Tautulli Connection {'Failed' if library.Tautulli is None else 'Successful'}")
library.Webhooks = Webhooks(self, {"error_webhooks": library.error_webhooks}, library=library, notifiarr=self.NotifiarrFactory)
logger.info("") logger.info("")
self.libraries.append(library) self.libraries.append(library)
@ -504,15 +585,28 @@ class Config:
util.separator() util.separator()
if self.errors:
self.notify(self.errors)
except Exception as e:
self.notify(e)
raise
def notify(self, text, library=None, collection=None, critical=True):
for error in util.get_list(text, split=False):
self.Webhooks.error_hooks(error, library=library, collection=collection, critical=critical)
def get_html(self, url, headers=None, params=None): def get_html(self, url, headers=None, params=None):
return html.fromstring(self.get(url, headers=headers, params=params).content) return html.fromstring(self.get(url, headers=headers, params=params).content)
def get_json(self, url, headers=None): def get_json(self, url, json=None, headers=None, params=None):
return self.get(url, headers=headers).json() return self.get(url, json=json, headers=headers, params=params).json()
@retry(stop_max_attempt_number=6, wait_fixed=10000) @retry(stop_max_attempt_number=6, wait_fixed=10000)
def get(self, url, headers=None, params=None): def get(self, url, json=None, headers=None, params=None):
return self.session.get(url, headers=headers, params=params) return self.session.get(url, json=json, headers=headers, params=params)
def get_image_encoded(self, url):
return base64.b64encode(self.get(url).content).decode('utf-8')
def post_html(self, url, data=None, json=None, headers=None): def post_html(self, url, data=None, json=None, headers=None):
return html.fromstring(self.post(url, data=data, json=json, headers=headers).content) return html.fromstring(self.post(url, data=data, json=json, headers=headers).content)

@ -62,10 +62,10 @@ class Convert:
def myanimelist_to_ids(self, mal_ids, library): def myanimelist_to_ids(self, mal_ids, library):
ids = [] ids = []
for mal_id in mal_ids: for mal_id in mal_ids:
if mal_id in library.mal_map: if int(mal_id) in library.mal_map:
ids.append((library.mal_map[mal_id], "ratingKey")) ids.append((library.mal_map[int(mal_id)], "ratingKey"))
elif mal_id in self.mal_to_anidb: elif int(mal_id) in self.mal_to_anidb:
ids.extend(self.anidb_to_ids(self.mal_to_anidb[mal_id], library)) ids.extend(self.anidb_to_ids(self.mal_to_anidb[int(mal_id)], library))
else: else:
logger.error(f"Convert Error: AniDB ID not found for MyAnimeList ID: {mal_id}") logger.error(f"Convert Error: AniDB ID not found for MyAnimeList ID: {mal_id}")
return ids return ids
@ -193,19 +193,23 @@ class Convert:
tvdb_id = [] tvdb_id = []
imdb_id = [] imdb_id = []
anidb_id = None anidb_id = None
guid = requests.utils.urlparse(item.guid)
item_type = guid.scheme.split(".")[-1]
check_id = guid.netloc
if self.config.Cache: if self.config.Cache:
cache_id, imdb_check, media_type, expired = self.config.Cache.query_guid_map(item.guid) cache_id, imdb_check, media_type, expired = self.config.Cache.query_guid_map(item.guid)
if cache_id and not expired: if cache_id and not expired:
media_id_type = "movie" if "movie" in media_type else "show" media_id_type = "movie" if "movie" in media_type else "show"
if item_type == "hama" and check_id.startswith("anidb"):
anidb_id = int(re.search("-(.*)", check_id).group(1))
library.anidb_map[anidb_id] = item.ratingKey
elif item_type == "myanimelist":
library.mal_map[int(check_id)] = item.ratingKey
return media_id_type, cache_id, imdb_check return media_id_type, cache_id, imdb_check
try: try:
guid = requests.utils.urlparse(item.guid)
item_type = guid.scheme.split(".")[-1]
check_id = guid.netloc
if item_type == "plex": if item_type == "plex":
try: try:
for guid_tag in library.get_guids(item): for guid_tag in item.guids:
url_parsed = requests.utils.urlparse(guid_tag.id) url_parsed = requests.utils.urlparse(guid_tag.id)
if url_parsed.scheme == "tvdb": tvdb_id.append(int(url_parsed.netloc)) if url_parsed.scheme == "tvdb": tvdb_id.append(int(url_parsed.netloc))
elif url_parsed.scheme == "imdb": imdb_id.append(url_parsed.netloc) elif url_parsed.scheme == "imdb": imdb_id.append(url_parsed.netloc)
@ -230,8 +234,8 @@ class Convert:
raise Failed(f"Hama Agent ID: {check_id} not supported") raise Failed(f"Hama Agent ID: {check_id} not supported")
elif item_type == "myanimelist": elif item_type == "myanimelist":
library.mal_map[int(check_id)] = item.ratingKey library.mal_map[int(check_id)] = item.ratingKey
if check_id in self.mal_to_anidb: if int(check_id) in self.mal_to_anidb:
anidb_id = self.mal_to_anidb[check_id] anidb_id = self.mal_to_anidb[int(check_id)]
else: else:
raise Failed(f"Convert Error: AniDB ID not found for MyAnimeList ID: {check_id}") raise Failed(f"Convert Error: AniDB ID not found for MyAnimeList ID: {check_id}")
elif item_type == "local": raise Failed("No match in Plex") elif item_type == "local": raise Failed("No match in Plex")

@ -12,6 +12,8 @@ class ICheckMovies:
self.config = config self.config = config
def _request(self, url, language, xpath): def _request(self, url, language, xpath):
if self.config.trace_mode:
logger.debug(f"URL: {url}")
return self.config.get_html(url, headers=util.header(language)).xpath(xpath) return self.config.get_html(url, headers=util.header(language)).xpath(xpath)
def _parse_list(self, list_url, language): def _parse_list(self, list_url, language):

@ -8,17 +8,11 @@ logger = logging.getLogger("Plex Meta Manager")
builders = ["imdb_list", "imdb_id"] builders = ["imdb_list", "imdb_id"]
base_url = "https://www.imdb.com" base_url = "https://www.imdb.com"
urls = { urls = {
"list": f"{base_url}/list/ls", "lists": f"{base_url}/list/ls",
"search": f"{base_url}/search/title/", "searches": f"{base_url}/search/title/",
"keyword": f"{base_url}/search/keyword/" "keyword_searches": f"{base_url}/search/keyword/",
"filmography_searches": f"{base_url}/filmosearch/"
} }
xpath = {
"imdb_id": "//div[contains(@class, 'lister-item-image')]//a/img//@data-tconst",
"list": "//div[@class='desc lister-total-num-results']/text()",
"search": "//div[@class='desc']/span/text()",
"keyword": "//div[@class='desc']/text()"
}
item_counts = {"list": 100, "search": 250, "keyword": 50}
class IMDb: class IMDb:
def __init__(self, config): def __init__(self, config):
@ -31,22 +25,25 @@ class IMDb:
imdb_dict = {"url": imdb_dict} imdb_dict = {"url": imdb_dict}
dict_methods = {dm.lower(): dm for dm in imdb_dict} dict_methods = {dm.lower(): dm for dm in imdb_dict}
imdb_url = util.parse("url", imdb_dict, methods=dict_methods, parent="imdb_list").strip() imdb_url = util.parse("url", imdb_dict, methods=dict_methods, parent="imdb_list").strip()
if not imdb_url.startswith((urls["list"], urls["search"], urls["keyword"])): if not imdb_url.startswith(tuple([v for k, v in urls.items()])):
raise Failed(f"IMDb Error: {imdb_url} must begin with either:\n{urls['list']} (For Lists)\n{urls['search']} (For Searches)\n{urls['keyword']} (For Keyword Searches)") fails = "\n".join([f"{v} (For {k.replace('_', ' ').title()})" for k, v in urls.items()])
raise Failed(f"IMDb Error: {imdb_url} must begin with either:{fails}")
self._total(imdb_url, language) self._total(imdb_url, language)
list_count = util.parse("limit", imdb_dict, datatype="int", methods=dict_methods, default=0, parent="imdb_list", minimum=0) if "limit" in dict_methods else 0 list_count = util.parse("limit", imdb_dict, datatype="int", methods=dict_methods, default=0, parent="imdb_list", minimum=0) if "limit" in dict_methods else 0
valid_lists.append({"url": imdb_url, "limit": list_count}) valid_lists.append({"url": imdb_url, "limit": list_count})
return valid_lists return valid_lists
def _total(self, imdb_url, language): def _total(self, imdb_url, language):
headers = util.header(language) if imdb_url.startswith(urls["lists"]):
if imdb_url.startswith(urls["keyword"]): xpath_total = "//div[@class='desc lister-total-num-results']/text()"
page_type = "keyword" per_page = 100
elif imdb_url.startswith(urls["list"]): elif imdb_url.startswith(urls["searches"]):
page_type = "list" xpath_total = "//div[@class='desc']/span/text()"
per_page = 250
else: else:
page_type = "search" xpath_total = "//div[@class='desc']/text()"
results = self.config.get_html(imdb_url, headers=headers).xpath(xpath[page_type]) per_page = 50
results = self.config.get_html(imdb_url, headers=util.header(language)).xpath(xpath_total)
total = 0 total = 0
for result in results: for result in results:
if "title" in result: if "title" in result:
@ -56,8 +53,8 @@ class IMDb:
except IndexError: except IndexError:
pass pass
if total > 0: if total > 0:
return total, item_counts[page_type] return total, per_page
raise ValueError(f"IMDb Error: Failed to parse URL: {imdb_url}") raise Failed(f"IMDb Error: Failed to parse URL: {imdb_url}")
def _ids_from_url(self, imdb_url, language, limit): def _ids_from_url(self, imdb_url, language, limit):
total, item_count = self._total(imdb_url, language) total, item_count = self._total(imdb_url, language)
@ -66,10 +63,13 @@ class IMDb:
parsed_url = urlparse(imdb_url) parsed_url = urlparse(imdb_url)
params = parse_qs(parsed_url.query) params = parse_qs(parsed_url.query)
imdb_base = parsed_url._replace(query=None).geturl() imdb_base = parsed_url._replace(query=None).geturl()
params.pop("start", None) params.pop("start", None) # noqa
params.pop("count", None) params.pop("count", None) # noqa
params.pop("page", None) params.pop("page", None) # noqa
if self.config.trace_mode:
logger.debug(f"URL: {imdb_base}")
logger.debug(f"Params: {params}")
search_url = imdb_base.startswith(urls["searches"])
if limit < 1 or total < limit: if limit < 1 or total < limit:
limit = total limit = total
remainder = limit % item_count remainder = limit % item_count
@ -79,13 +79,14 @@ class IMDb:
for i in range(1, num_of_pages + 1): for i in range(1, num_of_pages + 1):
start_num = (i - 1) * item_count + 1 start_num = (i - 1) * item_count + 1
util.print_return(f"Parsing Page {i}/{num_of_pages} {start_num}-{limit if i == num_of_pages else i * item_count}") util.print_return(f"Parsing Page {i}/{num_of_pages} {start_num}-{limit if i == num_of_pages else i * item_count}")
if imdb_base.startswith((urls["list"], urls["keyword"])): if search_url:
params["page"] = i params["count"] = remainder if i == num_of_pages else item_count # noqa
params["start"] = start_num # noqa
else: else:
params["count"] = remainder if i == num_of_pages else item_count params["page"] = i # noqa
params["start"] = start_num response = self.config.get_html(imdb_base, headers=headers, params=params)
ids_found = self.config.get_html(imdb_base, headers=headers, params=params).xpath(xpath["imdb_id"]) ids_found = response.xpath("//div[contains(@class, 'lister-item-image')]//a/img//@data-tconst")
if imdb_base.startswith((urls["list"], urls["keyword"])) and i == num_of_pages: if not search_url and i == num_of_pages:
ids_found = ids_found[:remainder] ids_found = ids_found[:remainder]
imdb_ids.extend(ids_found) imdb_ids.extend(ids_found)
time.sleep(2) time.sleep(2)
@ -93,7 +94,7 @@ class IMDb:
if len(imdb_ids) > 0: if len(imdb_ids) > 0:
logger.debug(f"{len(imdb_ids)} IMDb IDs Found: {imdb_ids}") logger.debug(f"{len(imdb_ids)} IMDb IDs Found: {imdb_ids}")
return imdb_ids return imdb_ids
raise ValueError(f"IMDb Error: No IMDb IDs Found at {imdb_url}") raise Failed(f"IMDb Error: No IMDb IDs Found at {imdb_url}")
def get_imdb_ids(self, method, data, language): def get_imdb_ids(self, method, data, language):
if method == "imdb_id": if method == "imdb_id":

@ -12,6 +12,8 @@ class Letterboxd:
self.config = config self.config = config
def _parse_list(self, list_url, language): def _parse_list(self, list_url, language):
if self.config.trace_mode:
logger.debug(f"URL: {list_url}")
response = self.config.get_html(list_url, headers=util.header(language)) response = self.config.get_html(list_url, headers=util.header(language))
letterboxd_ids = response.xpath("//li[contains(@class, 'poster-container')]/div/@data-film-id") letterboxd_ids = response.xpath("//li[contains(@class, 'poster-container')]/div/@data-film-id")
items = [] items = []
@ -25,6 +27,8 @@ class Letterboxd:
return items return items
def _tmdb(self, letterboxd_url, language): def _tmdb(self, letterboxd_url, language):
if self.config.trace_mode:
logger.debug(f"URL: {letterboxd_url}")
response = self.config.get_html(letterboxd_url, headers=util.header(language)) response = self.config.get_html(letterboxd_url, headers=util.header(language))
ids = response.xpath("//a[@data-track-action='TMDb']/@href") ids = response.xpath("//a[@data-track-action='TMDb']/@href")
if len(ids) > 0 and ids[0]: if len(ids) > 0 and ids[0]:
@ -34,6 +38,8 @@ class Letterboxd:
raise Failed(f"Letterboxd Error: TMDb Movie ID not found at {letterboxd_url}") raise Failed(f"Letterboxd Error: TMDb Movie ID not found at {letterboxd_url}")
def get_list_description(self, list_url, language): def get_list_description(self, list_url, language):
if self.config.trace_mode:
logger.debug(f"URL: {list_url}")
response = self.config.get_html(list_url, headers=util.header(language)) response = self.config.get_html(list_url, headers=util.header(language))
descriptions = response.xpath("//meta[@property='og:description']/@content") descriptions = response.xpath("//meta[@property='og:description']/@content")
return descriptions[0] if len(descriptions) > 0 and len(descriptions[0]) > 0 else None return descriptions[0] if len(descriptions) > 0 and len(descriptions[0]) > 0 else None

@ -0,0 +1,270 @@
import logging, os, requests, shutil, time
from abc import ABC, abstractmethod
from modules import util
from modules.meta import Metadata
from modules.util import Failed, ImageData
from PIL import Image
from ruamel import yaml
logger = logging.getLogger("Plex Meta Manager")
class Library(ABC):
def __init__(self, config, params):
self.Radarr = None
self.Sonarr = None
self.Tautulli = None
self.Webhooks = None
self.Notifiarr = None
self.collections = []
self.metadatas = []
self.metadata_files = []
self.missing = {}
self.movie_map = {}
self.show_map = {}
self.imdb_map = {}
self.anidb_map = {}
self.mal_map = {}
self.movie_rating_key_map = {}
self.show_rating_key_map = {}
self.run_again = []
self.run_sort = []
self.overlays = []
self.type = ""
self.config = config
self.name = params["name"]
self.original_mapping_name = params["mapping_name"]
self.metadata_path = params["metadata_path"]
self.asset_directory = params["asset_directory"]
self.default_dir = params["default_dir"]
self.mapping_name, output = util.validate_filename(self.original_mapping_name)
self.image_table_name = self.config.Cache.get_image_table_name(self.original_mapping_name) if self.config.Cache else None
self.missing_path = os.path.join(self.default_dir, f"{self.original_mapping_name}_missing.yml")
self.asset_folders = params["asset_folders"]
self.sync_mode = params["sync_mode"]
self.show_unmanaged = params["show_unmanaged"]
self.show_filtered = params["show_filtered"]
self.show_missing = params["show_missing"]
self.show_missing_assets = params["show_missing_assets"]
self.save_missing = params["save_missing"]
self.missing_only_released = params["missing_only_released"]
self.create_asset_folders = params["create_asset_folders"]
self.assets_for_all = params["assets_for_all"]
self.delete_unmanaged_collections = params["delete_unmanaged_collections"]
self.delete_collections_with_less = params["delete_collections_with_less"]
self.mass_genre_update = params["mass_genre_update"]
self.mass_audience_rating_update = params["mass_audience_rating_update"]
self.mass_critic_rating_update = params["mass_critic_rating_update"]
self.mass_trakt_rating_update = params["mass_trakt_rating_update"]
self.radarr_add_all = params["radarr_add_all"]
self.sonarr_add_all = params["sonarr_add_all"]
self.collection_minimum = params["collection_minimum"]
self.delete_below_minimum = params["delete_below_minimum"]
self.error_webhooks = params["error_webhooks"]
self.collection_creation_webhooks = params["collection_creation_webhooks"]
self.collection_addition_webhooks = params["collection_addition_webhooks"]
self.collection_removal_webhooks = params["collection_removal_webhooks"]
self.split_duplicates = params["split_duplicates"] # TODO: Here or just in Plex?
self.clean_bundles = params["plex"]["clean_bundles"] # TODO: Here or just in Plex?
self.empty_trash = params["plex"]["empty_trash"] # TODO: Here or just in Plex?
self.optimize = params["plex"]["optimize"] # TODO: Here or just in Plex?
metadata = []
for file_type, metadata_file in self.metadata_path:
if file_type == "Folder":
if os.path.isdir(metadata_file):
yml_files = util.glob_filter(os.path.join(metadata_file, "*.yml"))
if yml_files:
metadata.extend([("File", yml) for yml in yml_files])
else:
logger.error(f"Config Error: No YAML (.yml) files found in {metadata_file}")
else:
logger.error(f"Config Error: Folder not found: {metadata_file}")
else:
metadata.append((file_type, metadata_file))
for file_type, metadata_file in metadata:
try:
meta_obj = Metadata(config, self, file_type, metadata_file)
if meta_obj.collections:
self.collections.extend([c for c in meta_obj.collections])
if meta_obj.metadata:
self.metadatas.extend([c for c in meta_obj.metadata])
self.metadata_files.append(meta_obj)
except Failed as e:
util.print_multiline(e, error=True)
if len(self.metadata_files) == 0:
logger.info("")
raise Failed("Metadata File Error: No valid metadata files found")
if self.asset_directory:
logger.info("")
for ad in self.asset_directory:
logger.info(f"Using Asset Directory: {ad}")
if output:
logger.info(output)
def upload_images(self, item, poster=None, background=None, overlay=None):
image = None
image_compare = None
poster_uploaded = False
if self.config.Cache:
image, image_compare = self.config.Cache.query_image_map(item.ratingKey, self.image_table_name)
if poster is not None:
try:
if image_compare and str(poster.compare) != str(image_compare):
image = None
if image is None or image != item.thumb:
self._upload_image(item, poster)
poster_uploaded = True
logger.info(f"Detail: {poster.attribute} updated {poster.message}")
else:
logger.info(f"Detail: {poster.prefix}poster update not needed")
except Failed:
util.print_stacktrace()
logger.error(f"Detail: {poster.attribute} failed to update {poster.message}")
if overlay is not None:
overlay_name, overlay_folder, overlay_image, temp_image = overlay
self.reload(item)
item_labels = {item_tag.tag.lower(): item_tag.tag for item_tag in item.labels}
for item_label in item_labels:
if item_label.endswith(" overlay") and item_label != f"{overlay_name.lower()} overlay":
raise Failed(f"Overlay Error: Poster already has an existing Overlay: {item_labels[item_label]}")
if poster_uploaded or image is None or image != item.thumb or f"{overlay_name.lower()} overlay" not in item_labels:
if not item.posterUrl:
raise Failed(f"Overlay Error: No existing poster to Overlay for {item.title}")
response = requests.get(item.posterUrl)
if response.status_code >= 400:
raise Failed(f"Overlay Error: Overlay Failed for {item.title}")
og_image = response.content
with open(temp_image, "wb") as handler:
handler.write(og_image)
shutil.copyfile(temp_image, os.path.join(overlay_folder, f"{item.ratingKey}.png"))
while util.is_locked(temp_image):
time.sleep(1)
try:
new_poster = Image.open(temp_image).convert("RGBA")
new_poster = new_poster.resize(overlay_image.size, Image.ANTIALIAS)
new_poster.paste(overlay_image, (0, 0), overlay_image)
new_poster.save(temp_image)
self.upload_file_poster(item, temp_image)
self.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"])
poster_uploaded = True
logger.info(f"Detail: Overlay: {overlay_name} applied to {item.title}")
except OSError as e:
util.print_stacktrace()
logger.error(f"Overlay Error: {e}")
background_uploaded = False
if background is not None:
try:
image = None
if self.config.Cache:
image, image_compare = self.config.Cache.query_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds")
if str(background.compare) != str(image_compare):
image = None
if image is None or image != item.art:
self._upload_image(item, background)
background_uploaded = True
logger.info(f"Detail: {background.attribute} updated {background.message}")
else:
logger.info(f"Detail: {background.prefix}background update not needed")
except Failed:
util.print_stacktrace()
logger.error(f"Detail: {background.attribute} failed to update {background.message}")
if self.config.Cache:
if poster_uploaded:
self.config.Cache.update_image_map(item.ratingKey, self.image_table_name, item.thumb, poster.compare if poster else "")
if background_uploaded:
self.config.Cache.update_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds", item.art, background.compare)
def notify(self, text, collection=None, critical=True):
for error in util.get_list(text, split=False):
self.Webhooks.error_hooks(error, library=self, collection=collection, critical=critical)
self.config.notify(text, library=self, collection=collection, critical=critical)
@abstractmethod
def _upload_image(self, item, image):
pass
@abstractmethod
def upload_file_poster(self, item, image):
pass
@abstractmethod
def reload(self, item):
pass
@abstractmethod
def edit_tags(self, attr, obj, add_tags=None, remove_tags=None, sync_tags=None):
pass
@abstractmethod
def get_all(self):
pass
def add_missing(self, collection, items, is_movie):
if collection not in self.missing:
self.missing[collection] = {}
section = "Movies Missing (TMDb IDs)" if is_movie else "Shows Missing (TVDb IDs)"
if section not in self.missing[collection]:
self.missing[collection][section] = {}
for title, item_id in items:
self.missing[collection][section][int(item_id)] = title
with open(self.missing_path, "w"): pass
try:
yaml.round_trip_dump(self.missing, open(self.missing_path, "w", encoding="utf-8"))
except yaml.scanner.ScannerError as e:
util.print_multiline(f"YAML Error: {util.tab_new_lines(e)}", error=True)
def map_guids(self):
items = self.get_all()
logger.info(f"Mapping {self.type} Library: {self.name}")
logger.info("")
for i, item in enumerate(items, 1):
util.print_return(f"Processing: {i}/{len(items)} {item.title}")
if item.ratingKey not in self.movie_rating_key_map and item.ratingKey not in self.show_rating_key_map:
id_type, main_id, imdb_id = self.config.Convert.get_id(item, self)
if main_id:
if id_type == "movie":
self.movie_rating_key_map[item.ratingKey] = main_id[0]
util.add_dict_list(main_id, item.ratingKey, self.movie_map)
elif id_type == "show":
self.show_rating_key_map[item.ratingKey] = main_id[0]
util.add_dict_list(main_id, item.ratingKey, self.show_map)
if imdb_id:
util.add_dict_list(imdb_id, item.ratingKey, self.imdb_map)
logger.info("")
logger.info(util.adjust_space(f"Processed {len(items)} {self.type}s"))
return items
def find_collection_assets(self, item, name=None, create=False):
if name is None:
name = item.title
for ad in self.asset_directory:
poster = None
background = None
if self.asset_folders:
if not os.path.isdir(os.path.join(ad, name)):
continue
poster_filter = os.path.join(ad, name, "poster.*")
background_filter = os.path.join(ad, name, "background.*")
else:
poster_filter = os.path.join(ad, f"{name}.*")
background_filter = os.path.join(ad, f"{name}_background.*")
matches = util.glob_filter(poster_filter)
if len(matches) > 0:
poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_url=False)
matches = util.glob_filter(background_filter)
if len(matches) > 0:
background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_poster=False, is_url=False)
if poster or background:
return poster, background
if create and self.asset_folders and not os.path.isdir(os.path.join(self.asset_directory[0], name)):
os.makedirs(os.path.join(self.asset_directory[0], name), exist_ok=True)
logger.info(f"Asset Directory Created: {os.path.join(self.asset_directory[0], name)}")
return None, None

@ -128,7 +128,11 @@ class MyAnimeList:
def _request(self, url, authorization=None): def _request(self, url, authorization=None):
new_authorization = authorization if authorization else self.authorization new_authorization = authorization if authorization else self.authorization
if self.config.trace_mode:
logger.debug(f"URL: {url}")
response = self.config.get_json(url, headers={"Authorization": f"Bearer {new_authorization['access_token']}"}) response = self.config.get_json(url, headers={"Authorization": f"Bearer {new_authorization['access_token']}"})
if self.config.trace_mode:
logger.debug(f"Response: {response}")
if "error" in response: raise Failed(f"MyAnimeList Error: {response['error']}") if "error" in response: raise Failed(f"MyAnimeList Error: {response['error']}")
else: return response else: return response

@ -249,6 +249,7 @@ class Metadata:
add_edit("originally_available", item, meta, methods, key="originallyAvailableAt", value=originally_available, var_type="date") add_edit("originally_available", item, meta, methods, key="originallyAvailableAt", value=originally_available, var_type="date")
add_edit("critic_rating", item, meta, methods, value=rating, key="rating", var_type="float") add_edit("critic_rating", item, meta, methods, value=rating, key="rating", var_type="float")
add_edit("audience_rating", item, meta, methods, key="audienceRating", var_type="float") add_edit("audience_rating", item, meta, methods, key="audienceRating", var_type="float")
add_edit("user_rating", item, meta, methods, key="userRating", var_type="float")
add_edit("content_rating", item, meta, methods, key="contentRating") add_edit("content_rating", item, meta, methods, key="contentRating")
add_edit("original_title", item, meta, methods, key="originalTitle", value=original_title) add_edit("original_title", item, meta, methods, key="originalTitle", value=original_title)
add_edit("studio", item, meta, methods, value=studio) add_edit("studio", item, meta, methods, value=studio)

@ -0,0 +1,31 @@
import logging
from modules.util import Failed
logger = logging.getLogger("Plex Meta Manager")
base_url = "https://notifiarr.com/api/v1/"
dev_url = "https://dev.notifiarr.com/api/v1/"
class Notifiarr:
def __init__(self, config, params):
self.config = config
self.apikey = params["apikey"]
self.develop = params["develop"]
self.test = params["test"]
url, _ = self.get_url("user/validate/")
response = self.config.get(url)
response_json = response.json()
if response.status_code >= 400 or ("result" in response_json and response_json["result"] == "error"):
logger.debug(f"Response: {response_json}")
raise Failed(f"({response.status_code} [{response.reason}]) {response_json}")
if not params["test"] and not response_json["details"]["response"]:
raise Failed("Notifiarr Error: Invalid apikey")
def get_url(self, path):
url = f"{dev_url if self.develop else base_url}{'notification/test' if self.test else f'{path}{self.apikey}'}"
logger.debug(url.replace(self.apikey, "APIKEY"))
params = {"event": "pmm" if self.test else "collections"}
return url, params

@ -48,6 +48,8 @@ class OMDb:
omdb_dict, expired = self.config.Cache.query_omdb(imdb_id) omdb_dict, expired = self.config.Cache.query_omdb(imdb_id)
if omdb_dict and expired is False: if omdb_dict and expired is False:
return OMDbObj(imdb_id, omdb_dict) return OMDbObj(imdb_id, omdb_dict)
if self.config.trace_mode:
logger.debug(f"IMDb ID: {imdb_id}")
response = self.config.get(base_url, params={"i": imdb_id, "apikey": self.apikey}) response = self.config.get(base_url, params={"i": imdb_id, "apikey": self.apikey})
if response.status_code < 400: if response.status_code < 400:
omdb = OMDbObj(imdb_id, response.json()) omdb = OMDbObj(imdb_id, response.json())

@ -1,14 +1,12 @@
import logging, os, plexapi, requests, shutil, time import logging, os, plexapi, requests
from modules import builder, util from modules import builder, util
from modules.meta import Metadata from modules.library import Library
from modules.util import Failed, ImageData from modules.util import Failed, ImageData
from plexapi import utils from plexapi import utils
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.collection import Collection from plexapi.collection import Collection
from plexapi.server import PlexServer from plexapi.server import PlexServer
from PIL import Image
from retrying import retry from retrying import retry
from ruamel import yaml
from urllib import parse from urllib import parse
from xml.etree.ElementTree import ParseError from xml.etree.ElementTree import ParseError
@ -225,9 +223,9 @@ episode_sorts = {
} }
sort_types = {"movies": (1, movie_sorts), "shows": (2, show_sorts), "seasons": (3, season_sorts), "episodes": (4, episode_sorts)} sort_types = {"movies": (1, movie_sorts), "shows": (2, show_sorts), "seasons": (3, season_sorts), "episodes": (4, episode_sorts)}
class Plex: class Plex(Library):
def __init__(self, config, params): def __init__(self, config, params):
self.config = config super().__init__(config, params)
self.plex = params["plex"] self.plex = params["plex"]
self.url = params["plex"]["url"] self.url = params["plex"]["url"]
self.token = params["plex"]["token"] self.token = params["plex"]["token"]
@ -255,89 +253,6 @@ class Plex:
self.is_other = self.agent == "com.plexapp.agents.none" self.is_other = self.agent == "com.plexapp.agents.none"
if self.is_other: if self.is_other:
self.type = "Video" self.type = "Video"
self.collections = []
self.metadatas = []
self.metadata_files = []
metadata = []
for file_type, metadata_file in params["metadata_path"]:
if file_type == "Folder":
if os.path.isdir(metadata_file):
yml_files = util.glob_filter(os.path.join(metadata_file, "*.yml"))
if yml_files:
metadata.extend([("File", yml) for yml in yml_files])
else:
logger.error(f"Config Error: No YAML (.yml) files found in {metadata_file}")
else:
logger.error(f"Config Error: Folder not found: {metadata_file}")
else:
metadata.append((file_type, metadata_file))
for file_type, metadata_file in metadata:
try:
meta_obj = Metadata(config, self, file_type, metadata_file)
if meta_obj.collections:
self.collections.extend([c for c in meta_obj.collections])
if meta_obj.metadata:
self.metadatas.extend([c for c in meta_obj.metadata])
self.metadata_files.append(meta_obj)
except Failed as e:
util.print_multiline(e, error=True)
if len(self.metadata_files) == 0:
logger.info("")
raise Failed("Metadata File Error: No valid metadata files found")
if params["asset_directory"]:
logger.info("")
for ad in params["asset_directory"]:
logger.info(f"Using Asset Directory: {ad}")
self.Radarr = None
self.Sonarr = None
self.Tautulli = None
self.name = params["name"]
self.original_mapping_name = params["mapping_name"]
self.mapping_name, output = util.validate_filename(self.original_mapping_name)
if output:
logger.info(output)
self.image_table_name = self.config.Cache.get_image_table_name(self.original_mapping_name) if self.config.Cache else None
self.missing_path = os.path.join(params["default_dir"], f"{self.name}_missing.yml")
self.collection_minimum = params["collection_minimum"]
self.delete_below_minimum = params["delete_below_minimum"]
self.metadata_path = params["metadata_path"]
self.asset_directory = params["asset_directory"]
self.asset_folders = params["asset_folders"]
self.assets_for_all = params["assets_for_all"]
self.sync_mode = params["sync_mode"]
self.show_unmanaged = params["show_unmanaged"]
self.show_filtered = params["show_filtered"]
self.show_missing = params["show_missing"]
self.save_missing = params["save_missing"]
self.missing_only_released = params["missing_only_released"]
self.create_asset_folders = params["create_asset_folders"]
self.mass_genre_update = params["mass_genre_update"]
self.mass_audience_rating_update = params["mass_audience_rating_update"]
self.mass_critic_rating_update = params["mass_critic_rating_update"]
self.mass_trakt_rating_update = params["mass_trakt_rating_update"]
self.split_duplicates = params["split_duplicates"]
self.radarr_add_all = params["radarr_add_all"]
self.sonarr_add_all = params["sonarr_add_all"]
self.mass_update = self.mass_genre_update or self.mass_audience_rating_update or self.mass_critic_rating_update \
or self.mass_trakt_rating_update or self.split_duplicates or self.radarr_add_all or self.sonarr_add_all
self.clean_bundles = params["plex"]["clean_bundles"]
self.empty_trash = params["plex"]["empty_trash"]
self.optimize = params["plex"]["optimize"]
self.missing = {}
self.movie_map = {}
self.show_map = {}
self.imdb_map = {}
self.anidb_map = {}
self.mal_map = {}
self.movie_rating_key_map = {}
self.show_rating_key_map = {}
self.run_again = []
self.run_sort = []
self.overlays = []
def get_all_collections(self): def get_all_collections(self):
return self.search(libtype="collection") return self.search(libtype="collection")
@ -364,7 +279,7 @@ class Plex:
def get_all(self): def get_all(self):
logger.info(f"Loading All {self.type}s from Library: {self.name}") logger.info(f"Loading All {self.type}s from Library: {self.name}")
key = f"/library/sections/{self.Plex.key}/all?type={utils.searchType(self.Plex.TYPE)}" key = f"/library/sections/{self.Plex.key}/all?includeGuids=1&type={utils.searchType(self.Plex.TYPE)}"
container_start = 0 container_start = 0
container_size = plexapi.X_PLEX_CONTAINER_SIZE container_size = plexapi.X_PLEX_CONTAINER_SIZE
results = [] results = []
@ -387,6 +302,13 @@ class Plex:
def query_data(self, method, data): def query_data(self, method, data):
return method(data) return method(data)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed)
def query_collection(self, item, collection, locked=True, add=True):
if add:
item.addCollection(collection, locked=locked)
else:
item.removeCollection(collection, locked=locked)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def collection_mode_query(self, collection, data): def collection_mode_query(self, collection, data):
collection.modeUpdate(mode=data) collection.modeUpdate(mode=data)
@ -395,11 +317,6 @@ class Plex:
def collection_order_query(self, collection, data): def collection_order_query(self, collection, data):
collection.sortUpdate(sort=data) collection.sortUpdate(sort=data)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def get_guids(self, item):
self.reload(item)
return item.guids
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def reload(self, item): def reload(self, item):
try: try:
@ -422,6 +339,7 @@ class Plex:
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def _upload_image(self, item, image): def _upload_image(self, item, image):
try:
if image.is_poster and image.is_url: if image.is_poster and image.is_url:
item.uploadPoster(url=image.location) item.uploadPoster(url=image.location)
elif image.is_poster: elif image.is_poster:
@ -431,89 +349,14 @@ class Plex:
else: else:
item.uploadArt(filepath=image.location) item.uploadArt(filepath=image.location)
self.reload(item) self.reload(item)
except BadRequest as e:
raise Failed(e)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_plex)
def upload_file_poster(self, item, image): def upload_file_poster(self, item, image):
item.uploadPoster(filepath=image) item.uploadPoster(filepath=image)
self.reload(item) self.reload(item)
def upload_images(self, item, poster=None, background=None, overlay=None):
image = None
image_compare = None
poster_uploaded = False
if self.config.Cache:
image, image_compare = self.config.Cache.query_image_map(item.ratingKey, self.image_table_name)
if poster is not None:
try:
if image_compare and str(poster.compare) != str(image_compare):
image = None
if image is None or image != item.thumb:
self._upload_image(item, poster)
poster_uploaded = True
logger.info(f"Detail: {poster.attribute} updated {poster.message}")
else:
logger.info(f"Detail: {poster.prefix}poster update not needed")
except BadRequest:
util.print_stacktrace()
logger.error(f"Detail: {poster.attribute} failed to update {poster.message}")
if overlay is not None:
overlay_name, overlay_folder, overlay_image, temp_image = overlay
self.reload(item)
item_labels = {item_tag.tag.lower(): item_tag.tag for item_tag in item.labels}
for item_label in item_labels:
if item_label.endswith(" overlay") and item_label != f"{overlay_name.lower()} overlay":
raise Failed(f"Overlay Error: Poster already has an existing Overlay: {item_labels[item_label]}")
if poster_uploaded or image is None or image != item.thumb or f"{overlay_name.lower()} overlay" not in item_labels:
if not item.posterUrl:
raise Failed(f"Overlay Error: No existing poster to Overlay for {item.title}")
response = requests.get(item.posterUrl)
if response.status_code >= 400:
raise Failed(f"Overlay Error: Overlay Failed for {item.title}")
og_image = response.content
with open(temp_image, "wb") as handler:
handler.write(og_image)
shutil.copyfile(temp_image, os.path.join(overlay_folder, f"{item.ratingKey}.png"))
while util.is_locked(temp_image):
time.sleep(1)
try:
new_poster = Image.open(temp_image).convert("RGBA")
new_poster = new_poster.resize(overlay_image.size, Image.ANTIALIAS)
new_poster.paste(overlay_image, (0, 0), overlay_image)
new_poster.save(temp_image)
self.upload_file_poster(item, temp_image)
self.edit_tags("label", item, add_tags=[f"{overlay_name} Overlay"])
poster_uploaded = True
logger.info(f"Detail: Overlay: {overlay_name} applied to {item.title}")
except OSError as e:
util.print_stacktrace()
logger.error(f"Overlay Error: {e}")
background_uploaded = False
if background is not None:
try:
image = None
if self.config.Cache:
image, image_compare = self.config.Cache.query_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds")
if str(background.compare) != str(image_compare):
image = None
if image is None or image != item.art:
self._upload_image(item, background)
background_uploaded = True
logger.info(f"Detail: {background.attribute} updated {background.message}")
else:
logger.info(f"Detail: {background.prefix}background update not needed")
except BadRequest:
util.print_stacktrace()
logger.error(f"Detail: {background.attribute} failed to update {background.message}")
if self.config.Cache:
if poster_uploaded:
self.config.Cache.update_image_map(item.ratingKey, self.image_table_name, item.thumb, poster.compare if poster else "")
if background_uploaded:
self.config.Cache.update_image_map(item.ratingKey, f"{self.image_table_name}_backgrounds", item.art, background.compare)
@retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed) @retry(stop_max_attempt_number=6, wait_fixed=10000, retry_on_exception=util.retry_if_not_failed)
def get_search_choices(self, search_name, title=True): def get_search_choices(self, search_name, title=True):
final_search = search_translation[search_name] if search_name in search_translation else search_name final_search = search_translation[search_name] if search_name in search_translation else search_name
@ -539,6 +382,16 @@ class Plex:
else: method = None else: method = None
return self.Plex._server.query(key, method=method) return self.Plex._server.query(key, method=method)
def alter_collection(self, item, collection, smart_label_collection=False, add=True):
if smart_label_collection:
self.query_data(item.addLabel if add else item.removeLabel, collection)
else:
locked = True
if self.agent in ["tv.plex.agents.movie", "tv.plex.agents.series"]:
field = next((f for f in item.fields if f.name == "collection"), None)
locked = field is not None
self.query_collection(item, collection, locked=locked, add=add)
def move_item(self, collection, item, after=None): def move_item(self, collection, item, after=None):
key = f"{collection.key}/items/{item}/move" key = f"{collection.key}/items/{item}/move"
if after: if after:
@ -681,21 +534,6 @@ class Plex:
else: else:
raise Failed("Plex Error: No Items found in Plex") raise Failed("Plex Error: No Items found in Plex")
def add_missing(self, collection, items, is_movie):
col_name = collection.encode("ascii", "replace").decode()
if col_name not in self.missing:
self.missing[col_name] = {}
section = "Movies Missing (TMDb IDs)" if is_movie else "Shows Missing (TVDb IDs)"
if section not in self.missing[col_name]:
self.missing[col_name][section] = {}
for title, item_id in items:
self.missing[col_name][section][int(item_id)] = str(title).encode("ascii", "replace").decode()
with open(self.missing_path, "w"): pass
try:
yaml.round_trip_dump(self.missing, open(self.missing_path, "w"))
except yaml.scanner.ScannerError as e:
util.print_multiline(f"YAML Error: {util.tab_new_lines(e)}", error=True)
def get_collection_items(self, collection, smart_label_collection): def get_collection_items(self, collection, smart_label_collection):
if smart_label_collection: if smart_label_collection:
return self.get_labeled_items(collection.title if isinstance(collection, Collection) else str(collection)) return self.get_labeled_items(collection.title if isinstance(collection, Collection) else str(collection))
@ -715,27 +553,6 @@ class Plex:
name = collection.title if isinstance(collection, Collection) else str(collection) name = collection.title if isinstance(collection, Collection) else str(collection)
return name, self.get_collection_items(collection, smart_label_collection) return name, self.get_collection_items(collection, smart_label_collection)
def map_guids(self):
items = self.get_all()
logger.info(f"Mapping {self.type} Library: {self.name}")
logger.info("")
for i, item in enumerate(items, 1):
util.print_return(f"Processing: {i}/{len(items)} {item.title}")
if item.ratingKey not in self.movie_rating_key_map and item.ratingKey not in self.show_rating_key_map:
id_type, main_id, imdb_id = self.config.Convert.get_id(item, self)
if main_id:
if id_type == "movie":
self.movie_rating_key_map[item.ratingKey] = main_id[0]
util.add_dict_list(main_id, item.ratingKey, self.movie_map)
elif id_type == "show":
self.show_rating_key_map[item.ratingKey] = main_id[0]
util.add_dict_list(main_id, item.ratingKey, self.show_map)
if imdb_id:
util.add_dict_list(imdb_id, item.ratingKey, self.imdb_map)
logger.info("")
logger.info(util.adjust_space(f"Processed {len(items)} {self.type}s"))
return items
def get_tmdb_from_map(self, item): def get_tmdb_from_map(self, item):
return self.movie_rating_key_map[item.ratingKey] if item.ratingKey in self.movie_rating_key_map else None return self.movie_rating_key_map[item.ratingKey] if item.ratingKey in self.movie_rating_key_map else None
@ -791,7 +608,6 @@ class Plex:
def update_item_from_assets(self, item, overlay=None, create=False): def update_item_from_assets(self, item, overlay=None, create=False):
name = os.path.basename(os.path.dirname(str(item.locations[0])) if self.is_movie else str(item.locations[0])) name = os.path.basename(os.path.dirname(str(item.locations[0])) if self.is_movie else str(item.locations[0]))
logger.debug(name)
found_folder = False found_folder = False
poster = None poster = None
background = None background = None
@ -822,14 +638,23 @@ class Plex:
self.upload_images(item, poster=poster, background=background, overlay=overlay) self.upload_images(item, poster=poster, background=background, overlay=overlay)
if self.is_show: if self.is_show:
for season in self.query(item.seasons): for season in self.query(item.seasons):
season_name = f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}"
if item_dir: if item_dir:
season_filter = os.path.join(item_dir, f"Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}.*") season_poster_filter = os.path.join(item_dir, f"{season_name}.*")
season_background_filter = os.path.join(item_dir, f"{season_name}_background.*")
else: else:
season_filter = os.path.join(ad, f"{name}_Season{'0' if season.seasonNumber < 10 else ''}{season.seasonNumber}.*") season_poster_filter = os.path.join(ad, f"{name}_{season_name}.*")
matches = util.glob_filter(season_filter) season_background_filter = os.path.join(ad, f"{name}_{season_name}_background.*")
matches = util.glob_filter(season_poster_filter)
season_poster = None
season_background = None
if len(matches) > 0: if len(matches) > 0:
season_poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} Season {season.seasonNumber}'s ", is_url=False) season_poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} Season {season.seasonNumber}'s ", is_url=False)
self.upload_images(season, poster=season_poster) matches = util.glob_filter(season_background_filter)
if len(matches) > 0:
season_background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title} Season {season.seasonNumber}'s ", is_poster=False, is_url=False)
if season_poster or season_background:
self.upload_images(season, poster=season_poster, background=season_background)
for episode in self.query(season.episodes): for episode in self.query(season.episodes):
if item_dir: if item_dir:
episode_filter = os.path.join(item_dir, f"{episode.seasonEpisode.upper()}.*") episode_filter = os.path.join(item_dir, f"{episode.seasonEpisode.upper()}.*")
@ -846,32 +671,5 @@ class Plex:
logger.info(f"Asset Directory Created: {os.path.join(self.asset_directory[0], name)}") logger.info(f"Asset Directory Created: {os.path.join(self.asset_directory[0], name)}")
elif not overlay and self.asset_folders and not found_folder: elif not overlay and self.asset_folders and not found_folder:
logger.error(f"Asset Warning: No asset folder found called '{name}'") logger.error(f"Asset Warning: No asset folder found called '{name}'")
elif not poster and not background: elif not poster and not background and self.show_missing_assets:
logger.error(f"Asset Warning: No poster or background found in an assets folder for '{name}'") logger.error(f"Asset Warning: No poster or background found in an assets folder for '{name}'")
def find_collection_assets(self, item, name=None, create=False):
if name is None:
name = item.title
for ad in self.asset_directory:
poster = None
background = None
if self.asset_folders:
if not os.path.isdir(os.path.join(ad, name)):
continue
poster_filter = os.path.join(ad, name, "poster.*")
background_filter = os.path.join(ad, name, "background.*")
else:
poster_filter = os.path.join(ad, f"{name}.*")
background_filter = os.path.join(ad, f"{name}_background.*")
matches = util.glob_filter(poster_filter)
if len(matches) > 0:
poster = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_url=False)
matches = util.glob_filter(background_filter)
if len(matches) > 0:
background = ImageData("asset_directory", os.path.abspath(matches[0]), prefix=f"{item.title}'s ", is_poster=False, is_url=False)
if poster or background:
return poster, background
if create and self.asset_folders and not os.path.isdir(os.path.join(self.asset_directory[0], name)):
os.makedirs(os.path.join(self.asset_directory[0], name), exist_ok=True)
logger.info(f"Asset Directory Created: {os.path.join(self.asset_directory[0], name)}")
return None, None

@ -17,6 +17,7 @@ class Radarr:
self.token = params["token"] self.token = params["token"]
try: try:
self.api = RadarrAPI(self.url, self.token, session=self.config.session) self.api = RadarrAPI(self.url, self.token, session=self.config.session)
self.api.respect_list_exclusions_when_adding()
except ArrException as e: except ArrException as e:
raise Failed(e) raise Failed(e)
self.add = params["add"] self.add = params["add"]
@ -61,6 +62,8 @@ class Radarr:
for tmdb_id in invalid: for tmdb_id in invalid:
logger.info(f"Invalid TMDb ID | {tmdb_id}") logger.info(f"Invalid TMDb ID | {tmdb_id}")
return len(added)
def edit_tags(self, tmdb_ids, tags, apply_tags): def edit_tags(self, tmdb_ids, tags, apply_tags):
logger.info("") logger.info("")
logger.info(f"{apply_tags_translation[apply_tags].capitalize()} Radarr Tags: {tags}") logger.info(f"{apply_tags_translation[apply_tags].capitalize()} Radarr Tags: {tags}")

@ -35,6 +35,7 @@ class Sonarr:
self.token = params["token"] self.token = params["token"]
try: try:
self.api = SonarrAPI(self.url, self.token, session=self.config.session) self.api = SonarrAPI(self.url, self.token, session=self.config.session)
self.api.respect_list_exclusions_when_adding()
except ArrException as e: except ArrException as e:
raise Failed(e) raise Failed(e)
self.add = params["add"] self.add = params["add"]
@ -59,7 +60,7 @@ class Sonarr:
monitor = monitor_translation[options["monitor"] if "monitor" in options else self.monitor] monitor = monitor_translation[options["monitor"] if "monitor" in options else self.monitor]
quality_profile = options["quality"] if "quality" in options else self.quality_profile quality_profile = options["quality"] if "quality" in options else self.quality_profile
language_profile = options["language"] if "language" in options else self.language_profile language_profile = options["language"] if "language" in options else self.language_profile
language_profile = language_profile if self.api.v3 else 1 language_profile = language_profile if self.api._raw.v3 else 1
series = options["series"] if "series" in options else self.series_type series = options["series"] if "series" in options else self.series_type
season = options["season"] if "season" in options else self.season_folder season = options["season"] if "season" in options else self.season_folder
tags = options["tag"] if "tag" in options else self.tag tags = options["tag"] if "tag" in options else self.tag
@ -87,6 +88,8 @@ class Sonarr:
logger.info("") logger.info("")
logger.info(f"Invalid TVDb ID | {tvdb_id}") logger.info(f"Invalid TVDb ID | {tvdb_id}")
return len(added)
def edit_tags(self, tvdb_ids, tags, apply_tags): def edit_tags(self, tvdb_ids, tags, apply_tags):
logger.info("") logger.info("")
logger.info(f"{apply_tags_translation[apply_tags].capitalize()} Sonarr Tags: {tags}") logger.info(f"{apply_tags_translation[apply_tags].capitalize()} Sonarr Tags: {tags}")

@ -1,4 +1,7 @@
import logging import logging
from plexapi.video import Movie, Show
from modules import util from modules import util
from modules.util import Failed from modules.util import Failed
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
@ -40,7 +43,9 @@ class Tautulli:
for item in items: for item in items:
if item["section_id"] == section_id and count < int(params['list_size']): if item["section_id"] == section_id and count < int(params['list_size']):
try: try:
library.fetchItem(int(item["rating_key"])) plex_item = library.fetchItem(int(item["rating_key"]))
if not isinstance(plex_item, (Movie, Show)):
raise BadRequest
rating_keys.append(item["rating_key"]) rating_keys.append(item["rating_key"])
except (BadRequest, NotFound): except (BadRequest, NotFound):
new_item = library.exact_search(item["title"], year=item["year"]) new_item = library.exact_search(item["title"], year=item["year"])
@ -65,5 +70,5 @@ class Tautulli:
else: raise Failed(f"Tautulli Error: No Library named {library_name} in the response") else: raise Failed(f"Tautulli Error: No Library named {library_name} in the response")
def _request(self, url): def _request(self, url):
logger.debug(f"Tautulli URL: {url.replace(self.apikey, '###############')}") logger.debug(f"Tautulli URL: {url.replace(self.apikey, 'APIKEY').replace(self.url, 'URL')}")
return self.config.get_json(url) return self.config.get_json(url)

@ -56,9 +56,12 @@ class TMDb:
self.TMDb = tmdbv3api.TMDb(session=self.config.session) self.TMDb = tmdbv3api.TMDb(session=self.config.session)
self.TMDb.api_key = params["apikey"] self.TMDb.api_key = params["apikey"]
self.TMDb.language = params["language"] self.TMDb.language = params["language"]
try:
response = tmdbv3api.Configuration().info() response = tmdbv3api.Configuration().info()
if hasattr(response, "status_message"): if hasattr(response, "status_message"):
raise Failed(f"TMDb Error: {response.status_message}") raise Failed(f"TMDb Error: {response.status_message}")
except TMDbException as e:
raise Failed(f"TMDb Error: {e}")
self.apikey = params["apikey"] self.apikey = params["apikey"]
self.language = params["language"] self.language = params["language"]
self.Movie = tmdbv3api.Movie() self.Movie = tmdbv3api.Movie()
@ -199,6 +202,8 @@ class TMDb:
for date_attr in discover_dates: for date_attr in discover_dates:
if date_attr in attrs: if date_attr in attrs:
attrs[date_attr] = util.validate_date(attrs[date_attr], f"tmdb_discover attribute {date_attr}", return_as="%Y-%m-%d") attrs[date_attr] = util.validate_date(attrs[date_attr], f"tmdb_discover attribute {date_attr}", return_as="%Y-%m-%d")
if self.config.trace_mode:
logger.debug(f"Params: {attrs}")
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)
total_pages = int(self.TMDb.total_pages) total_pages = int(self.TMDb.total_pages)
total_results = int(self.TMDb.total_results) total_results = int(self.TMDb.total_results)

@ -9,8 +9,10 @@ redirect_uri = "urn:ietf:wg:oauth:2.0:oob"
redirect_uri_encoded = redirect_uri.replace(":", "%3A") redirect_uri_encoded = redirect_uri.replace(":", "%3A")
base_url = "https://api.trakt.tv" base_url = "https://api.trakt.tv"
builders = [ builders = [
"trakt_collected", "trakt_collection", "trakt_list", "trakt_list_details", "trakt_popular", "trakt_collected_daily", "trakt_collected_weekly", "trakt_collected_monthly", "trakt_collected_yearly", "trakt_collected_all",
"trakt_recommended", "trakt_trending", "trakt_watched", "trakt_watchlist" "trakt_recommended_daily", "trakt_recommended_weekly", "trakt_recommended_monthly", "trakt_recommended_yearly", "trakt_recommended_all",
"trakt_watched_daily", "trakt_watched_weekly", "trakt_watched_monthly", "trakt_watched_yearly", "trakt_watched_all",
"trakt_collection", "trakt_list", "trakt_list_details", "trakt_popular", "trakt_trending", "trakt_watchlist", "trakt_boxoffice"
] ]
sorts = [ sorts = [
"rank", "added", "title", "released", "runtime", "popularity", "rank", "added", "title", "released", "runtime", "popularity",
@ -105,6 +107,8 @@ class Trakt:
output_json = [] output_json = []
pages = 1 pages = 1
current = 1 current = 1
if self.config.trace_mode:
logger.debug(f"URL: {base_url}{url}")
while current <= pages: while current <= pages:
if pages == 1: if pages == 1:
response = self.config.get(f"{base_url}{url}", headers=headers) response = self.config.get(f"{base_url}{url}", headers=headers)
@ -114,6 +118,8 @@ class Trakt:
response = self.config.get(f"{base_url}{url}?page={current}", headers=headers) response = self.config.get(f"{base_url}{url}?page={current}", headers=headers)
if response.status_code == 200: if response.status_code == 200:
json_data = response.json() json_data = response.json()
if self.config.trace_mode:
logger.debug(f"Response: {json_data}")
if isinstance(json_data, dict): if isinstance(json_data, dict):
return json_data return json_data
else: else:
@ -148,7 +154,7 @@ class Trakt:
for item in items: for item in items:
if typeless: if typeless:
data = item data = item
current_type = None current_type = item_type
elif item_type: elif item_type:
data = item[item_type] data = item[item_type]
current_type = item_type current_type = item_type
@ -158,7 +164,7 @@ class Trakt:
else: else:
continue continue
id_type = "tmdb" if current_type == "movie" else "tvdb" id_type = "tmdb" if current_type == "movie" else "tvdb"
if data["ids"][id_type]: if id_type in data["ids"] and data["ids"][id_type]:
final_id = data["ids"][id_type] final_id = data["ids"][id_type]
if current_type == "episode": if current_type == "episode":
final_id = f"{final_id}_{item[current_type]['season']}" final_id = f"{final_id}_{item[current_type]['season']}"
@ -216,14 +222,15 @@ class Trakt:
def get_trakt_ids(self, method, data, is_movie): def get_trakt_ids(self, method, data, is_movie):
pretty = method.replace("_", " ").title() pretty = method.replace("_", " ").title()
media_type = "Movie" if is_movie else "Show" media_type = "Movie" if is_movie else "Show"
if method in ["trakt_trending", "trakt_popular", "trakt_recommended", "trakt_watched", "trakt_collected"]: if method in ["trakt_collection", "trakt_watchlist"]:
logger.info(f"Processing {pretty}: {data} {media_type}{'' if data == 1 else 's'}")
return self._pagenation(method[6:], data, is_movie)
elif method in ["trakt_collection", "trakt_watchlist"]:
logger.info(f"Processing {pretty} {media_type}s for {data}") logger.info(f"Processing {pretty} {media_type}s for {data}")
return self._user_items(method[6:], data, is_movie) return self._user_items(method[6:], data, is_movie)
elif method == "trakt_list": elif method == "trakt_list":
logger.info(f"Processing {pretty}: {data}") logger.info(f"Processing {pretty}: {data}")
return self._user_list(data) return self._user_list(data)
elif method in builders:
logger.info(f"Processing {pretty}: {data} {media_type}{'' if data == 1 else 's'}")
terms = method.split("_")
return self._pagenation(f"{terms[1]}{f'/{terms[2]}' if len(terms) > 2 else ''}", data, is_movie)
else: else:
raise Failed(f"Trakt Error: Method {method} not supported") raise Failed(f"Trakt Error: Method {method} not supported")

@ -13,6 +13,28 @@ urls = {
"movies": f"{base_url}/movies/", "alt_movies": f"{alt_url}/movies/", "movies": f"{base_url}/movies/", "alt_movies": f"{alt_url}/movies/",
"series_id": f"{base_url}/dereferrer/series/", "movie_id": f"{base_url}/dereferrer/movie/" "series_id": f"{base_url}/dereferrer/series/", "movie_id": f"{base_url}/dereferrer/movie/"
} }
language_translation = {
"ab": "abk", "aa": "aar", "af": "afr", "ak": "aka", "sq": "sqi", "am": "amh", "ar": "ara", "an": "arg", "hy": "hye",
"as": "asm", "av": "ava", "ae": "ave", "ay": "aym", "az": "aze", "bm": "bam", "ba": "bak", "eu": "eus", "be": "bel",
"bn": "ben", "bi": "bis", "bs": "bos", "br": "bre", "bg": "bul", "my": "mya", "ca": "cat", "ch": "cha", "ce": "che",
"ny": "nya", "zh": "zho", "cv": "chv", "kw": "cor", "co": "cos", "cr": "cre", "hr": "hrv", "cs": "ces", "da": "dan",
"dv": "div", "nl": "nld", "dz": "dzo", "en": "eng", "eo": "epo", "et": "est", "ee": "ewe", "fo": "fao", "fj": "fij",
"fi": "fin", "fr": "fra", "ff": "ful", "gl": "glg", "ka": "kat", "de": "deu", "el": "ell", "gn": "grn", "gu": "guj",
"ht": "hat", "ha": "hau", "he": "heb", "hz": "her", "hi": "hin", "ho": "hmo", "hu": "hun", "ia": "ina", "id": "ind",
"ie": "ile", "ga": "gle", "ig": "ibo", "ik": "ipk", "io": "ido", "is": "isl", "it": "ita", "iu": "iku", "ja": "jpn",
"jv": "jav", "kl": "kal", "kn": "kan", "kr": "kau", "ks": "kas", "kk": "kaz", "km": "khm", "ki": "kik", "rw": "kin",
"ky": "kir", "kv": "kom", "kg": "kon", "ko": "kor", "ku": "kur", "kj": "kua", "la": "lat", "lb": "ltz", "lg": "lug",
"li": "lim", "ln": "lin", "lo": "lao", "lt": "lit", "lu": "lub", "lv": "lav", "gv": "glv", "mk": "mkd", "mg": "mlg",
"ms": "msa", "ml": "mal", "mt": "mlt", "mi": "mri", "mr": "mar", "mh": "mah", "mn": "mon", "na": "nau", "nv": "nav",
"nd": "nde", "ne": "nep", "ng": "ndo", "nb": "nob", "nn": "nno", "no": "nor", "ii": "iii", "nr": "nbl", "oc": "oci",
"oj": "oji", "cu": "chu", "om": "orm", "or": "ori", "os": "oss", "pa": "pan", "pi": "pli", "fa": "fas", "pl": "pol",
"ps": "pus", "pt": "por", "qu": "que", "rm": "roh", "rn": "run", "ro": "ron", "ru": "rus", "sa": "san", "sc": "srd",
"sd": "snd", "se": "sme", "sm": "smo", "sg": "sag", "sr": "srp", "gd": "gla", "sn": "sna", "si": "sin", "sk": "slk",
"sl": "slv", "so": "som", "st": "sot", "es": "spa", "su": "sun", "sw": "swa", "ss": "ssw", "sv": "swe", "ta": "tam",
"te": "tel", "tg": "tgk", "th": "tha", "ti": "tir", "bo": "bod", "tk": "tuk", "tl": "tgl", "tn": "tsn", "to": "ton",
"tr": "tur", "ts": "tso", "tt": "tat", "tw": "twi", "ty": "tah", "ug": "uig", "uk": "ukr", "ur": "urd", "uz": "uzb",
"ve": "ven", "vi": "vie", "vo": "vol", "wa": "wln", "cy": "cym", "wo": "wol", "fy": "fry", "xh": "xho", "yi": "yid",
"yo": "yor", "za": "zha", "zu": "zul"}
class TVDbObj: class TVDbObj:
def __init__(self, tvdb_url, language, is_movie, config): def __init__(self, tvdb_url, language, is_movie, config):
@ -27,6 +49,8 @@ class TVDbObj:
else: else:
raise Failed(f"TVDb Error: {self.tvdb_url} must begin with {urls['movies'] if self.is_movie else urls['series']}") raise Failed(f"TVDb Error: {self.tvdb_url} must begin with {urls['movies'] if self.is_movie else urls['series']}")
if self.config.trace_mode:
logger.debug(f"URL: {tvdb_url}")
response = self.config.get_html(self.tvdb_url, headers=util.header(self.language)) response = self.config.get_html(self.tvdb_url, headers=util.header(self.language))
results = response.xpath(f"//*[text()='TheTVDB.com {self.media_type} ID']/parent::node()/span/text()") results = response.xpath(f"//*[text()='TheTVDB.com {self.media_type} ID']/parent::node()/span/text()")
if len(results) > 0: if len(results) > 0:
@ -38,23 +62,27 @@ class TVDbObj:
else: else:
raise Failed(f"TVDb Error: Could not find a TVDb {self.media_type} ID at the URL {self.tvdb_url}") raise Failed(f"TVDb Error: Could not find a TVDb {self.media_type} ID at the URL {self.tvdb_url}")
def parse_page(xpath, fail=None, multi=False): def parse_page(xpath):
parse_results = response.xpath(xpath) parse_results = response.xpath(xpath)
if len(parse_results) > 0: if len(parse_results) > 0:
parse_results = [r.strip() for r in parse_results if len(r) > 0] parse_results = [r.strip() for r in parse_results if len(r) > 0]
if not multi and len(parse_results) > 0: return parse_results[0] if len(parse_results) > 0 else None
return parse_results[0]
elif len(parse_results) > 0: def parse_title_summary(lang=None):
return parse_results place = "//div[@class='change_translation_text' and "
elif fail is not None: place += f"@data-language='{lang}']" if lang else "not(@style='display:none')]"
raise Failed(f"TVDb Error: {fail} not found from TVDb URL: {self.tvdb_url}") return parse_page(f"{place}/@data-title"), parse_page(f"{place}/p/text()[normalize-space()]")
else:
return None self.title, self.summary = parse_title_summary(lang=self.language)
if not self.title and self.language in language_translation:
self.title, self.summary = parse_title_summary(lang=language_translation[self.language])
if not self.title:
self.title, self.summary = parse_title_summary()
if not self.title:
raise Failed(f"TVDb Error: Name not found from TVDb URL: {self.tvdb_url}")
self.title = parse_page("//div[@class='change_translation_text' and not(@style='display:none')]/@data-title", fail="Name")
self.poster_path = parse_page("//div[@class='row hidden-xs hidden-sm']/div/img/@src") self.poster_path = parse_page("//div[@class='row hidden-xs hidden-sm']/div/img/@src")
self.background_path = parse_page("(//h2[@class='mt-4' and text()='Backgrounds']/following::div/a/@href)[1]") self.background_path = parse_page("(//h2[@class='mt-4' and text()='Backgrounds']/following::div/a/@href)[1]")
self.summary = parse_page("//div[@class='change_translation_text' and not(@style='display:none')]/p/text()[normalize-space()]")
if self.is_movie: if self.is_movie:
self.directors = parse_page("//strong[text()='Directors']/parent::li/span/a/text()[normalize-space()]") self.directors = parse_page("//strong[text()='Directors']/parent::li/span/a/text()[normalize-space()]")
self.writers = parse_page("//strong[text()='Writers']/parent::li/span/a/text()[normalize-space()]") self.writers = parse_page("//strong[text()='Writers']/parent::li/span/a/text()[normalize-space()]")
@ -84,49 +112,52 @@ class TVDbObj:
self.imdb_id = imdb_id self.imdb_id = imdb_id
class TVDb: class TVDb:
def __init__(self, config): def __init__(self, config, tvdb_language):
self.config = config self.config = config
self.tvdb_language = tvdb_language
def get_item(self, language, tvdb_url, is_movie): def get_item(self, tvdb_url, is_movie):
return self.get_movie(language, tvdb_url) if is_movie else self.get_series(language, tvdb_url) return self.get_movie(tvdb_url) if is_movie else self.get_series(tvdb_url)
def get_series(self, language, tvdb_url): def get_series(self, tvdb_url):
try: try:
tvdb_url = f"{urls['series_id']}{int(tvdb_url)}" tvdb_url = f"{urls['series_id']}{int(tvdb_url)}"
except ValueError: except ValueError:
pass pass
return TVDbObj(tvdb_url, language, False, self.config) return TVDbObj(tvdb_url, self.tvdb_language, False, self.config)
def get_movie(self, language, tvdb_url): def get_movie(self, tvdb_url):
try: try:
tvdb_url = f"{urls['movie_id']}{int(tvdb_url)}" tvdb_url = f"{urls['movie_id']}{int(tvdb_url)}"
except ValueError: except ValueError:
pass pass
return TVDbObj(tvdb_url, language, True, self.config) return TVDbObj(tvdb_url, self.tvdb_language, True, self.config)
def get_list_description(self, tvdb_url, language): def get_list_description(self, tvdb_url):
response = self.config.get_html(tvdb_url, headers=util.header(language)) response = self.config.get_html(tvdb_url, headers=util.header(self.tvdb_language))
description = response.xpath("//div[@class='block']/div[not(@style='display:none')]/p/text()") description = response.xpath("//div[@class='block']/div[not(@style='display:none')]/p/text()")
return description[0] if len(description) > 0 and len(description[0]) > 0 else "" return description[0] if len(description) > 0 and len(description[0]) > 0 else ""
def _ids_from_url(self, tvdb_url, language): def _ids_from_url(self, tvdb_url):
ids = [] ids = []
tvdb_url = tvdb_url.strip() tvdb_url = tvdb_url.strip()
if self.config.trace_mode:
logger.debug(f"URL: {tvdb_url}")
if tvdb_url.startswith((urls["list"], urls["alt_list"])): if tvdb_url.startswith((urls["list"], urls["alt_list"])):
try: try:
response = self.config.get_html(tvdb_url, headers=util.header(language)) response = self.config.get_html(tvdb_url, headers=util.header(self.tvdb_language))
items = response.xpath("//div[@class='col-xs-12 col-sm-12 col-md-8 col-lg-8 col-md-pull-4']/div[@class='row']") items = response.xpath("//div[@class='col-xs-12 col-sm-12 col-md-8 col-lg-8 col-md-pull-4']/div[@class='row']")
for item in items: for item in items:
title = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/text()")[0] title = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/text()")[0]
item_url = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/@href")[0] item_url = item.xpath(".//div[@class='col-xs-12 col-sm-9 mt-2']//a/@href")[0]
if item_url.startswith("/series/"): if item_url.startswith("/series/"):
try: try:
ids.append((self.get_series(language, f"{base_url}{item_url}").id, "tvdb")) ids.append((self.get_series(f"{base_url}{item_url}").id, "tvdb"))
except Failed as e: except Failed as e:
logger.error(f"{e} for series {title}") logger.error(f"{e} for series {title}")
elif item_url.startswith("/movies/"): elif item_url.startswith("/movies/"):
try: try:
movie = self.get_movie(language, f"{base_url}{item_url}") movie = self.get_movie(f"{base_url}{item_url}")
if movie.tmdb_id: if movie.tmdb_id:
ids.append((movie.tmdb_id, "tmdb")) ids.append((movie.tmdb_id, "tmdb"))
elif movie.imdb_id: elif movie.imdb_id:
@ -145,19 +176,19 @@ class TVDb:
else: else:
raise Failed(f"TVDb Error: {tvdb_url} must begin with {urls['list']}") raise Failed(f"TVDb Error: {tvdb_url} must begin with {urls['list']}")
def get_tvdb_ids(self, method, data, language): def get_tvdb_ids(self, method, data):
if method == "tvdb_show": if method == "tvdb_show":
logger.info(f"Processing TVDb Show: {data}") logger.info(f"Processing TVDb Show: {data}")
return [(self.get_series(language, data).id, "tvdb")] return [(self.get_series(data).id, "tvdb")]
elif method == "tvdb_movie": elif method == "tvdb_movie":
logger.info(f"Processing TVDb Movie: {data}") logger.info(f"Processing TVDb Movie: {data}")
movie = self.get_movie(language, data) movie = self.get_movie(data)
if movie.tmdb_id: if movie.tmdb_id:
return [(movie.tmdb_id, "tmdb")] return [(movie.tmdb_id, "tmdb")]
elif movie.imdb_id: elif movie.imdb_id:
return [(movie.imdb_id, "imdb")] return [(movie.imdb_id, "imdb")]
elif method == "tvdb_list": elif method == "tvdb_list":
logger.info(f"Processing TVDb List: {data}") logger.info(f"Processing TVDb List: {data}")
return self._ids_from_url(data, language) return self._ids_from_url(data)
else: else:
raise Failed(f"TVDb Error: Method {method} not supported") raise Failed(f"TVDb Error: Method {method} not supported")

@ -19,6 +19,9 @@ class TimeoutExpired(Exception):
class Failed(Exception): class Failed(Exception):
pass pass
class NotScheduled(Exception):
pass
class ImageData: class ImageData:
def __init__(self, attribute, location, prefix="", is_poster=True, is_url=True): def __init__(self, attribute, location, prefix="", is_poster=True, is_url=True):
self.attribute = attribute self.attribute = attribute
@ -29,6 +32,9 @@ class ImageData:
self.compare = location if is_url else os.stat(location).st_size self.compare = location if is_url else os.stat(location).st_size
self.message = f"{prefix}{'poster' if is_poster else 'background'} to [{'URL' if is_url else 'File'}] {location}" self.message = f"{prefix}{'poster' if is_poster else 'background'} to [{'URL' if is_url else 'File'}] {location}"
def __str__(self):
return str(self.__dict__)
def retry_if_not_failed(exception): def retry_if_not_failed(exception):
return not isinstance(exception, Failed) return not isinstance(exception, Failed)
@ -103,7 +109,7 @@ def logger_input(prompt, timeout=60):
else: raise SystemError("Input Timeout not supported on this system") else: raise SystemError("Input Timeout not supported on this system")
def header(language="en-US,en;q=0.5"): def header(language="en-US,en;q=0.5"):
return {"Accept-Language": language, "User-Agent": "Mozilla/5.0 x64"} return {"Accept-Language": "eng" if language == "default" else language, "User-Agent": "Mozilla/5.0 x64"}
def alarm_handler(signum, frame): def alarm_handler(signum, frame):
raise TimeoutExpired raise TimeoutExpired
@ -298,7 +304,7 @@ def parse(attribute, data, datatype=None, methods=None, parent=None, default=Non
value = data[methods[attribute]] if methods and attribute in methods else data value = data[methods[attribute]] if methods and attribute in methods else data
if datatype == "list": if datatype == "list":
if methods and attribute in methods and data[methods[attribute]]: if value:
return [v for v in value if v] if isinstance(value, list) else [str(value)] return [v for v in value if v] if isinstance(value, list) else [str(value)]
return [] return []
elif datatype == "dictlist": elif datatype == "dictlist":

@ -0,0 +1,83 @@
import logging
from modules.util import Failed
logger = logging.getLogger("Plex Meta Manager")
class Webhooks:
def __init__(self, config, system_webhooks, library=None, notifiarr=None):
self.config = config
self.error_webhooks = system_webhooks["error"] if "error" in system_webhooks else []
self.run_start_webhooks = system_webhooks["run_start"] if "run_start" in system_webhooks else []
self.run_end_webhooks = system_webhooks["run_end"] if "run_end" in system_webhooks else []
self.library = library
self.notifiarr = notifiarr
def _request(self, webhooks, json):
if self.config.trace_mode:
logger.debug("")
logger.debug(f"JSON: {json}")
for webhook in list(set(webhooks)):
if self.config.trace_mode:
logger.debug(f"Webhook: {webhook}")
if webhook == "notifiarr":
url, params = self.notifiarr.get_url("notification/plex/")
response = self.config.get(url, json=json, params=params)
else:
response = self.config.post(webhook, json=json)
response_json = response.json()
if self.config.trace_mode:
logger.debug(f"Response: {response_json}")
if response.status_code >= 400 or ("result" in response_json and response_json["result"] == "error"):
raise Failed(f"({response.status_code} [{response.reason}]) {response_json}")
def start_time_hooks(self, start_time):
if self.run_start_webhooks:
self._request(self.run_start_webhooks, {"start_time": start_time})
def end_time_hooks(self, start_time, run_time, stats):
if self.run_end_webhooks:
self._request(self.run_end_webhooks, {
"start_time": start_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"run_time": run_time,
"collections_created": stats["created"],
"collections_modified": stats["modified"],
"collections_deleted": stats["deleted"],
"items_added": stats["added"],
"items_removed": stats["removed"],
"added_to_radarr": stats["radarr"],
"added_to_sonarr": stats["sonarr"],
})
def error_hooks(self, text, library=None, collection=None, critical=True):
if self.error_webhooks:
json = {"error": str(text), "critical": critical}
if library:
json["server_name"] = library.PlexServer.friendlyName
json["library_name"] = library.name
if collection:
json["collection"] = str(collection)
self._request(self.error_webhooks, json)
def collection_hooks(self, webhooks, collection, created=False, additions=None, removals=None):
if self.library:
thumb = None
if collection.thumb and next((f for f in collection.fields if f.name == "thumb"), None):
thumb = self.config.get_image_encoded(f"{self.library.url}{collection.thumb}?X-Plex-Token={self.library.token}")
art = None
if collection.art and next((f for f in collection.fields if f.name == "art"), None):
art = self.config.get_image_encoded(f"{self.library.url}{collection.art}?X-Plex-Token={self.library.token}")
json = {
"server_name": self.library.PlexServer.friendlyName,
"library_name": self.library.name,
"type": "movie" if self.library.is_movie else "show",
"collection": collection.title,
"created": created,
"poster": thumb,
"background": art
}
if additions:
json["additions"] = additions
if removals:
json["removals"] = removals
self._request(webhooks, json)

@ -2,11 +2,11 @@ import argparse, logging, os, sys, time
from datetime import datetime from datetime import datetime
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
try: try:
import schedule import plexapi, schedule
from modules import util from modules import util
from modules.builder import CollectionBuilder from modules.builder import CollectionBuilder
from modules.config import Config from modules.config import Config
from modules.util import Failed from modules.util import Failed, NotScheduled
except ModuleNotFoundError: except ModuleNotFoundError:
print("Requirements Error: Requirements are not installed") print("Requirements Error: Requirements are not installed")
sys.exit(0) sys.exit(0)
@ -17,6 +17,7 @@ if sys.version_info[0] != 3 or sys.version_info[1] < 6:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("-db", "--debug", dest="debug", help=argparse.SUPPRESS, action="store_true", default=False) parser.add_argument("-db", "--debug", dest="debug", help=argparse.SUPPRESS, action="store_true", default=False)
parser.add_argument("-tr", "--trace", dest="trace", 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", "--times", dest="times", help="Times to update each day use format HH:MM (Default: 03:00) (comma-separated list)", default="03:00", type=str) parser.add_argument("-t", "--time", "--times", dest="times", help="Times to update each day use format HH:MM (Default: 03:00) (comma-separated list)", 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("-re", "--resume", dest="resume", help="Resume collection run from a specific collection", type=str)
@ -51,6 +52,7 @@ def get_arg(env_str, default, arg_bool=False, arg_int=False):
test = get_arg("PMM_TEST", args.test, arg_bool=True) test = get_arg("PMM_TEST", args.test, arg_bool=True)
debug = get_arg("PMM_DEBUG", args.debug, arg_bool=True) debug = get_arg("PMM_DEBUG", args.debug, arg_bool=True)
trace = get_arg("PMM_TRACE", args.trace, arg_bool=True)
run = get_arg("PMM_RUN", args.run, arg_bool=True) run = get_arg("PMM_RUN", args.run, arg_bool=True)
no_countdown = get_arg("PMM_NO_COUNTDOWN", args.no_countdown, arg_bool=True) no_countdown = get_arg("PMM_NO_COUNTDOWN", args.no_countdown, arg_bool=True)
no_missing = get_arg("PMM_NO_MISSING", args.no_missing, arg_bool=True) no_missing = get_arg("PMM_NO_MISSING", args.no_missing, arg_bool=True)
@ -61,8 +63,9 @@ libraries = get_arg("PMM_LIBRARIES", args.libraries)
resume = get_arg("PMM_RESUME", args.resume) resume = get_arg("PMM_RESUME", args.resume)
times = get_arg("PMM_TIME", args.times) times = get_arg("PMM_TIME", args.times)
divider = get_arg("PMM_DIVIDER", args.divider) divider = get_arg("PMM_DIVIDER", args.divider)
screen_width = get_arg("PMM_WIDTH", args.width) screen_width = get_arg("PMM_WIDTH", args.width, arg_int=True)
config_file = get_arg("PMM_CONFIG", args.config) config_file = get_arg("PMM_CONFIG", args.config)
stats = {}
util.separating_character = divider[0] util.separating_character = divider[0]
@ -92,13 +95,21 @@ def fmt_filter(record):
return True return True
cmd_handler = logging.StreamHandler() cmd_handler = logging.StreamHandler()
cmd_handler.setLevel(logging.DEBUG if test or debug else logging.INFO) cmd_handler.setLevel(logging.DEBUG if test or debug or trace else logging.INFO)
logger.addHandler(cmd_handler) logger.addHandler(cmd_handler)
sys.excepthook = util.my_except_hook sys.excepthook = util.my_except_hook
def start(config_path, is_test=False, time_scheduled=None, requested_collections=None, requested_libraries=None, resume_from=None): version = "Unknown"
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION")) as handle:
for line in handle.readlines():
line = line.strip()
if len(line) > 0:
version = line
break
def start(attrs):
file_logger = os.path.join(default_dir, "logs", "meta.log") file_logger = os.path.join(default_dir, "logs", "meta.log")
should_roll_over = os.path.isfile(file_logger) should_roll_over = os.path.isfile(file_logger)
file_handler = RotatingFileHandler(file_logger, delay=True, mode="w", backupCount=10, encoding="utf-8") file_handler = RotatingFileHandler(file_logger, delay=True, mode="w", backupCount=10, encoding="utf-8")
@ -108,37 +119,49 @@ def start(config_path, is_test=False, time_scheduled=None, requested_collections
file_handler.doRollover() file_handler.doRollover()
logger.addHandler(file_handler) logger.addHandler(file_handler)
util.separator() util.separator()
logger.info(util.centered(" ")) logger.info("")
logger.info(util.centered(" ____ _ __ __ _ __ __ ")) logger.info(util.centered(" ____ _ __ __ _ __ __ "))
logger.info(util.centered("| _ \\| | _____ __ | \\/ | ___| |_ __ _ | \\/ | __ _ _ __ __ _ __ _ ___ _ __ ")) logger.info(util.centered("| _ \\| | _____ __ | \\/ | ___| |_ __ _ | \\/ | __ _ _ __ __ _ __ _ ___ _ __ "))
logger.info(util.centered("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` | | |\\/| |/ _` | '_ \\ / _` |/ _` |/ _ \\ '__|")) logger.info(util.centered("| |_) | |/ _ \\ \\/ / | |\\/| |/ _ \\ __/ _` | | |\\/| |/ _` | '_ \\ / _` |/ _` |/ _ \\ '__|"))
logger.info(util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | ")) logger.info(util.centered("| __/| | __/> < | | | | __/ || (_| | | | | | (_| | | | | (_| | (_| | __/ | "))
logger.info(util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| ")) logger.info(util.centered("|_| |_|\\___/_/\\_\\ |_| |_|\\___|\\__\\__,_| |_| |_|\\__,_|_| |_|\\__,_|\\__, |\\___|_| "))
logger.info(util.centered(" |___/ ")) logger.info(util.centered(" |___/ "))
logger.info(util.centered(" Version: 1.12.2 ")) logger.info(f" Version: {version}")
if time_scheduled: start_type = f"{time_scheduled} " if "time" in attrs and attrs["time"]: start_type = f"{attrs['time']} "
elif is_test: start_type = "Test " elif "test" in attrs and attrs["test"]: start_type = "Test "
elif requested_collections: start_type = "Collections " elif "collections" in attrs and attrs["collections"]: start_type = "Collections "
elif requested_libraries: start_type = "Libraries " elif "libraries" in attrs and attrs["libraries"]: start_type = "Libraries "
else: start_type = "" else: start_type = ""
start_time = datetime.now() start_time = datetime.now()
if time_scheduled is None: if "time" not in attrs:
time_scheduled = start_time.strftime("%H:%M") attrs["time"] = start_time.strftime("%H:%M")
util.separator(f"Starting {start_type}Run") util.separator(f"Starting {start_type}Run")
config = None
global stats
stats = {"created": 0, "modified": 0, "deleted": 0, "added": 0, "removed": 0, "radarr": 0, "sonarr": 0}
try:
config = Config(default_dir, attrs)
except Exception as e:
util.print_stacktrace()
util.print_multiline(e, critical=True)
else:
try: try:
config = Config(default_dir, config_path=config_path, is_test=is_test,
time_scheduled=time_scheduled, requested_collections=requested_collections,
requested_libraries=requested_libraries, resume_from=resume_from)
update_libraries(config) update_libraries(config)
except Exception as e: except Exception as e:
config.notify(e)
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, critical=True) util.print_multiline(e, critical=True)
logger.info("") logger.info("")
util.separator(f"Finished {start_type}Run\nRun Time: {str(datetime.now() - start_time).split('.')[0]}") run_time = str(datetime.now() - start_time).split('.')[0]
if config:
config.Webhooks.end_time_hooks(start_time, run_time, stats)
util.separator(f"Finished {start_type}Run\nRun Time: {run_time}")
logger.removeHandler(file_handler) logger.removeHandler(file_handler)
def update_libraries(config): def update_libraries(config):
global stats
for library in config.libraries: for library in config.libraries:
try:
os.makedirs(os.path.join(default_dir, "logs", library.mapping_name, "collections"), exist_ok=True) os.makedirs(os.path.join(default_dir, "logs", library.mapping_name, "collections"), exist_ok=True)
col_file_logger = os.path.join(default_dir, "logs", library.mapping_name, "library.log") col_file_logger = os.path.join(default_dir, "logs", library.mapping_name, "library.log")
should_roll_over = os.path.isfile(col_file_logger) should_roll_over = os.path.isfile(col_file_logger)
@ -148,7 +171,7 @@ def update_libraries(config):
library_handler.doRollover() library_handler.doRollover()
logger.addHandler(library_handler) logger.addHandler(library_handler)
os.environ["PLEXAPI_PLEXAPI_TIMEOUT"] = str(library.timeout) plexapi.server.TIMEOUT = library.timeout
logger.info("") logger.info("")
util.separator(f"{library.name} Library") util.separator(f"{library.name} Library")
items = None items = None
@ -157,8 +180,6 @@ def update_libraries(config):
util.separator(f"Mapping {library.name} Library", space=False, border=False) util.separator(f"Mapping {library.name} Library", space=False, border=False)
logger.info("") logger.info("")
items = library.map_guids() items = library.map_guids()
if not config.test_mode and not config.resume_from and not collection_only and library.mass_update:
mass_metadata(config, library, items=items)
for metadata in library.metadata_files: for metadata in library.metadata_files:
logger.info("") logger.info("")
util.separator(f"Running Metadata File\n{metadata.path}") util.separator(f"Running Metadata File\n{metadata.path}")
@ -166,6 +187,7 @@ def update_libraries(config):
try: try:
metadata.update_metadata() metadata.update_metadata()
except Failed as e: except Failed as e:
library.notify(e)
logger.error(e) logger.error(e)
collections_to_run = metadata.get_collections(config.requested_collections) collections_to_run = metadata.get_collections(config.requested_collections)
if config.resume_from and config.resume_from not in collections_to_run: if config.resume_from and config.resume_from not in collections_to_run:
@ -188,34 +210,14 @@ def update_libraries(config):
logger.info("") logger.info("")
builder.sort_collection() builder.sort_collection()
if not config.test_mode and not config.requested_collections and ((library.show_unmanaged and not library_only) or (library.assets_for_all and not collection_only)): if not config.test_mode and not collection_only:
logger.info("") library_operations(config, library, items=items)
util.separator(f"Other {library.name} Library Operations")
unmanaged_collections = []
for col in library.get_all_collections():
if col.title not in library.collections:
unmanaged_collections.append(col)
if library.show_unmanaged and not library_only:
logger.info("")
util.separator(f"Unmanaged Collections in {library.name} Library", space=False, border=False)
logger.info("")
for col in unmanaged_collections:
logger.info(col.title)
logger.info("")
logger.info(f"{len(unmanaged_collections)} Unmanaged Collections")
if library.assets_for_all and not collection_only:
logger.info("")
util.separator(f"All {library.type}s Assets Check for {library.name} Library", space=False, border=False)
logger.info("")
for col in unmanaged_collections:
poster, background = library.find_collection_assets(col, create=library.create_asset_folders)
library.upload_images(col, poster=poster, background=background)
for item in library.get_all():
library.update_item_from_assets(item, create=library.create_asset_folders)
logger.removeHandler(library_handler) logger.removeHandler(library_handler)
except Exception as e:
library.notify(e)
util.print_stacktrace()
util.print_multiline(e, critical=True)
has_run_again = False has_run_again = False
for library in config.libraries: for library in config.libraries:
@ -234,6 +236,7 @@ def update_libraries(config):
util.print_end() util.print_end()
for library in config.libraries: for library in config.libraries:
if library.run_again: if library.run_again:
try:
col_file_logger = os.path.join(default_dir, "logs", library.mapping_name, f"library.log") col_file_logger = os.path.join(default_dir, "logs", library.mapping_name, f"library.log")
library_handler = RotatingFileHandler(col_file_logger, mode="w", backupCount=3, encoding="utf-8") library_handler = RotatingFileHandler(col_file_logger, mode="w", backupCount=3, encoding="utf-8")
util.apply_formatter(library_handler) util.apply_formatter(library_handler)
@ -251,9 +254,14 @@ def update_libraries(config):
try: try:
builder.run_collections_again() builder.run_collections_again()
except Failed as e: except Failed as e:
library.notify(e, collection=builder.name, critical=False)
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
logger.removeHandler(library_handler) logger.removeHandler(library_handler)
except Exception as e:
library.notify(e)
util.print_stacktrace()
util.print_multiline(e, critical=True)
used_url = [] used_url = []
for library in config.libraries: for library in config.libraries:
@ -266,17 +274,21 @@ def update_libraries(config):
if library.optimize: if library.optimize:
library.query(library.PlexServer.library.optimize) library.query(library.PlexServer.library.optimize)
def mass_metadata(config, library, items=None): def library_operations(config, library, items=None):
logger.info("") logger.info("")
util.separator(f"Mass Editing {library.type} Library: {library.name}") util.separator(f"{library.name} Library Operations")
logger.info("") logger.info("")
if items is None:
items = library.get_all()
if library.split_duplicates: if library.split_duplicates:
items = library.search(**{"duplicate": True}) items = library.search(**{"duplicate": True})
for item in items: for item in items:
item.split() item.split()
logger.info(util.adjust_space(f"{item.title[:25]:<25} | Splitting")) logger.info(util.adjust_space(f"{item.title[:25]:<25} | Splitting"))
if library.assets_for_all or library.mass_genre_update or library.mass_audience_rating_update or \
library.mass_critic_rating_update or library.mass_trakt_rating_update or library.radarr_add_all or library.sonarr_add_all:
if items is None:
items = library.get_all()
radarr_adds = [] radarr_adds = []
sonarr_adds = [] sonarr_adds = []
trakt_ratings = config.Trakt.user_ratings(library.is_movie) if library.mass_trakt_rating_update else [] trakt_ratings = config.Trakt.user_ratings(library.is_movie) if library.mass_trakt_rating_update else []
@ -288,6 +300,8 @@ def mass_metadata(config, library, items=None):
logger.error(e) logger.error(e)
continue continue
util.print_return(f"Processing: {i}/{len(items)} {item.title}") util.print_return(f"Processing: {i}/{len(items)} {item.title}")
if library.assets_for_all:
library.update_item_from_assets(item, create=library.create_asset_folders)
tmdb_id = None tmdb_id = None
tvdb_id = None tvdb_id = None
imdb_id = None imdb_id = None
@ -413,6 +427,7 @@ def mass_metadata(config, library, items=None):
except Failed: except Failed:
pass pass
if library.Radarr and library.radarr_add_all: if library.Radarr and library.radarr_add_all:
try: try:
library.Radarr.add_tmdb(radarr_adds) library.Radarr.add_tmdb(radarr_adds)
@ -425,7 +440,52 @@ def mass_metadata(config, library, items=None):
except Failed as e: except Failed as e:
logger.error(e) logger.error(e)
if library.delete_collections_with_less is not None or library.delete_unmanaged_collections:
logger.info("")
suffix = ""
unmanaged = ""
if library.delete_collections_with_less is not None and library.delete_collections_with_less > 0:
suffix = f" with less then {library.delete_collections_with_less} item{'s' if library.delete_collections_with_less > 1 else ''}"
if library.delete_unmanaged_collections:
if library.delete_collections_with_less is None:
unmanaged = "Unmanaged Collections "
elif library.delete_collections_with_less > 0:
unmanaged = "Unmanaged Collections and "
util.separator(f"Deleting All {unmanaged}Collections{suffix}", space=False, border=False)
logger.info("")
unmanaged_collections = []
for col in library.get_all_collections():
if (library.delete_collections_with_less is not None
and (library.delete_collections_with_less == 0 or col.childCount < library.delete_collections_with_less)) \
or (col.title not in library.collections and library.delete_unmanaged_collections):
library.query(col.delete)
logger.info(f"{col.title} Deleted")
elif col.title not in library.collections:
unmanaged_collections.append(col)
if library.show_unmanaged and len(unmanaged_collections) > 0:
logger.info("")
util.separator(f"Unmanaged Collections in {library.name} Library", space=False, border=False)
logger.info("")
for col in unmanaged_collections:
logger.info(col.title)
logger.info("")
logger.info(f"{len(unmanaged_collections)} Unmanaged Collection{'s' if len(unmanaged_collections) > 1 else ''}")
elif library.show_unmanaged:
logger.info("")
util.separator(f"No Unmanaged Collections in {library.name} Library", space=False, border=False)
logger.info("")
if library.assets_for_all and len(unmanaged_collections) > 0:
logger.info("")
util.separator(f"Unmanaged Collection Assets Check for {library.name} Library", space=False, border=False)
logger.info("")
for col in unmanaged_collections:
poster, background = library.find_collection_assets(col, create=library.create_asset_folders)
library.upload_images(col, poster=poster, background=background)
def run_collection(config, library, metadata, requested_collections): def run_collection(config, library, metadata, requested_collections):
global stats
logger.info("") logger.info("")
for mapping_name, collection_attrs in requested_collections.items(): for mapping_name, collection_attrs in requested_collections.items():
collection_start = datetime.now() collection_start = datetime.now()
@ -457,7 +517,7 @@ def run_collection(config, library, metadata, requested_collections):
collection_log_name, output_str = util.validate_filename(mapping_name) collection_log_name, output_str = util.validate_filename(mapping_name)
collection_log_folder = os.path.join(default_dir, "logs", library.mapping_name, "collections", collection_log_name) collection_log_folder = os.path.join(default_dir, "logs", library.mapping_name, "collections", collection_log_name)
os.makedirs(collection_log_folder, exist_ok=True) os.makedirs(collection_log_folder, exist_ok=True)
col_file_logger = os.path.join(collection_log_folder, f"collection.log") col_file_logger = os.path.join(collection_log_folder, "collection.log")
should_roll_over = os.path.isfile(col_file_logger) should_roll_over = os.path.isfile(col_file_logger)
collection_handler = RotatingFileHandler(col_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8") collection_handler = RotatingFileHandler(col_file_logger, delay=True, mode="w", backupCount=3, encoding="utf-8")
util.apply_formatter(collection_handler) util.apply_formatter(collection_handler)
@ -486,6 +546,8 @@ def run_collection(config, library, metadata, requested_collections):
logger.info("") logger.info("")
util.print_multiline(builder.smart_filter_details, info=True) util.print_multiline(builder.smart_filter_details, info=True)
items_added = 0
items_removed = 0
if not builder.smart_url: if not builder.smart_url:
logger.info("") logger.info("")
logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}") logger.info(f"Sync Mode: {'sync' if builder.sync else 'append'}")
@ -501,45 +563,72 @@ def run_collection(config, library, metadata, requested_collections):
logger.info("") logger.info("")
util.separator(f"Adding to {mapping_name} Collection", space=False, border=False) util.separator(f"Adding to {mapping_name} Collection", space=False, border=False)
logger.info("") logger.info("")
builder.add_to_collection() items_added = builder.add_to_collection()
stats["added"] += items_added
items_removed = 0
if builder.sync:
items_removed = builder.sync_collection()
stats["removed"] += items_removed
elif len(builder.rating_keys) < builder.minimum and builder.build_collection: elif len(builder.rating_keys) < builder.minimum and builder.build_collection:
logger.info("") logger.info("")
logger.info(f"Collection minimum: {builder.minimum} not met for {mapping_name} Collection") logger.info(f"Collection Minimum: {builder.minimum} not met for {mapping_name} Collection")
logger.info("") if builder.details["delete_below_minimum"] and builder.obj:
if library.delete_below_minimum and builder.obj:
builder.delete_collection() builder.delete_collection()
stats["deleted"] += 1
logger.info("")
logger.info(f"Collection {builder.obj.title} deleted") logger.info(f"Collection {builder.obj.title} deleted")
if builder.do_missing and (len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0): if builder.do_missing and (len(builder.missing_movies) > 0 or len(builder.missing_shows) > 0):
if builder.details["show_missing"] is True: if builder.details["show_missing"] is True:
logger.info("") logger.info("")
util.separator(f"Missing from Library", space=False, border=False) util.separator(f"Missing from Library", space=False, border=False)
logger.info("") logger.info("")
builder.run_missing() radarr_add, sonarr_add = builder.run_missing()
if builder.sync and len(builder.rating_keys) > 0 and builder.build_collection: stats["radarr"] += radarr_add
builder.sync_collection() stats["sonarr"] += sonarr_add
run_item_details = True
if builder.build_collection: if builder.build_collection:
try:
builder.load_collection()
if builder.created:
stats["created"] += 1
elif items_added > 0 or items_removed > 0:
stats["modified"] += 1
except Failed:
util.print_stacktrace()
run_item_details = False
logger.info("") logger.info("")
util.separator(f"Updating Details of {mapping_name} Collection", space=False, border=False) util.separator("No Collection to Update", space=False, border=False)
logger.info("") else:
builder.update_details() builder.update_details()
if builder.custom_sort: if builder.custom_sort:
library.run_sort.append(builder) library.run_sort.append(builder)
# logger.info("")
# util.separator(f"Sorting {mapping_name} Collection", space=False, border=False)
# logger.info("")
# builder.sort_collection() # builder.sort_collection()
builder.send_notifications()
if builder.item_details and run_item_details:
try:
builder.load_collection_items()
except Failed:
logger.info("")
util.separator("No Items Found", space=False, border=False)
else:
builder.update_item_details() builder.update_item_details()
if builder.run_again and (len(builder.run_again_movies) > 0 or len(builder.run_again_shows) > 0): if builder.run_again and (len(builder.run_again_movies) > 0 or len(builder.run_again_shows) > 0):
library.run_again.append(builder) library.run_again.append(builder)
except NotScheduled as e:
util.print_multiline(e, info=True)
except Failed as e: except Failed as e:
library.notify(e, collection=mapping_name)
util.print_stacktrace() util.print_stacktrace()
util.print_multiline(e, error=True) util.print_multiline(e, error=True)
except Exception as e: except Exception as e:
library.notify(f"Unknown Error: {e}", collection=mapping_name)
util.print_stacktrace() util.print_stacktrace()
logger.error(f"Unknown Error: {e}") logger.error(f"Unknown Error: {e}")
logger.info("") logger.info("")
@ -548,7 +637,14 @@ def run_collection(config, library, metadata, requested_collections):
try: try:
if run or test or collections or libraries or resume: if run or test or collections or libraries or resume:
start(config_file, is_test=test, requested_collections=collections, requested_libraries=libraries, resume_from=resume) start({
"config_file": config_file,
"test": test,
"collections": collections,
"libraries": libraries,
"resume": resume,
"trace": trace
})
else: else:
times_to_run = util.get_list(times) times_to_run = util.get_list(times)
valid_times = [] valid_times = []
@ -561,7 +657,7 @@ try:
else: else:
raise Failed(f"Argument Error: blank time argument") raise Failed(f"Argument Error: blank time argument")
for time_to_run in valid_times: for time_to_run in valid_times:
schedule.every().day.at(time_to_run).do(start, config_file, time_scheduled=time_to_run) schedule.every().day.at(time_to_run).do(start, {"config_file": config_file, "time": time_to_run, "trace": trace})
while True: while True:
schedule.run_pending() schedule.run_pending()
if not no_countdown: if not no_countdown:
@ -575,11 +671,14 @@ try:
if (seconds is None or new_seconds < seconds) and new_seconds > 0: if (seconds is None or new_seconds < seconds) and new_seconds > 0:
seconds = new_seconds seconds = new_seconds
og_time_str = time_to_run og_time_str = time_to_run
if seconds is not None:
hours = int(seconds // 3600) hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60) minutes = int((seconds % 3600) // 60)
time_str = f"{hours} Hour{'s' if hours > 1 else ''} and " if hours > 0 else "" time_str = f"{hours} Hour{'s' if hours > 1 else ''} and " if hours > 0 else ""
time_str += f"{minutes} Minute{'s' if minutes > 1 else ''}" time_str += f"{minutes} Minute{'s' if minutes > 1 else ''}"
util.print_return(f"Current Time: {current} | {time_str} until the next run at {og_time_str} | Runs: {', '.join(times_to_run)}") util.print_return(f"Current Time: {current} | {time_str} until the next run at {og_time_str} | Runs: {', '.join(times_to_run)}")
else:
logger.error(f"Time Error: {valid_times}")
time.sleep(60) time.sleep(60)
except KeyboardInterrupt: except KeyboardInterrupt:
util.separator("Exiting Plex Meta Manager") util.separator("Exiting Plex Meta Manager")

@ -1,10 +1,10 @@
PlexAPI==4.7.0 PlexAPI==4.7.2
tmdbv3api==1.7.6 tmdbv3api==1.7.6
arrapi==1.1.3 arrapi==1.2.3
lxml==4.6.3 lxml==4.6.4
requests==2.26.0 requests==2.26.0
ruamel.yaml==0.17.10 ruamel.yaml==0.17.17
schedule==1.1.0 schedule==1.1.0
retrying==1.3.3 retrying==1.3.3
pathvalidate==2.4.1 pathvalidate==2.5.0
pillow==8.3.2 pillow==8.4.0
Loading…
Cancel
Save