diff --git a/.env.sample b/.env.sample index 6464b80..cce23ea 100644 --- a/.env.sample +++ b/.env.sample @@ -2,8 +2,8 @@ DRYRUN = "True" ## Additional logging information DEBUG = "True" -## Debugging level, INFO is default, DEBUG is more verbose -DEBUG_LEVEL = "INFO" +## 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 @@ -16,6 +16,7 @@ LOGFILE = "log.log" ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers PLEX_BASEURL = "http://localhost:32400" ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ PLEX_TOKEN = "SuperSecretToken" @@ -26,6 +27,7 @@ PLEX_TOKEN = "SuperSecretToken" ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers JELLYFIN_BASEURL = "http://localhost:8096" ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key JELLYFIN_TOKEN = "SuperSecretToken" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e606928..e56dbcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,20 @@ on: - "*.md" jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: "Install dependencies" + run: pip install -r requirements.txt && pip install -r test/requirements.txt + + - name: "Run tests" + run: pytest -vvv + docker: runs-on: ubuntu-latest + needs: pytest steps: - name: Checkout uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 5ac8951..06e876a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +*.prof # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.vscode/launch.json b/.vscode/launch.json index ae8f15f..aa73483 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,16 +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 + // 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: Main", + "type": "python", + "request": "launch", + "program": "main.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} diff --git a/Dockerfile b/Dockerfile index e267be0..ab72099 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,30 @@ FROM python:3-slim +ENV DRYRUN 'True' +ENV DEBUG 'True' +ENV DEBUG_LEVEL 'INFO' +ENV SLEEP_DURATION '3600' +ENV LOGFILE 'log.log' + +ENV USER_MAPPING '{ "User Test": "User Test2" }' +ENV LIBRARY_MAPPING '{ "Shows Test": "TV Shows Test" }' + +ENV PLEX_BASEURL 'http://localhost:32400' +ENV PLEX_TOKEN '' +ENV PLEX_USERNAME '' +ENV PLEX_PASSWORD '' +ENV PLEX_SERVERNAME '' + +ENV JELLYFIN_BASEURL 'http://localhost:8096' +ENV JELLYFIN_TOKEN '' + +ENV BLACKLIST_LIBRARY '' +ENV WHITELIST_LIBRARY '' +ENV BLACKLIST_LIBRARY_TYPE '' +ENV WHITELIST_LIBRARY_TYPE '' +ENV BLACKLIST_USERS '' +ENV WHITELIST_USERS '' + WORKDIR /app COPY ./requirements.txt ./ @@ -7,4 +32,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["python", "-u", "main.py"] \ No newline at end of file +CMD ["python", "-u", "main.py"] diff --git a/README.md b/README.md index 36556d5..0da25f4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,11 @@ Sync watched between jellyfin and plex ## Description -Keep in sync all your users watched history between jellyfin and plex locally. This uses the imdb ids and any other matching id to find the correct episode/movie between the two. This is not perfect but it works for most cases. +Keep in sync all your users watched history between jellyfin and plex servers locally. This uses the imdb ids and any other matching id to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by enterying multiple options in the .env plex/jellyfin section seperated by commas. + +## Configuration + + ## Installation diff --git a/main.py b/main.py index 4b02093..866f866 100644 --- a/main.py +++ b/main.py @@ -1,307 +1,10 @@ -import copy, os, traceback, json -from dotenv import load_dotenv -from time import sleep +import sys -from src.functions import logger, str_to_bool, search_mapping, generate_library_guids_dict -from src.plex import Plex -from src.jellyfin import Jellyfin +if __name__ == '__main__': + # Check python version 3.6 or higher + if not (3, 6) <= tuple(map(int, sys.version_info[:2])): + print("This script requires Python 3.6 or higher") + sys.exit(1) -load_dotenv(override=True) - -def cleanup_watched(watched_list_1, watched_list_2, user_mapping=None, library_mapping=None): - modified_watched_list_1 = copy.deepcopy(watched_list_1) - - # remove entries from plex_watched that are in jellyfin_watched - for user_1 in watched_list_1: - user_other = None - if user_mapping: - user_other = search_mapping(user_mapping, user_1) - if user_1 in modified_watched_list_1: - if user_1 in watched_list_2: - user_2 = user_1 - elif user_other in watched_list_2: - user_2 = user_other - else: - logger(f"User {user_1} and {user_other} not found in watched list 2", 1) - continue - - for library_1 in watched_list_1[user_1]: - library_other = None - if library_mapping: - library_other = search_mapping(library_mapping, library_1) - if library_1 in modified_watched_list_1[user_1]: - if library_1 in watched_list_2[user_2]: - library_2 = library_1 - elif library_other in watched_list_2[user_2]: - library_2 = library_other - else: - logger(f"library {library_1} and {library_other} not found in watched list 2", 1) - continue - - # 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 - -def setup_black_white_lists(library_mapping=None): - blacklist_library = os.getenv("BLACKLIST_LIBRARY") - if blacklist_library: - if len(blacklist_library) > 0: - blacklist_library = blacklist_library.split(",") - blacklist_library = [x.strip() for x in blacklist_library] - if library_mapping: - temp_library = [] - for library in blacklist_library: - library_other = search_mapping(library_mapping, library) - if library_other: - temp_library.append(library_other) - - blacklist_library = blacklist_library + temp_library - else: - blacklist_library = [] - - logger(f"Blacklist Library: {blacklist_library}", 1) - - whitelist_library = os.getenv("WHITELIST_LIBRARY") - if whitelist_library: - if len(whitelist_library) > 0: - whitelist_library = whitelist_library.split(",") - whitelist_library = [x.strip() for x in whitelist_library] - if library_mapping: - temp_library = [] - for library in whitelist_library: - library_other = search_mapping(library_mapping, library) - if library_other: - temp_library.append(library_other) - - whitelist_library = whitelist_library + temp_library - else: - whitelist_library = [] - logger(f"Whitelist Library: {whitelist_library}", 1) - - blacklist_library_type = os.getenv("BLACKLIST_LIBRARY_TYPE") - if blacklist_library_type: - if len(blacklist_library_type) > 0: - blacklist_library_type = blacklist_library_type.split(",") - blacklist_library_type = [x.lower().strip() for x in blacklist_library_type] - else: - blacklist_library_type = [] - logger(f"Blacklist Library Type: {blacklist_library_type}", 1) - - whitelist_library_type = os.getenv("WHITELIST_LIBRARY_TYPE") - if whitelist_library_type: - if len(whitelist_library_type) > 0: - whitelist_library_type = whitelist_library_type.split(",") - whitelist_library_type = [x.lower().strip() for x in whitelist_library_type] - else: - whitelist_library_type = [] - logger(f"Whitelist Library Type: {whitelist_library_type}", 1) - - blacklist_users = os.getenv("BLACKLIST_USERS") - if blacklist_users: - if len(blacklist_users) > 0: - blacklist_users = blacklist_users.split(",") - blacklist_users = [x.lower().strip() for x in blacklist_users] - else: - blacklist_users = [] - logger(f"Blacklist Users: {blacklist_users}", 1) - - whitelist_users = os.getenv("WHITELIST_USERS") - if whitelist_users: - if len(whitelist_users) > 0: - whitelist_users = whitelist_users.split(",") - whitelist_users = [x.lower().strip() for x in whitelist_users] - else: - whitelist_users = [] - else: - whitelist_users = [] - logger(f"Whitelist Users: {whitelist_users}", 1) - - return blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users - -def setup_users(plex, jellyfin, blacklist_users, whitelist_users, user_mapping=None): - - # generate list of users from plex.users - plex_users = [ x.title.lower() for x in plex.users ] - jellyfin_users = [ key.lower() for key in jellyfin.users.keys() ] - - # combined list of overlapping users from plex and jellyfin - users = {} - - for plex_user in plex_users: - if user_mapping: - jellyfin_plex_mapped_user = search_mapping(user_mapping, plex_user) - if jellyfin_plex_mapped_user: - users[plex_user] = jellyfin_plex_mapped_user - continue - - if plex_user in jellyfin_users: - users[plex_user] = plex_user - - for jellyfin_user in jellyfin_users: - if user_mapping: - plex_jellyfin_mapped_user = search_mapping(user_mapping, jellyfin_user) - if plex_jellyfin_mapped_user: - users[plex_jellyfin_mapped_user] = jellyfin_user - continue - - if jellyfin_user in plex_users: - users[jellyfin_user] = jellyfin_user - - logger(f"User list that exist on both servers {users}", 1) - - users_filtered = {} - for user in users: - # whitelist_user is not empty and user lowercase is not in whitelist lowercase - if len(whitelist_users) > 0: - if user not in whitelist_users and users[user] not in whitelist_users: - logger(f"{user} or {users[user]} is not in whitelist", 1) - continue - - if user not in blacklist_users and users[user] not in blacklist_users: - users_filtered[user] = users[user] - - logger(f"Filtered user list {users_filtered}", 1) - - plex_users = [] - for plex_user in plex.users: - if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values(): - plex_users.append(plex_user) - - jellyfin_users = {} - for jellyfin_user, jellyfin_id in jellyfin.users.items(): - if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values(): - jellyfin_users[jellyfin_user] = jellyfin_id - - if len(plex_users) == 0: - raise Exception(f"No plex users found, users found {users} filtered users {users_filtered}") - - if len(jellyfin_users) == 0: - raise Exception(f"No jellyfin users found, users found {users} filtered users {users_filtered}") - - logger(f"plex_users: {plex_users}", 1) - logger(f"jellyfin_users: {jellyfin_users}", 1) - - return plex_users, jellyfin_users - -def main(): - logfile = os.getenv("LOGFILE","log.log") - # Delete logfile if it exists - if os.path.exists(logfile): - os.remove(logfile) - - dryrun = str_to_bool(os.getenv("DRYRUN", "False")) - logger(f"Dryrun: {dryrun}", 1) - - user_mapping = os.getenv("USER_MAPPING") - if user_mapping: - user_mapping = json.loads(user_mapping.lower()) - logger(f"User Mapping: {user_mapping}", 1) - - library_mapping = os.getenv("LIBRARY_MAPPING") - if library_mapping: - library_mapping = json.loads(library_mapping) - logger(f"Library Mapping: {library_mapping}", 1) - - plex = Plex() - jellyfin = Jellyfin() - - # Create (black/white)lists - blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users = setup_black_white_lists(library_mapping) - - # Create users list - plex_users, jellyfin_users = setup_users(plex, jellyfin, blacklist_users, whitelist_users, user_mapping) - - plex_watched = plex.get_plex_watched(plex_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) - jellyfin_watched = jellyfin.get_jellyfin_watched(jellyfin_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) - - # clone watched so it isnt modified in the cleanup function so all duplicates are actually removed - 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("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 - plex.update_watched(jellyfin_watched, user_mapping, library_mapping, dryrun) - jellyfin.update_watched(plex_watched, user_mapping, library_mapping, dryrun) - - -if __name__ == "__main__": - sleep_timer = float(os.getenv("SLEEP_TIMER", "3600")) - - while(True): - try: - main() - logger(f"Looping in {sleep_timer}") - except Exception as error: - if isinstance(error, list): - for message in error: - logger(message, log_type=2) - else: - logger(error, log_type=2) - - - logger(traceback.format_exc(), 2) - logger(f"Retrying in {sleep_timer}", log_type=0) - - except KeyboardInterrupt: - logger("Exiting", log_type=0) - os._exit(0) - - sleep(sleep_timer) + from src.main import main + main() diff --git a/src/functions.py b/src/functions.py index e246ead..8b404ec 100644 --- a/src/functions.py +++ b/src/functions.py @@ -1,21 +1,23 @@ import os +from concurrent.futures import ThreadPoolExecutor from dotenv import load_dotenv + load_dotenv(override=True) logfile = os.getenv("LOGFILE","log.log") -def logger(message, log_type=0): +def logger(message: str, log_type=0): debug = str_to_bool(os.getenv("DEBUG", "True")) - debug_level = os.getenv("DEBUG_LEVEL", "INFO") + debug_level = os.getenv("DEBUG_LEVEL", "info").lower() output = str(message) if log_type == 0: pass - elif log_type == 1 and (debug or debug_level == "INFO"): + 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"): + elif log_type == 3 and (debug and debug_level == "debug"): output = f"[DEBUG]: {output}" else: output = None @@ -37,11 +39,11 @@ def search_mapping(dictionary: dict, key_value: str): if key_value in dictionary.keys(): return dictionary[key_value] elif key_value.lower() in dictionary.keys(): - return dictionary[key_value] + return dictionary[key_value.lower()] elif key_value in dictionary.values(): return list(dictionary.keys())[list(dictionary.values()).index(key_value)] elif key_value.lower() in dictionary.values(): - return list(dictionary.keys())[list(dictionary.values()).index(key_value)] + return list(dictionary.keys())[list(dictionary.values()).index(key_value.lower())] else: return None @@ -88,13 +90,17 @@ def generate_library_guids_dict(user_list: dict, generate_output: int): 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(): + for provider_key, provider_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 provider_key.lower() == "locations": + for show_location in provider_value: + show_output_dict[provider_key.lower()].append(show_location) + else: + show_output_dict[provider_key.lower()].append(provider_value.lower()) if generate_output in (1, 3): for show in user_list: @@ -103,14 +109,42 @@ def generate_library_guids_dict(user_list: dict, generate_output: int): 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 episode_key == "locations": + for episode_location in episode_value: + episode_output_dict[episode_key.lower()].append(episode_location) + else: + 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()) + if movie_key == "locations": + for movie_location in movie_value: + movies_output_dict[movie_key.lower()].append(movie_location) + else: + movies_output_dict[movie_key.lower()].append(movie_value.lower()) return show_output_dict, episode_output_dict, movies_output_dict +def future_thread_executor(args: list, workers: int = -1): + futures_list = [] + results = [] + + if workers == -1: + workers = min(32, os.cpu_count()*1.25) + + with ThreadPoolExecutor(max_workers=workers) as executor: + for arg in args: + # * arg unpacks the list into actual arguments + futures_list.append(executor.submit(*arg)) + + for future in futures_list: + try: + result = future.result() + results.append(result) + except Exception as e: + raise Exception(e) + + return results diff --git a/src/jellyfin.py b/src/jellyfin.py index 44332c6..f16ac3c 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -1,16 +1,11 @@ -import requests, os -from dotenv import load_dotenv -from src.functions import logger, search_mapping, str_to_bool, check_skip_logic, generate_library_guids_dict - -load_dotenv(override=True) - -jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL") -jellyfin_token = os.getenv("JELLYFIN_TOKEN") +import requests +from src.functions import logger, search_mapping, str_to_bool, check_skip_logic, generate_library_guids_dict, future_thread_executor class Jellyfin(): - def __init__(self): - self.baseurl = jellyfin_baseurl - self.token = jellyfin_token + def __init__(self, baseurl, token): + self.baseurl = baseurl + self.token = token + self.session = requests.Session() if not self.baseurl: raise Exception("Jellyfin baseurl not set") @@ -25,8 +20,12 @@ def query(self, query, query_type): try: response = None + headers = { + "Accept": "application/json", + "X-Emby-Token": self.token + } if query_type == "get": - response = requests.get(self.baseurl + query, headers={"accept":"application/json", "X-Emby-Token": self.token}) + response = self.session.get(self.baseurl + query, headers=headers) elif query_type == "post": authorization = ( @@ -36,202 +35,285 @@ def query(self, query, query_type): 'DeviceId="script", ' 'Version="0.0.0"' ) - response = requests.post(self.baseurl + query, headers={"accept":"application/json", "X-Emby-Authorization": authorization, "X-Emby-Token": self.token}) + headers["X-Emby-Authorization"] = authorization + response = self.session.post(self.baseurl + query, headers=headers) return response.json() + except Exception as e: - logger(e, 2) - logger(response, 2) + logger(f"Jellyfin: Query failed {e}", 2) + raise Exception(e) def get_users(self): - users = {} - - query = "/Users" - response = self.query(query, "get") + try: + users = {} - # If reponse is not empty - if response: - for user in response: - users[user["Name"]] = user["Id"] + query = "/Users" + response = self.query(query, "get") - return users + # If reponse is not empty + if response: + for user in response: + users[user["Name"]] = user["Id"] - def get_jellyfin_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping=None): - users_watched = {} + return users + except Exception as e: + logger(f"Jellyfin: Get users failed {e}", 2) + raise Exception(e) - for user_name, user_id in users.items(): - # Get all libraries + def get_user_watched(self, user_name, user_id, library_type, library_id, library_title): + try: user_name = user_name.lower() + user_watched = {} + user_watched[user_name] = {} + + logger(f"Jellyfin: Generating watched for {user_name} in library {library_title}", 0) + # Movies + if library_type == "Movie": + user_watched[user_name][library_title] = [] + watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&Fields=ItemCounts,ProviderIds,MediaSources", "get") + for movie in watched["Items"]: + if movie["UserData"]["Played"] == True: + movie_guids = {} + movie_guids["title"] = movie["Name"] + if movie["ProviderIds"]: + # Lowercase movie["ProviderIds"] keys + movie_guids = {k.lower(): v for k, v in movie["ProviderIds"].items()} + if movie["MediaSources"]: + movie_guids["locations"] = tuple([x["Path"].split("/")[-1] for x in movie["MediaSources"]]) + user_watched[user_name][library_title].append(movie_guids) + + # TV Shows + if library_type == "Episode": + user_watched[user_name][library_title] = {} + watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Fields=ItemCounts,ProviderIds,Path", "get") + watched_shows = [x for x in watched["Items"] if x["Type"] == "Series"] + + for show in watched_shows: + show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()} + show_guids["title"] = show["Name"] + show_guids["locations"] = tuple([show["Path"].split("/")[-1]]) + 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,MediaSources", "get") + if len(episodes["Items"]) > 0: + for episode in episodes["Items"]: + if episode["UserData"]["Played"] == True: + if episode["ProviderIds"] or episode["MediaSources"]: + if show_guids not in user_watched[user_name][library_title]: + user_watched[user_name][library_title][show_guids] = {} + if season["Name"] not in user_watched[user_name][library_title][show_guids]: + user_watched[user_name][library_title][show_guids][season["Name"]] = [] + + # Lowercase episode["ProviderIds"] keys + episode_guids = {} + if episode["ProviderIds"]: + episode_guids = {k.lower(): v for k, v in episode["ProviderIds"].items()} + if episode["MediaSources"]: + episode_guids["locations"] = tuple([x["Path"].split("/")[-1] for x in episode["MediaSources"]]) + user_watched[user_name][library_title][show_guids][season["Name"]].append(episode_guids) - libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"] + return user_watched + except Exception as e: + logger(f"Jellyfin: Failed to get watched for {user_name} in library {library_title}, Error: {e}", 2) + raise Exception(e) - for library in libraries: - library_title = library["Name"] - library_id = library["Id"] - watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&limit=1", "get") - if len(watched["Items"]) == 0: - logger(f"Jellyfin: No watched items found in library {library_title}", 1) - continue - else: - library_type = watched["Items"][0]["Type"] + def get_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping=None): + try: + users_watched = {} + args = [] - skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) + for user_name, user_id in users.items(): + # Get all libraries + user_name = user_name.lower() - if skip_reason: - logger(f"Jellyfin: Skipping library {library_title} {skip_reason}", 1) - continue + libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"] - logger(f"Jellyfin: Generating watched for {user_name} in library {library_title}", 0) - # Movies - if library_type == "Movie": - watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&Fields=ItemCounts,ProviderIds", "get") - for movie in watched["Items"]: - if movie["UserData"]["Played"] == True: - if movie["ProviderIds"]: - if user_name not in users_watched: - users_watched[user_name] = {} - if library_title not in users_watched[user_name]: - users_watched[user_name][library_title] = [] - # Lowercase movie["ProviderIds"] keys - movie["ProviderIds"] = {k.lower(): v for k, v in movie["ProviderIds"].items()} - users_watched[user_name][library_title].append(movie["ProviderIds"]) - - # TV Shows - if library_type == "Episode": - 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: - 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") - if len(episodes["Items"]) > 0: - for episode in episodes["Items"]: - if episode["UserData"]["Played"] == True: - if episode["ProviderIds"]: - if user_name not in users_watched: - users_watched[user_name] = {} - if library_title not in users_watched[user_name]: - users_watched[user_name][library_title] = {} - 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"]] = [] + for library in libraries: + library_title = library["Name"] + library_id = library["Id"] + watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&limit=1", "get") - # Lowercase episode["ProviderIds"] keys - episode["ProviderIds"] = {k.lower(): v for k, v in episode["ProviderIds"].items()} - users_watched[user_name][library_title][show_guids][season["Name"]].append(episode["ProviderIds"]) + if len(watched["Items"]) == 0: + logger(f"Jellyfin: No watched items found in library {library_title}", 1) + continue + else: + library_type = watched["Items"][0]["Type"] - return users_watched + skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) - def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): - for user, libraries in watched_list.items(): - if user_mapping: - user_other = None + if skip_reason: + logger(f"Jellyfin: Skipping library {library_title} {skip_reason}", 1) + continue - if user in user_mapping.keys(): - user_other = user_mapping[user] - elif user in user_mapping.values(): - user_other = search_mapping(user_mapping, user) + args.append([self.get_user_watched, user_name, user_id, library_type, library_id, library_title]) - if user_other: - logger(f"Swapping user {user} with {user_other}", 1) - user = user_other + for user_watched in future_thread_executor(args): + for user, user_watched_temp in user_watched.items(): + if user not in users_watched: + users_watched[user] = {} + users_watched[user].update(user_watched_temp) - user_id = None - for key in self.users.keys(): - if user.lower() == key.lower(): - user_id = self.users[key] - break + return users_watched + except Exception as e: + logger(f"Jellyfin: Failed to get watched, Error: {e}", 2) + raise Exception(e) - if not user_id: - logger(f"{user} not found in Jellyfin", 2) - break + def update_user_watched(self, user, user_id, library, library_id, videos, dryrun): + try: + logger(f"Jellyfin: Updating watched for {user} in library {library}", 1) + library_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&limit=1", "get") + library_type = library_search["Items"][0]["Type"] + + # Movies + if library_type == "Movie": + _, _, 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,MediaSources", "get") + for jellyfin_video in jellyfin_search["Items"]: + movie_found = False + + if "MediaSources" in jellyfin_video: + for movie_location in jellyfin_video["MediaSources"]: + if movie_location["Path"].split("/")[-1] in videos_movies_ids["locations"]: + movie_found = True + break - jellyfin_libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"] + if not movie_found: + 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()]: + movie_found = True + break + + if movie_found: + jellyfin_video_id = jellyfin_video["Id"] + msg = f"{jellyfin_video['Name']} as watched for {user} in {library} for Jellyfin" + if not dryrun: + logger(f"Marking {msg}", 0) + self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", "post") + else: + logger(f"Dryrun {msg}", 0) + + + # TV Shows + if library_type == "Episode": + 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,Path", "get") + jellyfin_shows = [x for x in jellyfin_search["Items"]] + + for jellyfin_show in jellyfin_shows: + show_found = False + + if jellyfin_show["Name"] == "The 13 Ghosts of Scooby-Doo": + print(jellyfin_show) + + if "Path" in jellyfin_show: + if jellyfin_show["Path"].split("/")[-1] in videos_shows_ids["locations"]: + show_found = True + + if not show_found: + 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 + break - for library, videos in libraries.items(): - if library_mapping: - library_other = None + if show_found: + jellyfin_show_id = jellyfin_show["Id"] + jellyfin_episodes = self.query(f"/Shows/{jellyfin_show_id}/Episodes?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", "get") - if library in library_mapping.keys(): - library_other = library_mapping[library] - elif library in library_mapping.values(): - library_other = search_mapping(library_mapping, library) + for jellyfin_episode in jellyfin_episodes["Items"]: + episode_found = False - if library_other: - logger(f"Swapping library {library} with {library_other}", 1) - library = library_other + if "MediaSources" in jellyfin_episode: + for episode_location in jellyfin_episode["MediaSources"]: + if episode_location["Path"].split("/")[-1] in videos_episode_ids["locations"]: + episode_found = True + break - if library not in [x["Name"] for x in jellyfin_libraries]: - logger(f"{library} not found in Jellyfin", 2) - continue + if not episode_found: + 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()]: + episode_found = True + break - library_id = None - for jellyfin_library in jellyfin_libraries: - if jellyfin_library["Name"] == library: - library_id = jellyfin_library["Id"] - continue + if episode_found: + jellyfin_episode_id = jellyfin_episode["Id"] + 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) - if library_id: - logger(f"Jellyfin: Updating watched for {user} in library {library}", 1) - library_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&limit=1", "get") - library_type = library_search["Items"][0]["Type"] - - # Movies - if library_type == "Movie": - _, _, 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 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) - self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", "post") - else: - logger(f"Dryrun {msg}", 0) - break + except Exception as e: + logger(f"Jellyfin: Error updating watched for {user} in library {library}", 2) + raise Exception(e) - # TV Shows - if library_type == "Episode": - 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: - 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 + + def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): + try: + args = [] + for user, libraries in watched_list.items(): + user_other = None + if user_mapping: + if user in user_mapping.keys(): + user_other = user_mapping[user] + elif user in user_mapping.values(): + user_other = search_mapping(user_mapping, user) + + user_id = None + for key in self.users.keys(): + if user.lower() == key.lower(): + user_id = self.users[key] + break + elif user_other and user_other.lower() == key.lower(): + user_id = self.users[key] + break + + if not user_id: + logger(f"{user} {user_other} not found in Jellyfin", 2) + continue + + jellyfin_libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"] + + for library, videos in libraries.items(): + library_other = None + if library_mapping: + if library in library_mapping.keys(): + library_other = library_mapping[library] + elif library in library_mapping.values(): + library_other = search_mapping(library_mapping, library) + + + if library.lower() not in [x["Name"].lower() for x in jellyfin_libraries]: + if library_other: + if library_other.lower() in [x["Name"].lower() for x in jellyfin_libraries]: + logger(f"Jellyfin: Library {library} not found, but {library_other} found, using {library_other}", 1) + library = library_other + else: + logger(f"Jellyfin: Library {library} or {library_other} not found in library list", 2) + continue + else: + logger(f"Jellyfin: Library {library} not found in library list", 2) + continue + + library_id = None + for jellyfin_library in jellyfin_libraries: + if jellyfin_library["Name"] == library: + library_id = jellyfin_library["Id"] + continue + + if library_id: + args.append([self.update_user_watched, user, user_id, library, library_id, videos, dryrun]) + + future_thread_executor(args) + except Exception as e: + logger(f"Jellyfin: Error updating watched", 2) + raise Exception(e) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..a4cffdb --- /dev/null +++ b/src/main.py @@ -0,0 +1,441 @@ +import copy, os, traceback, json +from dotenv import load_dotenv +from time import sleep + +from src.functions import logger, str_to_bool, search_mapping, generate_library_guids_dict, future_thread_executor +from src.plex import Plex +from src.jellyfin import Jellyfin + +load_dotenv(override=True) + +def cleanup_watched(watched_list_1, watched_list_2, user_mapping=None, library_mapping=None): + modified_watched_list_1 = copy.deepcopy(watched_list_1) + + # remove entries from plex_watched that are in jellyfin_watched + for user_1 in watched_list_1: + user_other = None + if user_mapping: + user_other = search_mapping(user_mapping, user_1) + if user_1 in modified_watched_list_1: + if user_1 in watched_list_2: + user_2 = user_1 + elif user_other in watched_list_2: + user_2 = user_other + else: + logger(f"User {user_1} and {user_other} not found in watched list 2", 1) + continue + + for library_1 in watched_list_1[user_1]: + library_other = None + if library_mapping: + library_other = search_mapping(library_mapping, library_1) + if library_1 in modified_watched_list_1[user_1]: + if library_1 in watched_list_2[user_2]: + library_2 = library_1 + elif library_other in watched_list_2[user_2]: + library_2 = library_other + else: + logger(f"library {library_1} and {library_other} not found in watched list 2", 1) + continue + + # Movies + if isinstance(watched_list_1[user_1][library_1], list): + _, _, movies_watched_list_2_keys_dict = generate_library_guids_dict(watched_list_2[user_2][library_2], 2) + for movie in watched_list_1[user_1][library_1]: + movie_found = False + for movie_key, movie_value in movie.items(): + if movie_key == "locations": + for location in movie_value: + if location in movies_watched_list_2_keys_dict["locations"]: + movie_found = True + break + else: + if movie_key in movies_watched_list_2_keys_dict.keys(): + if movie_value in movies_watched_list_2_keys_dict[movie_key]: + movie_found = True + + if movie_found: + logger(f"Removing {movie} from {library_1}", 3) + modified_watched_list_1[user_1][library_1].remove(movie) + break + + + # 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 + show_watched_list_2_keys_dict, episode_watched_list_2_keys_dict, _ = generate_library_guids_dict(watched_list_2[user_2][library_2], 3) + + 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]: + episode_found = False + for episode_key, episode_value in episode.items(): + # If episode_key and episode_value are in episode_watched_list_2_keys_dict exactly, then remove from watch_list_1 + if episode_key == "locations": + for location in episode_value: + if location in episode_watched_list_2_keys_dict["locations"]: + episode_found = True + break + + else: + if episode_key in episode_watched_list_2_keys_dict.keys(): + if episode_value in episode_watched_list_2_keys_dict[episode_key]: + episode_found = True + + if episode_found: + 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) + break + + # 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] + + for user_1 in watched_list_1: + for library_1 in watched_list_1[user_1]: + if library_1 in modified_watched_list_1[user_1]: + # If library is empty then remove it + if len(modified_watched_list_1[user_1][library_1]) == 0: + logger(f"Removing {library_1} from {user_1} because it is empty", 1) + del modified_watched_list_1[user_1][library_1] + + if user_1 in modified_watched_list_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 + +def setup_black_white_lists(blacklist_library: str, whitelist_library: str, blacklist_library_type: str, whitelist_library_type: str, blacklist_users: str, whitelist_users: str, library_mapping=None, user_mapping=None): + if blacklist_library: + if len(blacklist_library) > 0: + blacklist_library = blacklist_library.split(",") + blacklist_library = [x.strip() for x in blacklist_library] + if library_mapping: + temp_library = [] + for library in blacklist_library: + library_other = search_mapping(library_mapping, library) + if library_other: + temp_library.append(library_other) + + blacklist_library = blacklist_library + temp_library + else: + blacklist_library = [] + logger(f"Blacklist Library: {blacklist_library}", 1) + + if whitelist_library: + if len(whitelist_library) > 0: + whitelist_library = whitelist_library.split(",") + whitelist_library = [x.strip() for x in whitelist_library] + if library_mapping: + temp_library = [] + for library in whitelist_library: + library_other = search_mapping(library_mapping, library) + if library_other: + temp_library.append(library_other) + + whitelist_library = whitelist_library + temp_library + else: + whitelist_library = [] + logger(f"Whitelist Library: {whitelist_library}", 1) + + if blacklist_library_type: + if len(blacklist_library_type) > 0: + blacklist_library_type = blacklist_library_type.split(",") + blacklist_library_type = [x.lower().strip() for x in blacklist_library_type] + else: + blacklist_library_type = [] + logger(f"Blacklist Library Type: {blacklist_library_type}", 1) + + if whitelist_library_type: + if len(whitelist_library_type) > 0: + whitelist_library_type = whitelist_library_type.split(",") + whitelist_library_type = [x.lower().strip() for x in whitelist_library_type] + else: + whitelist_library_type = [] + logger(f"Whitelist Library Type: {whitelist_library_type}", 1) + + if blacklist_users: + if len(blacklist_users) > 0: + blacklist_users = blacklist_users.split(",") + blacklist_users = [x.lower().strip() for x in blacklist_users] + if user_mapping: + temp_users = [] + for user in blacklist_users: + user_other = search_mapping(user_mapping, user) + if user_other: + temp_users.append(user_other) + + blacklist_users = blacklist_users + temp_users + else: + blacklist_users = [] + logger(f"Blacklist Users: {blacklist_users}", 1) + + if whitelist_users: + if len(whitelist_users) > 0: + whitelist_users = whitelist_users.split(",") + whitelist_users = [x.lower().strip() for x in whitelist_users] + if user_mapping: + temp_users = [] + for user in whitelist_users: + user_other = search_mapping(user_mapping, user) + if user_other: + temp_users.append(user_other) + + whitelist_users = whitelist_users + temp_users + else: + whitelist_users = [] + else: + whitelist_users = [] + logger(f"Whitelist Users: {whitelist_users}", 1) + + return blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users + +def setup_users(server_1, server_2, blacklist_users, whitelist_users, user_mapping=None): + + # generate list of users from server 1 and server 2 + server_1_type = server_1[0] + server_1_connection = server_1[1] + server_2_type = server_2[0] + server_2_connection = server_2[1] + + server_1_users = [] + if server_1_type == "plex": + server_1_users = [ x.title.lower() for x in server_1_connection.users ] + elif server_1_type == "jellyfin": + server_1_users = [ key.lower() for key in server_1_connection.users.keys() ] + + server_2_users = [] + if server_2_type == "plex": + server_2_users = [ x.title.lower() for x in server_2_connection.users ] + elif server_2_type == "jellyfin": + server_2_users = [ key.lower() for key in server_2_connection.users.keys() ] + + + # combined list of overlapping users from plex and jellyfin + users = {} + + for server_1_user in server_1_users: + if user_mapping: + jellyfin_plex_mapped_user = search_mapping(user_mapping, server_1_user) + if jellyfin_plex_mapped_user: + users[server_1_user] = jellyfin_plex_mapped_user + continue + + if server_1_user in server_2_users: + users[server_1_user] = server_1_user + + for server_2_user in server_2_users: + if user_mapping: + plex_jellyfin_mapped_user = search_mapping(user_mapping, server_2_user) + if plex_jellyfin_mapped_user: + users[plex_jellyfin_mapped_user] = server_2_user + continue + + if server_2_user in server_1_users: + users[server_2_user] = server_2_user + + logger(f"User list that exist on both servers {users}", 1) + + users_filtered = {} + for user in users: + # whitelist_user is not empty and user lowercase is not in whitelist lowercase + if len(whitelist_users) > 0: + if user not in whitelist_users and users[user] not in whitelist_users: + logger(f"{user} or {users[user]} is not in whitelist", 1) + continue + + if user not in blacklist_users and users[user] not in blacklist_users: + users_filtered[user] = users[user] + + logger(f"Filtered user list {users_filtered}", 1) + + if server_1_type == "plex": + output_server_1_users = [] + for plex_user in server_1_connection.users: + if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values(): + output_server_1_users.append(plex_user) + elif server_1_type == "jellyfin": + output_server_1_users = {} + for jellyfin_user, jellyfin_id in server_1_connection.users.items(): + if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values(): + output_server_1_users[jellyfin_user] = jellyfin_id + + if server_2_type == "plex": + output_server_2_users = [] + for plex_user in server_2_connection.users: + if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values(): + output_server_2_users.append(plex_user) + elif server_2_type == "jellyfin": + output_server_2_users = {} + for jellyfin_user, jellyfin_id in server_2_connection.users.items(): + if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values(): + output_server_2_users[jellyfin_user] = jellyfin_id + + if len(output_server_1_users) == 0: + raise Exception(f"No users found for server 1, users found {users} filtered users {users_filtered}") + + if len(output_server_2_users) == 0: + raise Exception(f"No users found for server 2, users found {users} filtered users {users_filtered}") + + logger(f"Server 1 users: {output_server_1_users}", 1) + logger(f"Server 2 users: {output_server_2_users}", 1) + + return output_server_1_users, output_server_2_users + +def generate_server_connections(): + servers = [] + + plex_baseurl = os.getenv("PLEX_BASEURL", None) + plex_token = os.getenv("PLEX_TOKEN", None) + plex_username = os.getenv("PLEX_USERNAME", None) + plex_password = os.getenv("PLEX_PASSWORD", None) + plex_servername = os.getenv("PLEX_SERVERNAME", None) + + if plex_baseurl and plex_token: + plex_baseurl = plex_baseurl.split(",") + plex_token = plex_token.split(",") + + if len(plex_baseurl) != len(plex_token): + raise Exception("PLEX_BASEURL and PLEX_TOKEN must have the same number of entries") + + for i, url in enumerate(plex_baseurl): + servers.append(("plex", Plex(baseurl=url.strip(), token=plex_token[i].strip(), username=None, password=None, servername=None))) + + if plex_username and plex_password and plex_servername: + plex_username = plex_username.split(",") + plex_password = plex_password.split(",") + plex_servername = plex_servername.split(",") + + if len(plex_username) != len(plex_password) or len(plex_username) != len(plex_servername): + raise Exception("PLEX_USERNAME, PLEX_PASSWORD and PLEX_SERVERNAME must have the same number of entries") + + for i, username in enumerate(plex_username): + servers.append(("plex", Plex(baseurl=None, token=None, username=username.strip(), password=plex_password[i].strip(), servername=plex_servername[i].strip()))) + + jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None) + jellyfin_token = os.getenv("JELLYFIN_TOKEN", None) + + if jellyfin_baseurl and jellyfin_token: + jellyfin_baseurl = jellyfin_baseurl.split(",") + jellyfin_token = jellyfin_token.split(",") + + if len(jellyfin_baseurl) != len(jellyfin_token): + raise Exception("JELLYFIN_BASEURL and JELLYFIN_TOKEN must have the same number of entries") + + for i, baseurl in enumerate(jellyfin_baseurl): + servers.append(("jellyfin", Jellyfin(baseurl=baseurl.strip(), token=jellyfin_token[i].strip()))) + + return servers + +def main_loop(): + logfile = os.getenv("LOGFILE","log.log") + # Delete logfile if it exists + if os.path.exists(logfile): + os.remove(logfile) + + dryrun = str_to_bool(os.getenv("DRYRUN", "False")) + logger(f"Dryrun: {dryrun}", 1) + + user_mapping = os.getenv("USER_MAPPING") + if user_mapping: + user_mapping = json.loads(user_mapping.lower()) + logger(f"User Mapping: {user_mapping}", 1) + + library_mapping = os.getenv("LIBRARY_MAPPING") + if library_mapping: + library_mapping = json.loads(library_mapping) + logger(f"Library Mapping: {library_mapping}", 1) + + # Create (black/white)lists + logger("Creating (black/white)lists", 1) + blacklist_library = os.getenv("BLACKLIST_LIBRARY", None) + whitelist_library = os.getenv("WHITELIST_LIBRARY", None) + blacklist_library_type = os.getenv("BLACKLIST_LIBRARY_TYPE", None) + whitelist_library_type = os.getenv("WHITELIST_LIBRARY_TYPE", None) + blacklist_users = os.getenv("BLACKLIST_USERS", None) + whitelist_users = os.getenv("WHITELIST_USERS", None) + + blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users, library_mapping, user_mapping) + + # Create server connections + logger("Creating server connections", 1) + servers = generate_server_connections() + + for server_1 in servers: + # If server is the final server in the list, then we are done with the loop + if server_1 == servers[-1]: + break + + # Start server_2 at the next server in the list + for server_2 in servers[servers.index(server_1) + 1:]: + + server_1_connection = server_1[1] + server_2_connection = server_2[1] + + # Create users list + logger("Creating users list", 1) + server_1_users, server_2_users = setup_users(server_1, server_2, blacklist_users, whitelist_users, user_mapping) + + logger("Creating watched lists", 1) + args = [[server_1_connection.get_watched, server_1_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping] + , [server_2_connection.get_watched, server_2_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping]] + + results = future_thread_executor(args) + server_1_watched = results[0] + server_2_watched = results[1] + logger(f"Server 1 watched: {server_1_watched}", 3) + logger(f"Server 2 watched: {server_2_watched}", 3) + + # clone watched so it isnt modified in the cleanup function so all duplicates are actually removed + server_1_watched_filtered = copy.deepcopy(server_1_watched) + server_2_watched_filtered = copy.deepcopy(server_2_watched) + + logger("Cleaning Server 1 Watched", 1) + server_1_watched_filtered = cleanup_watched(server_1_watched, server_2_watched, user_mapping, library_mapping) + + logger("Cleaning Server 2 Watched", 1) + server_2_watched_filtered = cleanup_watched(server_2_watched, server_1_watched, user_mapping, library_mapping) + + logger(f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}", 1) + logger(f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}", 1) + + args= [[server_1_connection.update_watched, server_2_watched_filtered, user_mapping, library_mapping, dryrun] + , [server_2_connection.update_watched, server_1_watched_filtered, user_mapping, library_mapping, dryrun]] + + future_thread_executor(args) + +def main(): + sleep_duration = float(os.getenv("SLEEP_DURATION", "3600")) + + while(True): + try: + main_loop() + logger(f"Looping in {sleep_duration}") + sleep(sleep_duration) + except Exception as error: + if isinstance(error, list): + for message in error: + logger(message, log_type=2) + else: + logger(error, log_type=2) + + + logger(traceback.format_exc(), 2) + logger(f"Retrying in {sleep_duration}", log_type=0) + sleep(sleep_duration) + + except KeyboardInterrupt: + logger("Exiting", log_type=0) + os._exit(0) diff --git a/src/plex.py b/src/plex.py index 5207214..825c3a0 100644 --- a/src/plex.py +++ b/src/plex.py @@ -1,31 +1,24 @@ -import re, os -from dotenv import load_dotenv +import re -from src.functions import logger, search_mapping, check_skip_logic, generate_library_guids_dict from plexapi.server import PlexServer from plexapi.myplex import MyPlexAccount -load_dotenv(override=True) +from src.functions import logger, search_mapping, check_skip_logic, generate_library_guids_dict, future_thread_executor -plex_baseurl = os.getenv("PLEX_BASEURL") -plex_token = os.getenv("PLEX_TOKEN") -username = os.getenv("PLEX_USERNAME") -password = os.getenv("PLEX_PASSWORD") -servername = os.getenv("PLEX_SERVERNAME") # class plex accept base url and token and username and password but default with none class Plex: - def __init__(self): - self.baseurl = plex_baseurl - self.token = plex_token + def __init__(self, baseurl=None, token=None, username=None, password=None, servername=None): + self.baseurl = baseurl + self.token = token self.username = username self.password = password self.servername = servername - self.plex = self.plex_login() + self.plex = self.login() self.admin_user = self.plex.myPlexAccount() - self.users = self.get_plex_users() + self.users = self.get_users() - def plex_login(self): + def login(self): try: if self.baseurl and self.token: # Login via token @@ -44,171 +37,174 @@ def plex_login(self): logger(f"Plex: Failed to login, {msg}, Error: {e}", 2) else: logger(f"Plex: Failed to login, Error: {e}", 2) - return None - - - def get_plex_users(self): - users = self.plex.myPlexAccount().users() - - # append self to users - users.append(self.plex.myPlexAccount()) - - return users - - def get_plex_user_watched(self, user, library): - if self.admin_user == user: - user_plex = self.plex - else: - user_plex = PlexServer(self.baseurl, user.get_token(self.plex.machineIdentifier)) - - watched = None - - if library.type == "movie": - watched = [] - library_videos = user_plex.library.section(library.title) - for video in library_videos.search(unmatched=False, unwatched=False): - guids = {} - for guid in video.guids: - guid_source = re.search(r'(.*)://', guid.id).group(1).lower() - guid_id = re.search(r'://(.*)', guid.id).group(1) - guids[guid_source] = guid_id - watched.append(guids) - - elif library.type == "show": - 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: + raise Exception(e) + + + def get_users(self): + try: + users = self.plex.myPlexAccount().users() + + # append self to users + users.append(self.plex.myPlexAccount()) + + return users + except Exception as e: + logger(f"Plex: Failed to get users, Error: {e}", 2) + raise Exception(e) + + def get_user_watched(self, user, user_plex, library): + try: + user_name = user.title.lower() + user_watched = {} + user_watched[user_name] = {} + + logger(f"Plex: Generating watched for {user_name} in library {library.title}", 0) + + if library.type == "movie": + user_watched[user_name][library.title] = [] + + library_videos = user_plex.library.section(library.title) + for video in library_videos.search(unwatched=False): + movie_guids = {} + for guid in video.guids: + guid_source = re.search(r'(.*)://', guid.id).group(1).lower() + guid_id = re.search(r'://(.*)', guid.id).group(1) + movie_guids[guid_source] = guid_id + + movie_guids["title"] = video.title + movie_guids["locations"] = tuple([x.split("/")[-1] for x in video.locations]) + + user_watched[user_name][library.title].append(movie_guids) + + elif library.type == "show": + user_watched[user_name][library.title] = {} + + library_videos = user_plex.library.section(library.title) + for show in library_videos.search(unwatched=False): + show_guids = {} + for show_guid in show.guids: + # 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["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(): - episode_guids = [] - for episode in season.episodes(): - if episode.viewCount > 0: - 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) - episode_guids_temp[guid_source] = guid_id - - episode_guids.append(episode_guids_temp) - - if episode_guids: - # append show, season, episode - 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 - - def get_plex_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping): - # Get all libraries - libraries = self.plex.library.sections() - users_watched = {} - - # for not in blacklist - for library in libraries: - library_title = library.title - library_type = library.type - - skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) - - if skip_reason: - logger(f"Plex: Skipping library {library_title} {skip_reason}", 1) - continue + show_guids["locations"] = tuple([x.split("/")[-1] for x in show.locations]) + show_guids = frozenset(show_guids.items()) + + for season in show.seasons(): + episode_guids = [] + for episode in season.episodes(): + if episode.viewCount > 0: + 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) + episode_guids_temp[guid_source] = guid_id + + episode_guids_temp["locations"] = tuple([x.split("/")[-1] for x in episode.locations]) + episode_guids.append(episode_guids_temp) + + if episode_guids: + # append show, season, episode + if show_guids not in user_watched[user_name][library.title]: + user_watched[user_name][library.title][show_guids] = {} + if season.title not in user_watched[user_name][library.title][show_guids]: + user_watched[user_name][library.title][show_guids][season.title] = {} + user_watched[user_name][library.title][show_guids][season.title] = episode_guids + + + return user_watched + except Exception as e: + logger(f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}", 2) + raise Exception(e) - for user in users: - logger(f"Plex: Generating watched for {user.title} in library {library_title}", 0) - user_name = user.title.lower() - watched = self.get_plex_user_watched(user, library) - if watched: - if user_name not in users_watched: - users_watched[user_name] = {} - if library_title not in users_watched[user_name]: - users_watched[user_name][library_title] = [] - users_watched[user_name][library_title] = watched - - return users_watched - def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): - for user, libraries in watched_list.items(): - if user_mapping: - user_other = None + def get_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping): + try: + # Get all libraries + users_watched = {} + args = [] - if user in user_mapping.keys(): - user_other = user_mapping[user] - elif user in user_mapping.values(): - user_other = search_mapping(user_mapping, user) + for user in users: + if self.admin_user == user: + user_plex = self.plex + else: + user_plex = PlexServer(self.plex._baseurl, user.get_token(self.plex.machineIdentifier)) - if user_other: - logger(f"Swapping user {user} with {user_other}", 1) - user = user_other + libraries = user_plex.library.sections() - for index, value in enumerate(self.users): - if user.lower() == value.title.lower(): - user = self.users[index] - break + for library in libraries: + library_title = library.title + library_type = library.type - if self.admin_user == user: - user_plex = self.plex - else: - user_plex = PlexServer(self.baseurl, user.get_token(self.plex.machineIdentifier)) + skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) - for library, videos in libraries.items(): - if library_mapping: - library_other = None + if skip_reason: + logger(f"Plex: Skipping library {library_title} {skip_reason}", 1) + continue - if library in library_mapping.keys(): - library_other = library_mapping[library] - elif library in library_mapping.values(): - library_other = search_mapping(library_mapping, library) + args.append([self.get_user_watched, user, user_plex, library]) - if library_other: - logger(f"Swapping library {library} with {library_other}", 1) - library = library_other + for user_watched in future_thread_executor(args): + for user, user_watched_temp in user_watched.items(): + if user not in users_watched: + users_watched[user] = {} + users_watched[user].update(user_watched_temp) - # if library in plex library list - library_list = user_plex.library.sections() - if library.lower() not in [x.title.lower() for x in library_list]: - logger(f"Library {library} not found in Plex library list", 2) - continue + return users_watched + except Exception as e: + logger(f"Plex: Failed to get watched, Error: {e}", 2) + raise Exception(e) - logger(f"Plex: Updating watched for {user.title} in library {library}", 1) - 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): + def update_user_watched (self, user, user_plex, library, videos, dryrun): + try: + logger(f"Plex: Updating watched for {user.title} in library {library}", 1) + 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(unwatched=True): + movie_found = False + for movie_location in movies_search.locations: + if movie_location.split("/")[-1] in videos_movies_ids["locations"]: + movie_found = True + break + + if not movie_found: 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) + movie_found = True break + if movie_found: + 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) + - elif library_videos.type == "show": - videos_shows_ids, videos_episode_ids, _ = generate_library_guids_dict(videos, 3) + 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): - show_found = False + for show_search in library_videos.search(unwatched=True): + show_found = False + for show_location in show_search.locations: + if show_location.split("/")[-1] in videos_shows_ids["locations"]: + show_found = True + break + + if not show_found: 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) @@ -217,22 +213,93 @@ def update_watched(self, watched_list, user_mapping=None, library_mapping=None, 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 + break + + if show_found: + for episode_search in show_search.episodes(): + episode_found = False + + for episode_location in episode_search.locations: + if episode_location.split("/")[-1] in videos_episode_ids["locations"]: + episode_found = True + break + + if not episode_found: + 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]: + episode_found = True + break + + if episode_found: + 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) + except Exception as e: + logger(f"Plex: Failed to update watched for {user.title} in library {library}, Error: {e}", 2) + raise Exception(e) + + + def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): + try: + args = [] + + for user, libraries in watched_list.items(): + user_other = None + # If type of user is dict + if user_mapping: + if user in user_mapping.keys(): + user_other = user_mapping[user] + elif user in user_mapping.values(): + user_other = search_mapping(user_mapping, user) + + for index, value in enumerate(self.users): + if user.lower() == value.title.lower(): + user = self.users[index] + break + elif user_other and user_other.lower() == value.title.lower(): + user = self.users[index] + break + + if self.admin_user == user: + user_plex = self.plex + else: + user_plex = PlexServer(self.plex._baseurl, user.get_token(self.plex.machineIdentifier)) + + for library, videos in libraries.items(): + library_other = None + if library_mapping: + if library in library_mapping.keys(): + library_other = library_mapping[library] + elif library in library_mapping.values(): + library_other = search_mapping(library_mapping, library) + + # if library in plex library list + library_list = user_plex.library.sections() + if library.lower() not in [x.title.lower() for x in library_list]: + if library_other: + if library_other.lower() in [x.title.lower() for x in library_list]: + logger(f"Plex: Library {library} not found, but {library_other} found, using {library_other}", 1) + library = library_other + else: + logger(f"Plex: Library {library} or {library_other} not found in library list", 2) + continue + else: + logger(f"Plex: Library {library} not found in library list", 2) + continue + + + args.append([self.update_user_watched, user, user_plex, library, videos, dryrun]) + + future_thread_executor(args) + except Exception as e: + logger(f"Plex: Failed to update watched, Error: {e}", 2) + raise Exception(e) diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/test/test_main_.py b/test/test_main_.py new file mode 100644 index 0000000..2b15392 --- /dev/null +++ b/test/test_main_.py @@ -0,0 +1,47 @@ +import sys +import os + +# getting the name of the directory +# where the this file is present. +current = os.path.dirname(os.path.realpath(__file__)) + +# Getting the parent directory name +# where the current directory is present. +parent = os.path.dirname(current) + +# adding the parent directory to +# the sys.path. +sys.path.append(parent) + +from src.main import setup_black_white_lists + +def test_setup_black_white_lists(): + # Simple + blacklist_library = 'library1, library2' + whitelist_library = 'library1, library2' + blacklist_library_type = 'library_type1, library_type2' + whitelist_library_type = 'library_type1, library_type2' + blacklist_users = 'user1, user2' + whitelist_users = 'user1, user2' + + results_blacklist_library, return_whitelist_library, return_blacklist_library_type, return_whitelist_library_type, return_blacklist_users, return_whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users) + + assert results_blacklist_library == ['library1', 'library2'] + assert return_whitelist_library == ['library1', 'library2'] + assert return_blacklist_library_type == ['library_type1', 'library_type2'] + assert return_whitelist_library_type == ['library_type1', 'library_type2'] + assert return_blacklist_users == ['user1', 'user2'] + assert return_whitelist_users == ['user1', 'user2'] + + # Library Mapping and user mapping + library_mapping = { "library1": "library3" } + user_mapping = { "user1": "user3" } + + results_blacklist_library, return_whitelist_library, return_blacklist_library_type, return_whitelist_library_type, return_blacklist_users, return_whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users, library_mapping, user_mapping) + + assert results_blacklist_library == ['library1', 'library2', 'library3'] + assert return_whitelist_library == ['library1', 'library2', 'library3'] + assert return_blacklist_library_type == ['library_type1', 'library_type2'] + assert return_whitelist_library_type == ['library_type1', 'library_type2'] + assert return_blacklist_users == ['user1', 'user2', 'user3'] + assert return_whitelist_users == ['user1', 'user2', 'user3'] diff --git a/test/test_main_cleanup_watched.py b/test/test_main_cleanup_watched.py new file mode 100644 index 0000000..4d2bbcd --- /dev/null +++ b/test/test_main_cleanup_watched.py @@ -0,0 +1,176 @@ +import sys +import os + +# getting the name of the directory +# where the this file is present. +current = os.path.dirname(os.path.realpath(__file__)) + +# Getting the parent directory name +# where the current directory is present. +parent = os.path.dirname(current) + +# adding the parent directory to +# the sys.path. +sys.path.append(parent) + +from src.main import cleanup_watched + +tv_shows_watched_list_1 = { + frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): { + "Season 1": [ + {'imdb': 'tt0550489', 'tmdb': '282843', 'tvdb': '176357', 'locations': ('Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv',)}, + {'imdb': 'tt0550487', 'tmdb': '282861', 'tvdb': '300385', 'locations': ('Criminal Minds S01E02 Compulsion WEBDL-720p.mkv',)} + ] + }, + frozenset({("title", "Test"), ("locations", ("Test",))}): { + "Season 1": [ + {'locations': ('Test S01E01.mkv',)}, + {'locations': ('Test S01E02.mkv',)} + ] + } +} + +movies_watched_list_1 = [ + {"imdb":"tt2380307", "tmdb":"354912", 'title': 'Coco', 'locations': ('Coco (2017) Remux-1080p.mkv',)}, + {"tmdbcollection":"448150", "imdb":"tt1431045", "tmdb":"293660", 'title': 'Deadpool', 'locations': ('Deadpool (2016) Remux-1080p.mkv',)}, +] + +tv_shows_watched_list_2 = { + frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): { + "Season 1": [ + {'imdb': 'tt0550487', 'tmdb': '282861', 'tvdb': '300385', 'locations': ('Criminal Minds S01E02 Compulsion WEBDL-720p.mkv',)}, + {'imdb': 'tt0550498', 'tmdb': '282865', 'tvdb': '300474', 'locations': ("Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",)} + ] + }, + frozenset({("title", "Test"), ("locations", ("Test",))}): { + "Season 1": [ + {'locations': ('Test S01E02.mkv',)}, + {'locations': ('Test S01E03.mkv',)} + ] + } +} + +movies_watched_list_2 = [ + {"imdb":"tt2380307", "tmdb":"354912", 'title': 'Coco', 'locations': ('Coco (2017) Remux-1080p.mkv',)}, + {'imdb': 'tt0384793', 'tmdb': '9788', 'tvdb': '9103', 'title': 'Accepted', 'locations': ('Accepted (2006) Remux-1080p.mkv',)} +] + +# Test to see if objects get deleted all the way up to the root. +tv_shows_2_watched_list_1 = { + frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): { + "Season 1": [ + {'imdb': 'tt0550489', 'tmdb': '282843', 'tvdb': '176357', 'locations': ('Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv',)}, + ] + } +} + +expected_tv_show_watched_list_1 = { + frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): { + "Season 1": [ + {'imdb': 'tt0550489', 'tmdb': '282843', 'tvdb': '176357', 'locations': ('Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv',)} + ] + }, + frozenset({("title", "Test"), ("locations", ("Test",))}): { + "Season 1": [ + {'locations': ('Test S01E01.mkv',)} + ] + } +} + +expected_movie_watched_list_1 = [ + {"tmdbcollection":"448150", "imdb":"tt1431045", "tmdb":"293660", 'title': 'Deadpool', 'locations': ('Deadpool (2016) Remux-1080p.mkv',)} +] + +expected_tv_show_watched_list_2 = { + frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): { + "Season 1": [ + {'imdb': 'tt0550498', 'tmdb': '282865', 'tvdb': '300474', 'locations': ("Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",)} + ] + }, + frozenset({("title", "Test"), ("locations", ("Test",))}): { + "Season 1": [ + {'locations': ('Test S01E03.mkv',)} + ] + } +} + +expected_movie_watched_list_2 = [ + {'imdb': 'tt0384793', 'tmdb': '9788', 'tvdb': '9103', 'title': 'Accepted', 'locations': ('Accepted (2006) Remux-1080p.mkv',)} +] + + +def test_simple_cleanup_watched(): + user_watched_list_1 = { + "user1": { + "TV Shows": tv_shows_watched_list_1, + "Movies": movies_watched_list_1, + "Other Shows": tv_shows_2_watched_list_1 + }, + } + user_watched_list_2 = { + "user1": { + "TV Shows": tv_shows_watched_list_2, + "Movies": movies_watched_list_2, + "Other Shows": tv_shows_2_watched_list_1 + } + } + + expected_watched_list_1 = { + "user1": { + "TV Shows": expected_tv_show_watched_list_1 + , "Movies": expected_movie_watched_list_1 + } + } + + expected_watched_list_2 = { + "user1": { + "TV Shows": expected_tv_show_watched_list_2 + , "Movies": expected_movie_watched_list_2 + } + } + + return_watched_list_1 = cleanup_watched(user_watched_list_1, user_watched_list_2) + return_watched_list_2 = cleanup_watched(user_watched_list_2, user_watched_list_1) + + assert return_watched_list_1 == expected_watched_list_1 + assert return_watched_list_2 == expected_watched_list_2 + + +def test_mapping_cleanup_watched(): + user_watched_list_1 = { + "user1": { + "TV Shows": tv_shows_watched_list_1, + "Movies": movies_watched_list_1, + "Other Shows": tv_shows_2_watched_list_1 + }, + } + user_watched_list_2 = { + "user2": { + "Shows": tv_shows_watched_list_2, + "Movies": movies_watched_list_2, + "Other Shows": tv_shows_2_watched_list_1 + } + } + + expected_watched_list_1 = { + "user1": { + "TV Shows": expected_tv_show_watched_list_1 + , "Movies": expected_movie_watched_list_1 + } + } + + expected_watched_list_2 = { + "user2": { + "Shows": expected_tv_show_watched_list_2 + , "Movies": expected_movie_watched_list_2 + } + } + + user_mapping = { "user1": "user2" } + library_mapping = { "TV Shows": "Shows" } + + return_watched_list_1 = cleanup_watched(user_watched_list_1, user_watched_list_2, user_mapping=user_mapping, library_mapping=library_mapping) + return_watched_list_2 = cleanup_watched(user_watched_list_2, user_watched_list_1, user_mapping=user_mapping, library_mapping=library_mapping) + + assert return_watched_list_1 == expected_watched_list_1 + assert return_watched_list_2 == expected_watched_list_2