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/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..2f28d36 100644 --- a/main.py +++ b/main.py @@ -168,34 +168,49 @@ def setup_black_white_lists(library_mapping=None): 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): +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() ] - # 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: + for server_1_user in server_1_users: if user_mapping: - jellyfin_plex_mapped_user = search_mapping(user_mapping, plex_user) + jellyfin_plex_mapped_user = search_mapping(user_mapping, server_1_user) if jellyfin_plex_mapped_user: - users[plex_user] = jellyfin_plex_mapped_user + users[server_1_user] = jellyfin_plex_mapped_user continue - if plex_user in jellyfin_users: - users[plex_user] = plex_user + if server_1_user in server_2_users: + users[server_1_user] = server_1_user - for jellyfin_user in jellyfin_users: + for server_2_user in server_2_users: if user_mapping: - plex_jellyfin_mapped_user = search_mapping(user_mapping, jellyfin_user) + plex_jellyfin_mapped_user = search_mapping(user_mapping, server_2_user) if plex_jellyfin_mapped_user: - users[plex_jellyfin_mapped_user] = jellyfin_user + users[plex_jellyfin_mapped_user] = server_2_user continue - if jellyfin_user in plex_users: - users[jellyfin_user] = jellyfin_user + 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) @@ -212,26 +227,84 @@ def setup_users(plex, jellyfin, blacklist_users, whitelist_users, user_mapping=N 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) + 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 - 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 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(plex_users) == 0: - raise Exception(f"No plex users found, users found {users} filtered users {users_filtered}") + 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(jellyfin_users) == 0: - raise Exception(f"No jellyfin users found, 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"plex_users: {plex_users}", 1) - logger(f"jellyfin_users: {jellyfin_users}", 1) + logger(f"Server 1 users: {output_server_1_users}", 1) + logger(f"Server 2 users: {output_server_2_users}", 1) - return plex_users, jellyfin_users + 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 in range(len(plex_baseurl)): + servers.append(("plex", Plex(baseurl=plex_baseurl[i].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 in range(len(plex_username)): + servers.append(("plex", Plex(baseurl=None, token=None, username=plex_username[i].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 in range(len(jellyfin_baseurl)): + servers.append(("jellyfin", Jellyfin(baseurl=jellyfin_baseurl[i].strip(), token=jellyfin_token[i].strip()))) + + print(f"Servers: {servers}") + return servers def main(): logfile = os.getenv("LOGFILE","log.log") @@ -252,34 +325,50 @@ def main(): 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) + # Create server connections + servers = generate_server_connections() - 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) + 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 - # 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) + # Start server_2 at the next server in the list + servers_2_ = servers[servers.index(server_1) + 1:] + for server_2 in servers[servers.index(server_1) + 1:]: + print(f"server_1: {server_1}, server_2: {server_2}") - logger("Cleaning Plex Watched", 1) - plex_watched = cleanup_watched(plex_watched_filtered, jellyfin_watched_filtered, user_mapping, library_mapping) + server_1_type = server_1[0] + server_1_connection = server_1[1] - logger("Cleaning Jellyfin Watched", 1) - jellyfin_watched = cleanup_watched(jellyfin_watched_filtered, plex_watched_filtered, user_mapping, library_mapping) + server_2_type = server_2[0] + server_2_connection = server_2[1] - 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) + # Create users list + server_1_users, server_2_users = setup_users(server_1, server_2, blacklist_users, whitelist_users, user_mapping) - # Update watched status - plex.update_watched(jellyfin_watched, user_mapping, library_mapping, dryrun) - jellyfin.update_watched(plex_watched, user_mapping, library_mapping, dryrun) + server_1_watched = server_1_connection.get_watched(server_1_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) + server_2_watched = server_2_connection.get_watched(server_2_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 + 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) + + # Update watched status + 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) if __name__ == "__main__": diff --git a/src/functions.py b/src/functions.py index e246ead..0ef75f4 100644 --- a/src/functions.py +++ b/src/functions.py @@ -6,16 +6,16 @@ logfile = os.getenv("LOGFILE","log.log") def logger(message, 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 diff --git a/src/jellyfin.py b/src/jellyfin.py index 44332c6..1052714 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -1,16 +1,10 @@ -import requests, os -from dotenv import load_dotenv +import requests 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") - class Jellyfin(): - def __init__(self): - self.baseurl = jellyfin_baseurl - self.token = jellyfin_token + def __init__(self, baseurl, token): + self.baseurl = baseurl + self.token = token if not self.baseurl: raise Exception("Jellyfin baseurl not set") @@ -56,7 +50,7 @@ class Jellyfin(): return users - def get_jellyfin_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping=None): + def get_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping=None): users_watched = {} for user_name, user_id in users.items(): @@ -131,46 +125,45 @@ class Jellyfin(): def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): for user, libraries in watched_list.items(): + user_other = None if user_mapping: - user_other = None - if user in user_mapping.keys(): user_other = user_mapping[user] elif user in user_mapping.values(): user_other = search_mapping(user_mapping, user) - if user_other: - logger(f"Swapping user {user} with {user_other}", 1) - user = user_other - 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} not found in Jellyfin", 2) + logger(f"{user} {user_other} not found in Jellyfin", 2) break jellyfin_libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"] for library, videos in libraries.items(): + library_other = None if library_mapping: - library_other = None - 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_other: - logger(f"Swapping library {library} with {library_other}", 1) - library = library_other - if library not in [x["Name"] for x in jellyfin_libraries]: - logger(f"{library} not found in Jellyfin", 2) - continue + if library.lower() not in [x["Name"].lower() for x in jellyfin_libraries]: + if library_other and library_other.lower() in [x["Name"].lower() for x in jellyfin_libraries]: + logger(f"Plex: Library {library} not found, but {library_other} found, using {library_other}", 1) + library = library_other + else: + logger(f"Library {library} {library_other} not found in Plex library list", 2) + continue + library_id = None for jellyfin_library in jellyfin_libraries: diff --git a/src/plex.py b/src/plex.py index 5207214..0c43e49 100644 --- a/src/plex.py +++ b/src/plex.py @@ -1,31 +1,22 @@ -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) - -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 @@ -47,7 +38,7 @@ class Plex: return None - def get_plex_users(self): + def get_users(self): users = self.plex.myPlexAccount().users() # append self to users @@ -55,7 +46,7 @@ class Plex: return users - def get_plex_user_watched(self, user, library): + def get_user_watched(self, user, library): if self.admin_user == user: user_plex = self.plex else: @@ -110,7 +101,7 @@ class Plex: return watched - def get_plex_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping): + def get_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 = {} @@ -129,7 +120,7 @@ class Plex: 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) + watched = self.get_user_watched(user, library) if watched: if user_name not in users_watched: users_watched[user_name] = {} @@ -141,22 +132,21 @@ class Plex: def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): for user, libraries in watched_list.items(): + user_other = None + # If type of user is dict if user_mapping: - user_other = None - if user in user_mapping.keys(): user_other = user_mapping[user] elif user in user_mapping.values(): user_other = search_mapping(user_mapping, user) - if user_other: - logger(f"Swapping user {user} with {user_other}", 1) - user = user_other - 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 @@ -164,23 +154,22 @@ class Plex: user_plex = PlexServer(self.baseurl, user.get_token(self.plex.machineIdentifier)) for library, videos in libraries.items(): + library_other = None if library_mapping: - library_other = None - 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_other: - logger(f"Swapping library {library} with {library_other}", 1) - library = library_other - # 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 + if library_other and 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"Library {library} {library_other} not found in Plex library list", 2) + continue logger(f"Plex: Updating watched for {user.title} in library {library}", 1) library_videos = user_plex.library.section(library) @@ -191,6 +180,7 @@ class Plex: 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]: