diff --git a/.env.sample b/.env.sample index c2e54fc..6464b80 100644 --- a/.env.sample +++ b/.env.sample @@ -2,6 +2,8 @@ DRYRUN = "True" ## Additional logging information DEBUG = "True" +## Debugging level, INFO is default, DEBUG is more verbose +DEBUG_LEVEL = "INFO" ## How often to run the script in seconds SLEEP_DURATION = "3600" ## Log file where all output will be written to diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ae8f15f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python", + "type": "python", + "request": "launch", + "program": "main.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 6b108b2..36556d5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Keep in sync all your users watched history between jellyfin and plex locally. T ## Installation -### Baremeta +### Baremetal - Setup virtualenv of your choice diff --git a/main.py b/main.py index fe32e40..4b02093 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ from dotenv import load_dotenv from time import sleep -from src.functions import logger, str_to_bool, search_mapping +from src.functions import logger, str_to_bool, search_mapping, generate_library_guids_dict from src.plex import Plex from src.jellyfin import Jellyfin @@ -35,49 +35,59 @@ def cleanup_watched(watched_list_1, watched_list_2, user_mapping=None, library_m elif library_other in watched_list_2[user_2]: library_2 = library_other else: - logger(f"User {library_1} and {library_other} not found in watched list 2", 1) + logger(f"library {library_1} and {library_other} not found in watched list 2", 1) continue - for item in watched_list_1[user_1][library_1]: - if item in modified_watched_list_1[user_1][library_1]: - # Movies - if isinstance(watched_list_1[user_1][library_1], list): - for watch_list_1_key, watch_list_1_value in item.items(): - for watch_list_2_item in watched_list_2[user_2][library_2]: - for watch_list_2_item_key, watch_list_2_item_value in watch_list_2_item.items(): - if watch_list_1_key == watch_list_2_item_key and watch_list_1_value == watch_list_2_item_value: - if item in modified_watched_list_1[user_1][library_1]: - modified_watched_list_1[user_1][library_1].remove(item) - - # TV Shows - elif isinstance(watched_list_1[user_1][library_1], dict): - if item in watched_list_2[user_2][library_2]: - for season in watched_list_1[user_1][library_1][item]: - if season in watched_list_2[user_2][library_2][item]: - for episode in watched_list_1[user_1][library_1][item][season]: - for watch_list_1_episode_key, watch_list_1_episode_value in episode.items(): - for watch_list_2_episode in watched_list_2[user_2][library_2][item][season]: - for watch_list_2_episode_key, watch_list_2_episode_value in watch_list_2_episode.items(): - if watch_list_1_episode_key == watch_list_2_episode_key and watch_list_1_episode_value == watch_list_2_episode_value: - if episode in modified_watched_list_1[user_1][library_1][item][season]: - modified_watched_list_1[user_1][library_1][item][season].remove(episode) - - # If season is empty, remove season - if len(modified_watched_list_1[user_1][library_1][item][season]) == 0: - if season in modified_watched_list_1[user_1][library_1][item]: - del modified_watched_list_1[user_1][library_1][item][season] - - # If the show is empty, remove the show - if len(modified_watched_list_1[user_1][library_1][item]) == 0: - if item in modified_watched_list_1[user_1][library_1]: - del modified_watched_list_1[user_1][library_1][item] - - # If library is empty then remove it - if len(modified_watched_list_1[user_1][library_1]) == 0: - if library_1 in modified_watched_list_1[user_1]: - del modified_watched_list_1[user_1][library_1] + + # Movies + if isinstance(watched_list_1[user_1][library_1], list): + for item in watched_list_1[user_1][library_1]: + for watch_list_1_key, watch_list_1_value in item.items(): + for watch_list_2_item in watched_list_2[user_2][library_2]: + for watch_list_2_item_key, watch_list_2_item_value in watch_list_2_item.items(): + if watch_list_1_key == watch_list_2_item_key and watch_list_1_value == watch_list_2_item_value: + if item in modified_watched_list_1[user_1][library_1]: + logger(f"Removing {item} from {library_1}", 3) + modified_watched_list_1[user_1][library_1].remove(item) + + + # TV Shows + elif isinstance(watched_list_1[user_1][library_1], dict): + # Generate full list of provider ids for episodes in watch_list_2 to easily compare if they exist in watch_list_1 + _, episode_watched_list_2_keys_dict, _ = generate_library_guids_dict(watched_list_2[user_2][library_2], 1) + + for show_key_1 in watched_list_1[user_1][library_1].keys(): + show_key_dict = dict(show_key_1) + for season in watched_list_1[user_1][library_1][show_key_1]: + for episode in watched_list_1[user_1][library_1][show_key_1][season]: + for episode_key, episode_item in episode.items(): + # If episode_key and episode_item are in episode_watched_list_2_keys_dict exactly, then remove from watch_list_1 + if episode_key in episode_watched_list_2_keys_dict.keys(): + if episode_item in episode_watched_list_2_keys_dict[episode_key]: + if episode in modified_watched_list_1[user_1][library_1][show_key_1][season]: + logger(f"Removing {show_key_dict['title']} {episode} from {library_1}", 3) + modified_watched_list_1[user_1][library_1][show_key_1][season].remove(episode) + + # Remove empty seasons + if len(modified_watched_list_1[user_1][library_1][show_key_1][season]) == 0: + if season in modified_watched_list_1[user_1][library_1][show_key_1]: + logger(f"Removing {season} from {library_1} because it is empty", 3) + del modified_watched_list_1[user_1][library_1][show_key_1][season] + + # If the show is empty, remove the show + if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0: + if show_key_1 in modified_watched_list_1[user_1][library_1]: + logger(f"Removing {show_key_dict['title']} from {library_1} because it is empty", 1) + del modified_watched_list_1[user_1][library_1][show_key_1] + + # If library is empty then remove it + if len(modified_watched_list_1[user_1][library_1]) == 0: + if library_1 in modified_watched_list_1[user_1]: + logger(f"Removing {library_1} from {user_1} because it is empty", 1) + del modified_watched_list_1[user_1][library_1] # If user is empty delete user if len(modified_watched_list_1[user_1]) == 0: + logger(f"Removing {user_1} from watched list 1 because it is empty", 1) del modified_watched_list_1[user_1] return modified_watched_list_1 @@ -258,10 +268,13 @@ def main(): plex_watched_filtered = copy.deepcopy(plex_watched) jellyfin_watched_filtered = copy.deepcopy(jellyfin_watched) + logger("Cleaning Plex Watched", 1) plex_watched = cleanup_watched(plex_watched_filtered, jellyfin_watched_filtered, user_mapping, library_mapping) - logger(f"plex_watched that needs to be synced to jellyfin:\n{plex_watched}", 1) + logger("Cleaning Jellyfin Watched", 1) jellyfin_watched = cleanup_watched(jellyfin_watched_filtered, plex_watched_filtered, user_mapping, library_mapping) + + logger(f"plex_watched that needs to be synced to jellyfin:\n{plex_watched}", 1) logger(f"jellyfin_watched that needs to be synced to plex:\n{jellyfin_watched}", 1) # Update watched status diff --git a/src/functions.py b/src/functions.py index b371212..e246ead 100644 --- a/src/functions.py +++ b/src/functions.py @@ -6,14 +6,17 @@ def logger(message, log_type=0): debug = str_to_bool(os.getenv("DEBUG", "True")) + debug_level = os.getenv("DEBUG_LEVEL", "INFO") output = str(message) if log_type == 0: pass - elif log_type == 1 and debug: + elif log_type == 1 and (debug or debug_level == "INFO"): output = f"[INFO]: {output}" elif log_type == 2: output = f"[ERROR]: {output}" + elif log_type == 3 and (debug and debug_level == "DEBUG"): + output = f"[DEBUG]: {output}" else: output = None @@ -73,3 +76,41 @@ def check_skip_logic(library_title, library_type, blacklist_library, whitelist_l skip_reason = "is not whitelist_library" return skip_reason + + +def generate_library_guids_dict(user_list: dict, generate_output: int): + # if generate_output is 0 then only generate shows, if 1 then only generate episodes, if 2 then generate movies, if 3 then generate shows and episodes + show_output_dict = {} + episode_output_dict = {} + movies_output_dict = {} + + if generate_output in (0, 3): + show_output_keys = user_list.keys() + show_output_keys = ([ dict(x) for x in list(show_output_keys) ]) + for show_key in show_output_keys: + for provider_key, prvider_value in show_key.items(): + # Skip title + if provider_key.lower() == "title": + continue + if provider_key.lower() not in show_output_dict: + show_output_dict[provider_key.lower()] = [] + show_output_dict[provider_key.lower()].append(prvider_value.lower()) + + if generate_output in (1, 3): + for show in user_list: + for season in user_list[show]: + for episode in user_list[show][season]: + for episode_key, episode_value in episode.items(): + if episode_key.lower() not in episode_output_dict: + episode_output_dict[episode_key.lower()] = [] + episode_output_dict[episode_key.lower()].append(episode_value.lower()) + + if generate_output == 2: + for movie in user_list: + for movie_key, movie_value in movie.items(): + if movie_key.lower() not in movies_output_dict: + movies_output_dict[movie_key.lower()] = [] + movies_output_dict[movie_key.lower()].append(movie_value.lower()) + + return show_output_dict, episode_output_dict, movies_output_dict + diff --git a/src/jellyfin.py b/src/jellyfin.py index 5cd0a9c..44332c6 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -1,6 +1,6 @@ import requests, os from dotenv import load_dotenv -from src.functions import logger, search_mapping, str_to_bool, check_skip_logic +from src.functions import logger, search_mapping, str_to_bool, check_skip_logic, generate_library_guids_dict load_dotenv(override=True) @@ -99,11 +99,14 @@ def get_jellyfin_watched(self, users, blacklist_library, whitelist_library, blac # TV Shows if library_type == "Episode": - watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}", "get") + watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Fields=ItemCounts,ProviderIds", "get") watched_shows = [x for x in watched["Items"] if x["Type"] == "Series"] for show in watched_shows: - seasons = self.query(f"/Shows/{show['Id']}/Seasons?userId={user_id}&Fields=ItemCounts", "get") + show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()} + show_guids["title"] = show["Name"] + show_guids = frozenset(show_guids.items()) + seasons = self.query(f"/Shows/{show['Id']}/Seasons?userId={user_id}&Fields=ItemCounts,ProviderIds", "get") if len(seasons["Items"]) > 0: for season in seasons["Items"]: episodes = self.query(f"/Shows/{show['Id']}/Episodes?seasonId={season['Id']}&userId={user_id}&Fields=ItemCounts,ProviderIds", "get") @@ -115,14 +118,14 @@ def get_jellyfin_watched(self, users, blacklist_library, whitelist_library, blac users_watched[user_name] = {} if library_title not in users_watched[user_name]: users_watched[user_name][library_title] = {} - if show["Name"] not in users_watched[user_name][library_title]: - users_watched[user_name][library_title][show["Name"]] = {} - if season["Name"] not in users_watched[user_name][library_title][show["Name"]]: - users_watched[user_name][library_title][show["Name"]][season["Name"]] = [] + if show_guids not in users_watched[user_name][library_title]: + users_watched[user_name][library_title][show_guids] = {} + if season["Name"] not in users_watched[user_name][library_title][show_guids]: + users_watched[user_name][library_title][show_guids][season["Name"]] = [] # Lowercase episode["ProviderIds"] keys episode["ProviderIds"] = {k.lower(): v for k, v in episode["ProviderIds"].items()} - users_watched[user_name][library_title][show["Name"]][season["Name"]].append(episode["ProviderIds"]) + users_watched[user_name][library_title][show_guids][season["Name"]].append(episode["ProviderIds"]) return users_watched @@ -141,7 +144,7 @@ def update_watched(self, watched_list, user_mapping=None, library_mapping=None, user = user_other user_id = None - for key, value in self.users.items(): + for key in self.users.keys(): if user.lower() == key.lower(): user_id = self.users[key] break @@ -182,13 +185,16 @@ def update_watched(self, watched_list, user_mapping=None, library_mapping=None, # Movies if library_type == "Movie": - jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds", "get") + _, _, videos_movies_ids = generate_library_guids_dict(videos, 2) + + jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds", "get") for jellyfin_video in jellyfin_search["Items"]: if str_to_bool(jellyfin_video["UserData"]["Played"]) == False: jellyfin_video_id = jellyfin_video["Id"] - for video in videos: - for key, value in jellyfin_video["ProviderIds"].items(): - if key.lower() in video.keys() and value.lower() == video[key.lower()].lower(): + + for movie_provider_source, movie_provider_id in jellyfin_video["ProviderIds"].items(): + if movie_provider_source.lower() in videos_movies_ids: + if movie_provider_id.lower() in videos_movies_ids[movie_provider_source.lower()]: msg = f"{jellyfin_video['Name']} as watched for {user} in {library} for Jellyfin" if not dryrun: logger(f"Marking {msg}", 0) @@ -199,25 +205,33 @@ def update_watched(self, watched_list, user_mapping=None, library_mapping=None, # TV Shows if library_type == "Episode": - jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&isPlayed=false", "get") - jellyfin_shows = [x for x in jellyfin_search["Items"] if x["Type"] == "Series"] + videos_shows_ids, videos_episode_ids, _ = generate_library_guids_dict(videos, 3) + + jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds", "get") + jellyfin_shows = [x for x in jellyfin_search["Items"]] for jellyfin_show in jellyfin_shows: - if jellyfin_show["Name"] in videos.keys(): - jellyfin_show_id = jellyfin_show["Id"] - jellyfin_episodes = self.query(f"/Shows/{jellyfin_show_id}/Episodes?userId={user_id}&Fields=ItemCounts,ProviderIds", "get") - for jellyfin_episode in jellyfin_episodes["Items"]: - if str_to_bool(jellyfin_episode["UserData"]["Played"]) == False: - jellyfin_episode_id = jellyfin_episode["Id"] - for show in videos: - for season in videos[show]: - for episode in videos[show][season]: - for key, value in jellyfin_episode["ProviderIds"].items(): - if key.lower() in episode.keys() and value.lower() == episode[key.lower()].lower(): - msg = f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} {jellyfin_episode['Name']} as watched for {user} in {library} for Jellyfin" + show_found = False + for show_provider_source, show_provider_id in jellyfin_show["ProviderIds"].items(): + if show_provider_source.lower() in videos_shows_ids: + if show_provider_id.lower() in videos_shows_ids[show_provider_source.lower()]: + show_found = True + jellyfin_show_id = jellyfin_show["Id"] + jellyfin_episodes = self.query(f"/Shows/{jellyfin_show_id}/Episodes?userId={user_id}&Fields=ItemCounts,ProviderIds", "get") + for jellyfin_episode in jellyfin_episodes["Items"]: + if str_to_bool(jellyfin_episode["UserData"]["Played"]) == False: + jellyfin_episode_id = jellyfin_episode["Id"] + + for episode_provider_source, episode_provider_id in jellyfin_episode["ProviderIds"].items(): + if episode_provider_source.lower() in videos_episode_ids: + if episode_provider_id.lower() in videos_episode_ids[episode_provider_source.lower()]: + msg = f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']} as watched for {user} in {library} for Jellyfin" if not dryrun: logger(f"Marked {msg}", 0) self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", "post") else: logger(f"Dryrun {msg}", 0) break + + if show_found: + break diff --git a/src/plex.py b/src/plex.py index e8b0350..5207214 100644 --- a/src/plex.py +++ b/src/plex.py @@ -1,7 +1,7 @@ import re, os from dotenv import load_dotenv -from src.functions import logger, search_mapping, check_skip_logic +from src.functions import logger, search_mapping, check_skip_logic, generate_library_guids_dict from plexapi.server import PlexServer from plexapi.myplex import MyPlexAccount @@ -78,26 +78,35 @@ def get_plex_user_watched(self, user, library): watched = {} library_videos = user_plex.library.section(library.title) for show in library_videos.search(unmatched=False, unwatched=False): + show_guids = {} + for show_guid in show.guids: + show_guids["title"] = show.title + # Extract after :// from guid.id + show_guid_source = re.search(r'(.*)://', show_guid.id).group(1).lower() + show_guid_id = re.search(r'://(.*)', show_guid.id).group(1) + show_guids[show_guid_source] = show_guid_id + show_guids = frozenset(show_guids.items()) + for season in show.seasons(): - guids = [] + episode_guids = [] for episode in season.episodes(): if episode.viewCount > 0: - guids_temp = {} + episode_guids_temp = {} for guid in episode.guids: # Extract after :// from guid.id guid_source = re.search(r'(.*)://', guid.id).group(1).lower() guid_id = re.search(r'://(.*)', guid.id).group(1) - guids_temp[guid_source] = guid_id + episode_guids_temp[guid_source] = guid_id - guids.append(guids_temp) + episode_guids.append(episode_guids_temp) - if guids: + if episode_guids: # append show, season, episode - if show.title not in watched: - watched[show.title] = {} - if season.title not in watched[show.title]: - watched[show.title][season.title] = {} - watched[show.title][season.title] = guids + if show_guids not in watched: + watched[show_guids] = {} + if season.title not in watched[show_guids]: + watched[show_guids][season.title] = {} + watched[show_guids][season.title] = episode_guids return watched @@ -177,40 +186,53 @@ def update_watched(self, watched_list, user_mapping=None, library_mapping=None, library_videos = user_plex.library.section(library) if library_videos.type == "movie": + _, _, videos_movies_ids = generate_library_guids_dict(videos, 2) for movies_search in library_videos.search(unmatched=False, unwatched=True): - for guid in movies_search.guids: - guid_source = re.search(r'(.*)://', guid.id).group(1).lower() - guid_id = re.search(r'://(.*)', guid.id).group(1) - for video in videos: - for video_keys, video_id in video.items(): - if video_keys == guid_source and video_id == guid_id: - if movies_search.viewCount == 0: - msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex" - if not dryrun: - logger(f"Marked {msg}", 0) - movies_search.markWatched() - else: - logger(f"Dryrun {msg}", 0) - break + for movie_guid in movies_search.guids: + movie_guid_source = re.search(r'(.*)://', movie_guid.id).group(1).lower() + movie_guid_id = re.search(r'://(.*)', movie_guid.id).group(1) + # If movie provider source and movie provider id are in videos_movie_ids exactly, then the movie is in the list + if movie_guid_source in videos_movies_ids.keys(): + if movie_guid_id in videos_movies_ids[movie_guid_source]: + if movies_search.viewCount == 0: + msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex" + if not dryrun: + logger(f"Marked {msg}", 0) + movies_search.markWatched() + else: + logger(f"Dryrun {msg}", 0) + break + elif library_videos.type == "show": + videos_shows_ids, videos_episode_ids, _ = generate_library_guids_dict(videos, 3) + for show_search in library_videos.search(unmatched=False, unwatched=True): - if show_search.title in videos: - for season_search in show_search.seasons(): - for episode_search in season_search.episodes(): - for guid in episode_search.guids: - guid_source = re.search(r'(.*)://', guid.id).group(1).lower() - guid_id = re.search(r'://(.*)', guid.id).group(1) - for show in videos: - for season in videos[show]: - for episode in videos[show][season]: - for episode_keys, episode_id in episode.items(): - if episode_keys == guid_source and episode_id == guid_id: - if episode_search.viewCount == 0: - msg = f"{show_search.title} {season_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex" - if not dryrun: - logger(f"Marked {msg}", 0) - episode_search.markWatched() - else: - logger(f"Dryrun {msg}", 0) - break + show_found = False + for show_guid in show_search.guids: + show_guid_source = re.search(r'(.*)://', show_guid.id).group(1).lower() + show_guid_id = re.search(r'://(.*)', show_guid.id).group(1) + + # If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list + if show_guid_source in videos_shows_ids.keys(): + if show_guid_id in videos_shows_ids[show_guid_source]: + show_found = True + for episode_search in show_search.episodes(): + for episode_guid in episode_search.guids: + episode_guid_source = re.search(r'(.*)://', episode_guid.id).group(1).lower() + episode_guid_id = re.search(r'://(.*)', episode_guid.id).group(1) + + # If episode provider source and episode provider id are in videos_episode_ids exactly, then the episode is in the list + if episode_guid_source in videos_episode_ids.keys(): + if episode_guid_id in videos_episode_ids[episode_guid_source]: + if episode_search.viewCount == 0: + msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex" + if not dryrun: + logger(f"Marked {msg}", 0) + episode_search.markWatched() + else: + logger(f"Dryrun {msg}", 0) + break + + if show_found: + break