diff --git a/PlexAniSync.py b/PlexAniSync.py index b3f3c23..4b17abf 100644 --- a/PlexAniSync.py +++ b/PlexAniSync.py @@ -1,25 +1,25 @@ # coding=utf-8 import configparser import logging -import logging.handlers +from logging.handlers import RotatingFileHandler import os import sys import coloredlogs -from custom_mappings import read_custom_mappings import anilist -import plexmodule import graphql +import plexmodule from _version import __version__ +from custom_mappings import read_custom_mappings # Logger settings LOG_FILENAME = "PlexAniSync.log" logger = logging.getLogger("PlexAniSync") # Add the rotating log message handler to the standard log -handler = logging.handlers.RotatingFileHandler( - LOG_FILENAME, maxBytes=10000000, backupCount=5, encoding="utf-8" +handler = RotatingFileHandler( + LOG_FILENAME, maxBytes=10_000_000, backupCount=5, encoding="utf-8" ) handler.setLevel(logging.INFO) logger.addHandler(handler) diff --git a/README.md b/README.md index 9a6234c..fa6cc71 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,9 @@ https://anilist.co/anime/99263/Tate-no-Yuusha-no-Nariagari #### Community mappings -There are some mappings provided by the Github community at https://github.com/RickDB/PlexAniSync-Custom-Mappings/. For now you can use the mapping files by copying parts into your own mapping file. +There are some mappings provided by the Github community at https://github.com/RickDB/PlexAniSync-Custom-Mappings/. You can use them by specifying `remote-urls` like in the example mapping file. + +If the local mapping file contains mappings for the same show as the community mapping, the local one will take precedence. The feature of synonyms was introduced for the community mappings where you can specify that a show can have one of multiple titles but should be mapped the same way. See Shaman King (2021) in the example mapping file. diff --git a/TautulliSyncHelper.py b/TautulliSyncHelper.py index 79949b1..0b19362 100644 --- a/TautulliSyncHelper.py +++ b/TautulliSyncHelper.py @@ -6,11 +6,11 @@ import coloredlogs -from custom_mappings import read_custom_mappings import anilist -import plexmodule import graphql +import plexmodule from _version import __version__ +from custom_mappings import read_custom_mappings # Logger settings logger = logging.getLogger("PlexAniSync") diff --git a/_version.py b/_version.py index 19937bc..51cd752 100644 --- a/_version.py +++ b/_version.py @@ -1 +1 @@ -__version__ = "1.3.16" +__version__ = "1.3.17" diff --git a/anilist.py b/anilist.py index e8b244b..6efb7e1 100644 --- a/anilist.py +++ b/anilist.py @@ -1,14 +1,14 @@ # coding=utf-8 import logging import re -from typing import Dict, List, Optional from dataclasses import dataclass +from typing import Dict, List, Optional import inflect -from plexmodule import PlexWatchedSeries from custom_mappings import AnilistCustomMapping from graphql import fetch_user_list, search_by_name, search_by_id, update_series +from plexmodule import PlexWatchedSeries logger = logging.getLogger("PlexAniSync") CUSTOM_MAPPINGS: Dict[str, List[AnilistCustomMapping]] = {} @@ -80,7 +80,8 @@ def process_user_list(username: str) -> Optional[List[AnilistSeries]]: if hasattr(media_collection, "entries"): for list_entry in media_collection.entries: if (hasattr(list_entry, "status") - and list_entry.status in ["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"] + and list_entry.status in ["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", + "REPEATING"] and list_entry.media is not None): series_obj = mediaitem_to_object(list_entry) anilist_series.append(series_obj) @@ -193,7 +194,7 @@ def match_to_plex(anilist_series: List[AnilistSeries], plex_series_watched: List plex_watched_episode_count_custom_mapping += plex_season.watched_episodes custom_mapping_season_count += 1 - custom_mapping_seasons_anilist_id = matched_id + custom_mapping_seasons_anilist_id = matched_id # If we had custom mappings for multiple seasons with the same ID use # cumulative episode count and skip per season processing @@ -206,7 +207,8 @@ def match_to_plex(anilist_series: List[AnilistSeries], plex_series_watched: List ) add_or_update_show_by_id( - anilist_series, plex_title, plex_year, True, plex_watched_episode_count_custom_mapping, custom_mapping_seasons_anilist_id + anilist_series, plex_title, plex_year, True, plex_watched_episode_count_custom_mapping, + custom_mapping_seasons_anilist_id ) mapped_season_count = custom_mapping_season_count @@ -275,8 +277,10 @@ def match_to_plex(anilist_series: List[AnilistSeries], plex_series_watched: List # Reordered checks from above to ensure that custom mappings always take precedent if plex_anilist_id: - logger.info(f"[ANILIST] Series {plex_title} has Anilist ID {plex_anilist_id} in its metadata, using that for updating") - add_or_update_show_by_id(anilist_series, plex_title, plex_year, True, plex_watched_episode_count, plex_anilist_id) + logger.info( + f"[ANILIST] Series {plex_title} has Anilist ID {plex_anilist_id} in its metadata, using that for updating") + add_or_update_show_by_id(anilist_series, plex_title, plex_year, True, plex_watched_episode_count, + plex_anilist_id) continue # Regular matching @@ -374,7 +378,8 @@ def match_to_plex(anilist_series: List[AnilistSeries], plex_series_watched: List plex_title_lookup = plex_title if media_id_search: - add_or_update_show_by_id(anilist_series, plex_title, plex_year, skip_year_check, plex_watched_episode_count, media_id_search) + add_or_update_show_by_id(anilist_series, plex_title, plex_year, skip_year_check, + plex_watched_episode_count, media_id_search) else: error_message = ( f"[ANILIST] Failed to find valid season title match on AniList for: {plex_title_lookup} season {season_number}" @@ -390,7 +395,7 @@ def find_mapped_series(anilist_series: List[AnilistSeries], anime_id: int): def match_series_against_potential_titles( - series: AnilistSeries, potential_titles: List[str], matched_anilist_series: List[AnilistSeries] + series: AnilistSeries, potential_titles: List[str], matched_anilist_series: List[AnilistSeries] ): if series.title_english: if series.title_english.lower() in potential_titles: @@ -548,8 +553,8 @@ def find_id_best_match(title: str, year: int) -> Optional[int]: # logger.info('Comparing AniList: %s | %s[%s] <===> %s[%s]' % (title_english, title_romaji, started_year, match_title, match_year)) if ( - match_title == title_english_for_matching - and match_year == started_year + match_title == title_english_for_matching + and match_year == started_year ): media_id = media_item.id logger.warning( @@ -557,8 +562,8 @@ def find_id_best_match(title: str, year: int) -> Optional[int]: ) break if ( - match_title == title_romaji_for_matching - and match_year == started_year + match_title == title_romaji_for_matching + and match_year == started_year ): media_id = media_item.id logger.warning( @@ -570,8 +575,8 @@ def find_id_best_match(title: str, year: int) -> Optional[int]: synonyms = synonym synonyms_for_matching = clean_title(synonyms) if ( - match_title == synonyms_for_matching - and match_year == started_year + match_title == synonyms_for_matching + and match_year == started_year ): media_id = media_item.id logger.warning( @@ -579,15 +584,15 @@ def find_id_best_match(title: str, year: int) -> Optional[int]: ) break if ( - match_title == title_romaji_for_matching - and match_year != started_year + match_title == title_romaji_for_matching + and match_year != started_year ): logger.info( f"[ANILIST] Found match however started year is a mismatch: {title_romaji} [AL: {started_year} <==> Plex: {match_year}] " ) elif ( - match_title == title_english_for_matching - and match_year != started_year + match_title == title_english_for_matching + and match_year != started_year ): logger.info( f"[ANILIST] Found match however started year is a mismatch: {title_english} [AL: {started_year} <==> Plex: {match_year}] " @@ -597,7 +602,8 @@ def find_id_best_match(title: str, year: int) -> Optional[int]: return media_id -def add_or_update_show_by_id(anilist_series: List[AnilistSeries], plex_title: str, plex_year: int, skip_year_check: bool, watched_episodes: int, anime_id: int): +def add_or_update_show_by_id(anilist_series: List[AnilistSeries], plex_title: str, plex_year: int, + skip_year_check: bool, watched_episodes: int, anime_id: int): series = find_mapped_series(anilist_series, anime_id) if series: logger.info( @@ -624,7 +630,7 @@ def add_or_update_show_by_id(anilist_series: List[AnilistSeries], plex_title: st def add_by_id( - anilist_id: int, plex_title: str, plex_year: int, plex_watched_episode_count: int, ignore_year: bool + anilist_id: int, plex_title: str, plex_year: int, plex_watched_episode_count: int, ignore_year: bool ): media_lookup_result = search_by_id(anilist_id) if media_lookup_result: @@ -648,7 +654,8 @@ def add_by_id( def update_entry( - title: str, year: int, watched_episode_count: int, matched_anilist_series: List[AnilistSeries], ignore_year: bool + title: str, year: int, watched_episode_count: int, matched_anilist_series: List[AnilistSeries], + ignore_year: bool ): for series in matched_anilist_series: status = "" @@ -703,8 +710,8 @@ def update_entry( pass if ( - watched_episode_count >= anilist_total_episodes > 0 - and anilist_media_status == "FINISHED" + watched_episode_count >= anilist_total_episodes > 0 + and anilist_media_status == "FINISHED" ): # series completed watched logger.warning( @@ -716,8 +723,8 @@ def update_entry( update_episode_incremental(series, watched_episode_count, anilist_episodes_watched, "COMPLETED") return elif ( - watched_episode_count > anilist_episodes_watched - and anilist_total_episodes > 0 + watched_episode_count > anilist_episodes_watched + and anilist_total_episodes > 0 ): # episode watch count higher than plex new_status = status if status == "REPEATING" else "CURRENT" @@ -736,8 +743,8 @@ def update_entry( ) return elif ( - anilist_episodes_watched > watched_episode_count - and ANILIST_PLEX_EPISODE_COUNT_PRIORITY + anilist_episodes_watched > watched_episode_count + and ANILIST_PLEX_EPISODE_COUNT_PRIORITY ): if watched_episode_count > 0: logger.info( @@ -766,7 +773,8 @@ def update_entry( ) -def update_episode_incremental(series: AnilistSeries, watched_episode_count: int, anilist_episodes_watched: int, new_status: str): +def update_episode_incremental(series: AnilistSeries, watched_episode_count: int, anilist_episodes_watched: int, + new_status: str): # calculate episode difference and iterate up so activity stream lists # episodes watched if episode difference exceeds 32 only update most # recent as otherwise will flood the notification feed @@ -789,7 +797,8 @@ def retrieve_season_mappings(title: str, season: int) -> List[AnilistCustomMappi return season_mappings -def map_watchcount_to_seasons(title: str, season_mappings: List[AnilistCustomMapping], watched_episodes: int) -> Dict[int, int]: +def map_watchcount_to_seasons(title: str, season_mappings: List[AnilistCustomMapping], watched_episodes: int) -> Dict[ + int, int]: # mapping from anilist-id to watched episodes episodes_in_anilist_entry: Dict[int, int] = {} total_mapped_episodes = 0 diff --git a/custom_mappings.py b/custom_mappings.py index 78e596c..8062296 100644 --- a/custom_mappings.py +++ b/custom_mappings.py @@ -1,16 +1,17 @@ # coding=utf-8 -import os import logging +import os import sys -from typing import List from dataclasses import dataclass +from typing import Dict, List +import requests import yamale from yamale.yamale_error import YamaleError - logger = logging.getLogger("PlexAniSync") MAPPING_FILE = "custom_mappings.yaml" +REMOTE_MAPPING_FILE = "remote_mappings.yaml" @dataclass @@ -20,42 +21,90 @@ class AnilistCustomMapping: start: int -def read_custom_mappings(): - custom_mappings = {} +def read_custom_mappings() -> Dict[str, List[AnilistCustomMapping]]: + custom_mappings: Dict[str, List[AnilistCustomMapping]] = {} if not os.path.isfile(MAPPING_FILE): logger.info(f"[MAPPING] Custom map file not found: {MAPPING_FILE}") - else: - logger.info(f"[MAPPING] Custom map file found: {MAPPING_FILE}") - schema = yamale.make_schema('./custom_mappings_schema.yaml', parser='ruamel') + return custom_mappings + + logger.info(f"[MAPPING] Custom mapping found locally, using: {MAPPING_FILE}") - # Create a Data object - file_mappings = yamale.make_data(MAPPING_FILE, parser='ruamel') + schema = yamale.make_schema('./custom_mappings_schema.yaml', parser='ruamel') + # Create a Data object + file_mappings_local = yamale.make_data(MAPPING_FILE, parser='ruamel') + try: + # Validate data against the schema same as before. + yamale.validate(schema, file_mappings_local) + except YamaleError as e: + logger.error('[MAPPING] Custom Mappings validation failed!\n') + for result in e.results: + for error in result.errors: + logger.error(f"{error}\n") + sys.exit(1) + + remote_custom_mapping = get_custom_mapping_remote(file_mappings_local) + + # loop through list tuple + for value in remote_custom_mapping: + mapping_location = value[0] + yaml_content = value[1] try: - # Validate data against the schema same as before. - yamale.validate(schema, file_mappings) + file_mappings_remote = yamale.make_data(content=yaml_content, parser='ruamel') + yamale.validate(schema, file_mappings_remote) except YamaleError as e: - logger.error('Custom Mappings validation failed!\n') + logger.error(f'[MAPPING] Custom Mappings {mapping_location} validation failed!\n') for result in e.results: for error in result.errors: logger.error(f"{error}\n") sys.exit(1) + add_mappings(custom_mappings, mapping_location, file_mappings_remote) + + add_mappings(custom_mappings, MAPPING_FILE, file_mappings_local) - for file_entry in file_mappings[0][0]['entries']: - series_title = str(file_entry['title']) - synonyms: List[str] = file_entry.get('synonyms', []) - series_mappings: List[AnilistCustomMapping] = [] - for file_season in file_entry['seasons']: - season = file_season['season'] - anilist_id = file_season['anilist-id'] - start = file_season.get('start', 1) - - logger.info( - f"[MAPPING] Adding custom mapping | title: {series_title} | season: {season} | anilist id: {anilist_id} | start: {start}" - ) - series_mappings.append(AnilistCustomMapping(season, anilist_id, start)) - - custom_mappings[series_title.lower()] = series_mappings - for synonym in synonyms: - custom_mappings[synonym.lower()] = series_mappings return custom_mappings + + +def add_mappings(custom_mappings, mapping_location, file_mappings): + # handles missing and empty 'entries' + entries = file_mappings[0][0].get('entries', []) or [] + for file_entry in entries: + series_title = str(file_entry['title']) + synonyms: List[str] = file_entry.get('synonyms', []) + series_mappings: List[AnilistCustomMapping] = [] + for file_season in file_entry['seasons']: + season = file_season['season'] + anilist_id = file_season['anilist-id'] + start = file_season.get('start', 1) + logger.info( + f"[MAPPING] Adding custom mapping from {mapping_location} " + f"| title: {series_title} | season: {season} | anilist id: {anilist_id}" + ) + series_mappings.append(AnilistCustomMapping(season, anilist_id, start)) + if synonyms: + logger.info(f"[MAPPING] {series_title} has synonyms: {synonyms}") + for title in [series_title] + synonyms: + title_lower = title.lower() + if title_lower in custom_mappings: + logger.info(f"[MAPPING] Overwriting previous mapping for {title}") + custom_mappings[title_lower] = series_mappings + + +# Get the custom mappings from the web. +def get_custom_mapping_remote(file_mappings) -> List[tuple[str, str]]: + custom_mappings_remote: List[tuple[str, str]] = [] + # handles missing and empty 'remote-urls' + remote_mappings_urls: List[str] = file_mappings[0][0].get('remote-urls', []) or [] + + # Get url and read the data + for url in remote_mappings_urls: + file_name = url.split('/')[-1] + logger.info(f"[MAPPING] Adding remote mapping url: {url}") + + response = requests.get(url) + if response.status_code == 200: + custom_mappings_remote.append((file_name, response.text)) + else: + logger.error(f"[MAPPING] Could not download mapping file, received {response.reason}.") + + return custom_mappings_remote diff --git a/custom_mappings.yaml.example b/custom_mappings.yaml.example index 8a4f59c..42d8c26 100644 --- a/custom_mappings.yaml.example +++ b/custom_mappings.yaml.example @@ -1,3 +1,5 @@ +remote-urls: + - https://raw.githubusercontent.com/RickDB/PlexAniSync-Custom-Mappings/main/series-tvdb.en.yaml entries: - title: The Rising of the Shield Hero seasons: diff --git a/custom_mappings_schema.yaml b/custom_mappings_schema.yaml index fc62f3b..6830012 100644 --- a/custom_mappings_schema.yaml +++ b/custom_mappings_schema.yaml @@ -1,4 +1,5 @@ -entries: list(include('entry'), min=1) +remote-urls: list(str(), required=False) +entries: list(include('entry'), required=False) --- entry: title: str() diff --git a/graphql.py b/graphql.py index 927c8ce..b7790fb 100644 --- a/graphql.py +++ b/graphql.py @@ -7,10 +7,8 @@ import requests - logger = logging.getLogger("PlexAniSync") - ANILIST_ACCESS_TOKEN = "" ANILIST_SKIP_UPDATE = False diff --git a/plexmodule.py b/plexmodule.py index 42253ad..a80ceda 100644 --- a/plexmodule.py +++ b/plexmodule.py @@ -2,16 +2,15 @@ import logging import re import sys -from typing import List, Optional from dataclasses import dataclass - -from requests import Session -from requests.adapters import HTTPAdapter -from urllib3.poolmanager import PoolManager +from typing import List, Optional from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer from plexapi.video import Episode, Season, Show +from requests import Session +from requests.adapters import HTTPAdapter +from urllib3.poolmanager import PoolManager logger = logging.getLogger("PlexAniSync") plex_settings = dict() @@ -168,7 +167,8 @@ def get_watched_shows(shows: List[Show]) -> Optional[List[PlexWatchedSeries]]: if hasattr(show, "seasons"): show_seasons = show.seasons() # ignore season 0 and unwatched seasons - show_seasons = filter(lambda season: season.seasonNumber > 0 and season.viewedLeafCount > 0, show_seasons) + show_seasons = filter(lambda season: season.seasonNumber > 0 and season.viewedLeafCount > 0, + show_seasons) seasons = [] for season in show_seasons: