diff --git a/.env.sample b/.env.sample index c9d3931..878c7d7 100644 --- a/.env.sample +++ b/.env.sample @@ -55,7 +55,6 @@ MAX_THREADS = 32 WHITELIST_USERS = "testuser1,testuser2" - # Plex ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers @@ -77,13 +76,6 @@ PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2" ## Set to True if running into ssl certificate errors SSL_BYPASS = "False" -## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex -## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers -SYNC_FROM_PLEX_TO_JELLYFIN = "True" -SYNC_FROM_JELLYFIN_TO_PLEX = "True" -SYNC_FROM_PLEX_TO_PLEX = "True" -SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" - # Jellyfin @@ -94,3 +86,31 @@ JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096" ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Comma separated list for multiple servers JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2" + + +# Emby + +## Emby server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +EMBY_BASEURL = "http://localhost:8097" + +## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key +## Comma seperated list for multiple servers +EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" + + +# Syncing Options + +## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex +## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers +SYNC_FROM_PLEX_TO_JELLYFIN = "True" +SYNC_FROM_PLEX_TO_PLEX = "True" +SYNC_FROM_PLEX_TO_EMBY = "True" + +SYNC_FROM_JELLYFIN_TO_PLEX = "True" +SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" +SYNC_FROM_JELLYFIN_TO_EMBY = "True" + +SYNC_FROM_EMBY_TO_PLEX = "True" +SYNC_FROM_EMBY_TO_JELLYFIN = "True" +SYNC_FROM_EMBY_TO_EMBY = "True" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 289bc98..a8882e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,43 +40,67 @@ jobs: - name: "Start containers" run: | - export PGID=$(id -g) - export PUID=$(id -u) + JellyPlex-Watched-CI/start_containers.sh - sudo chown -R $PUID:$PGID JellyPlex-Watched-CI - - docker pull lscr.io/linuxserver/plex & - docker pull lscr.io/linuxserver/jellyfin & - - wait - - docker-compose -f JellyPlex-Watched-CI/plex/docker-compose.yml up -d - docker-compose -f JellyPlex-Watched-CI/jellyfin/docker-compose.yml up -d # Wait for containers to start sleep 10 - docker-compose -f JellyPlex-Watched-CI/plex/docker-compose.yml logs - docker-compose -f JellyPlex-Watched-CI/jellyfin/docker-compose.yml logs + for FOLDER in $(find "JellyPlex-Watched-CI" -type f -name "docker-compose.yml" -exec dirname {} \;); do + docker-compose -f "${FOLDER}/docker-compose.yml" logs + done - - name: "Run tests" + - name: "Test Plex" + run: | + mv test/ci_plex.env .env + python main.py + python test/validate_ci_marklog.py --plex + + rm mark.log + + - name: "Test Jellyfin" + run: | + mv test/ci_jellyfin.env .env + python main.py + python test/validate_ci_marklog.py --jellyfin + + rm mark.log + + - name: "Test Emby" run: | - # Test ci1 - mv test/ci1.env .env + mv test/ci_emby.env .env python main.py + python test/validate_ci_marklog.py --emby + + rm mark.log - # Test ci2 - mv test/ci2.env .env + - name: "Test Guids" + run: | + mv test/ci_guids.env .env python main.py + python test/validate_ci_marklog.py --dry + + rm mark.log - # Test ci3 - mv test/ci3.env .env + - name: "Test Locations" + run: | + mv test/ci_locations.env .env + python main.py + python test/validate_ci_marklog.py --dry + + rm mark.log + + - name: "Test writing to the servers" + run: | + # Test writing to the servers + mv test/ci_write.env .env python main.py # Test again to test if it can handle existing data python main.py - cat mark.log - python test/validate_ci_marklog.py + python test/validate_ci_marklog.py --write + + rm mark.log docker: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 476f933..37880ed 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com\&utm_medium=referral\&utm_content=luigi311/JellyPlex-Watched\&utm_campaign=Badge_Grade) -Sync watched between jellyfin and plex locally +Sync watched between jellyfin, plex and emby locally ## Description -Keep in sync all your users watched history between jellyfin and plex servers locally. This uses file names and provider ids 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 entering multiple options in the .env plex/jellyfin section separated by commas. +Keep in sync all your users watched history between jellyfin, plex and emby servers locally. This uses file names and provider ids 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 entering multiple options in the .env plex/jellyfin section separated by commas. ## Features @@ -28,17 +28,17 @@ Keep in sync all your users watched history between jellyfin and plex servers lo * \[x] Use single login * \[x] One way/multi way sync * \[x] Sync watched -* \[ ] Sync in progress +* \[x] Sync in progress ### Emby -* \[ ] Match via filenames -* \[ ] Match via provider ids -* \[ ] Map usernames -* \[ ] Use single login -* \[ ] One way/multi way sync -* \[ ] Sync watched -* \[ ] Sync in progress +* \[x] Match via filenames +* \[x] Match via provider ids +* \[x] Map usernames +* \[x] Use single login +* \[x] One way/multi way sync +* \[x] Sync watched +* \[x] Sync in progress ## Configuration diff --git a/requirements.txt b/requirements.txt index f431e8c..1a8abf3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ PlexAPI==4.15.7 -requests==2.31.0 +requests==2.32.0 python-dotenv==1.0.0 diff --git a/src/emby.py b/src/emby.py new file mode 100644 index 0000000..5e0ba82 --- /dev/null +++ b/src/emby.py @@ -0,0 +1,21 @@ +from src.jellyfin_emby import JellyfinEmby + + +class Emby(JellyfinEmby): + def __init__(self, baseurl, token): + authorization = ( + "Emby , " + 'Client="JellyPlex-Watched", ' + 'Device="script", ' + 'DeviceId="script", ' + 'Version="0.0.0"' + ) + headers = { + "Accept": "application/json", + "X-Emby-Token": token, + "X-Emby-Authorization": authorization, + } + + super().__init__( + server_type="Emby", baseurl=baseurl, token=token, headers=headers + ) diff --git a/src/jellyfin.py b/src/jellyfin.py index cfa0243..282beea 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -1,859 +1,21 @@ -import traceback, os -from math import floor -from dotenv import load_dotenv -import requests - -from src.functions import ( - logger, - search_mapping, - contains_nested, - log_marked, - str_to_bool, -) -from src.library import ( - check_skip_logic, - generate_library_guids_dict, -) -from src.watched import ( - combine_watched_dicts, -) - -load_dotenv(override=True) - -generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True")) -generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True")) - - -def get_guids(item): - if item.get("Name"): - guids = {"title": item.get("Name")} - else: - logger(f"Jellyfin: Name not found in {item.get('Id')}", 1) - guids = {"title": None} - - if "ProviderIds" in item: - guids.update({k.lower(): v for k, v in item["ProviderIds"].items()}) - else: - logger(f"Jellyfin: ProviderIds not found in {item.get('Name')}", 1) - - if "MediaSources" in item: - guids["locations"] = tuple( - [x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x] - ) - else: - logger(f"Jellyfin: MediaSources not found in {item.get('Name')}", 1) - guids["locations"] = tuple() - - if "UserData" in item: - guids["status"] = { - "completed": item["UserData"]["Played"], - # Convert ticks to milliseconds to match Plex - "time": floor(item["UserData"]["PlaybackPositionTicks"] / 10000), - } - else: - logger(f"Jellyfin: UserData not found in {item.get('Name')}", 1) - guids["status"] = {} - - return guids - - -def get_video_status(jellyfin_video, videos_ids, videos): - video_status = None - - if generate_locations: - if "MediaSources" in jellyfin_video: - for video_location in jellyfin_video["MediaSources"]: - if "Path" in video_location: - if ( - contains_nested( - video_location["Path"].split("/")[-1], - videos_ids["locations"], - ) - is not None - ): - for video in videos: - if ( - contains_nested( - video_location["Path"].split("/")[-1], - video["locations"], - ) - is not None - ): - video_status = video["status"] - break - break - - if generate_guids: - if not video_status: - for ( - video_provider_source, - video_provider_id, - ) in jellyfin_video["ProviderIds"].items(): - if video_provider_source.lower() in videos_ids: - if ( - video_provider_id.lower() - in videos_ids[video_provider_source.lower()] - ): - for video in videos: - if video_provider_id.lower() in video.get( - video_provider_source.lower(), [] - ): - video_status = video["status"] - break - break - - return video_status - - -class Jellyfin: - def __init__(self, baseurl, token): - self.baseurl = baseurl - self.token = token - self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300)) - - if not self.baseurl: - raise Exception("Jellyfin baseurl not set") - - if not self.token: - raise Exception("Jellyfin token not set") - - self.session = requests.Session() - self.users = self.get_users() - - def query(self, query, query_type, session=None, identifiers=None): - try: - results = None - - authorization = ( - "MediaBrowser , " - 'Client="other", ' - 'Device="script", ' - 'DeviceId="script", ' - 'Version="0.0.0"' - ) - headers = { - "Accept": "application/json", - "X-Emby-Token": self.token, - "X-Emby-Authorization": authorization, - } - - if query_type == "get": - response = self.session.get( - self.baseurl + query, headers=headers, timeout=self.timeout - ) - if response.status_code != 200: - raise Exception( - f"Query failed with status {response.status_code} {response.reason}" - ) - results = response.json() - - elif query_type == "post": - response = self.session.post( - self.baseurl + query, headers=headers, timeout=self.timeout - ) - if response.status_code != 200: - raise Exception( - f"Query failed with status {response.status_code} {response.reason}" - ) - results = response.json() - - if not isinstance(results, list) and not isinstance(results, dict): - raise Exception("Query result is not of type list or dict") - - # append identifiers to results - if identifiers: - results["Identifiers"] = identifiers - - return results - - except Exception as e: - logger(f"Jellyfin: Query {query_type} {query}\nResults {results}\n{e}", 2) - raise Exception(e) - - def info(self) -> str: - try: - query_string = "/System/Info/Public" - - response = self.query(query_string, "get") - - if response: - return f"{response['ServerName']}: {response['Version']}" - else: - return None - - except Exception as e: - logger(f"Jellyfin: Get server name failed {e}", 2) - raise Exception(e) - - def get_users(self): - try: - users = {} - - query_string = "/Users" - response = self.query(query_string, "get") - - # If response is not empty - if response: - for user in response: - users[user["Name"]] = user["Id"] - - return users - except Exception as e: - logger(f"Jellyfin: Get users failed {e}", 2) - raise Exception(e) - - def get_user_library_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" - + f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", - "get", - ) - - in_progress = self.query( - f"/Users/{user_id}/Items" - + f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", - "get", - ) - - for movie in watched["Items"] + in_progress["Items"]: - if "MediaSources" in movie and movie["MediaSources"] != {}: - if "UserData" not in movie: - continue - - # Skip if not watched or watched less than a minute - if ( - movie["UserData"]["Played"] == True - or movie["UserData"]["PlaybackPositionTicks"] > 600000000 - ): - logger( - f"Jellyfin: Adding {movie.get('Name')} to {user_name} watched list", - 3, - ) - - # Get the movie's GUIDs - movie_guids = get_guids(movie) - - # Append the movie dictionary to the list for the given user and library - user_watched[user_name][library_title].append(movie_guids) - logger( - f"Jellyfin: Added {movie_guids} to {user_name} watched list", - 3, - ) - - # TV Shows - if library_type in ["Series", "Episode"]: - # Initialize an empty dictionary for the given user and library - user_watched[user_name][library_title] = {} - - # Retrieve a list of watched TV shows - watched_shows = self.query( - f"/Users/{user_id}/Items" - + f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount", - "get", - ) - - # Filter the list of shows to only include those that have been partially or fully watched - watched_shows_filtered = [] - for show in watched_shows["Items"]: - if not "UserData" in show: - continue - - if "PlayedPercentage" in show["UserData"]: - if show["UserData"]["PlayedPercentage"] > 0: - watched_shows_filtered.append(show) - - # Retrieve the seasons of each watched show - seasons_watched = [] - for show in watched_shows_filtered: - logger( - f"Jellyfin: Adding {show.get('Name')} to {user_name} watched list", - 3, - ) - 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]]) - if "Path" in show - else tuple() - ) - show_guids = frozenset(show_guids.items()) - show_identifiers = { - "show_guids": show_guids, - "show_id": show["Id"], - } - - seasons_watched.append( - self.query( - f"/Shows/{show['Id']}/Seasons" - + f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount", - "get", - identifiers=frozenset(show_identifiers.items()), - ) - ) - - # Filter the list of seasons to only include those that have been partially or fully watched - seasons_watched_filtered = [] - for seasons in seasons_watched: - seasons_watched_filtered_dict = {} - seasons_watched_filtered_dict["Identifiers"] = seasons[ - "Identifiers" - ] - seasons_watched_filtered_dict["Items"] = [] - for season in seasons["Items"]: - if "PlayedPercentage" in season["UserData"]: - if season["UserData"]["PlayedPercentage"] > 0: - seasons_watched_filtered_dict["Items"].append(season) - - if seasons_watched_filtered_dict["Items"]: - seasons_watched_filtered.append(seasons_watched_filtered_dict) - - # Create a list of tasks to retrieve the episodes of each watched season - watched_episodes = [] - for seasons in seasons_watched_filtered: - if len(seasons["Items"]) > 0: - for season in seasons["Items"]: - if "IndexNumber" not in season: - logger( - f"Jellyfin: Skipping show {season.get('SeriesName')} season {season.get('Name')} as it has no index number", - 3, - ) - - continue - season_identifiers = dict(seasons["Identifiers"]) - season_identifiers["season_index"] = season["IndexNumber"] - watched_task = self.query( - f"/Shows/{season_identifiers['show_id']}/Episodes" - + f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsPlayed&Fields=ProviderIds,MediaSources", - "get", - identifiers=frozenset(season_identifiers.items()), - ) - - in_progress_task = self.query( - f"/Shows/{season_identifiers['show_id']}/Episodes" - + f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsResumable&Fields=ProviderIds,MediaSources", - "get", - identifiers=frozenset(season_identifiers.items()), - ) - watched_episodes.append(watched_task) - watched_episodes.append(in_progress_task) - - # Iterate through the watched episodes - for episodes in watched_episodes: - # If the season has any watched episodes - if len(episodes["Items"]) > 0: - # Create a dictionary for the season with its identifier and episodes - season_dict = {} - season_dict["Identifiers"] = dict(episodes["Identifiers"]) - season_dict["Episodes"] = [] - for episode in episodes["Items"]: - if ( - "MediaSources" in episode - and episode["MediaSources"] != {} - ): - # If watched or watched more than a minute - if ( - episode["UserData"]["Played"] == True - or episode["UserData"]["PlaybackPositionTicks"] - > 600000000 - ): - episode_dict = get_guids(episode) - # Add the episode dictionary to the season's list of episodes - season_dict["Episodes"].append(episode_dict) - - # Add the season dictionary to the show's list of seasons - if ( - season_dict["Identifiers"]["show_guids"] - not in user_watched[user_name][library_title] - ): - user_watched[user_name][library_title][ - season_dict["Identifiers"]["show_guids"] - ] = {} - - if ( - season_dict["Identifiers"]["season_index"] - not in user_watched[user_name][library_title][ - season_dict["Identifiers"]["show_guids"] - ] - ): - user_watched[user_name][library_title][ - season_dict["Identifiers"]["show_guids"] - ][season_dict["Identifiers"]["season_index"]] = [] - - user_watched[user_name][library_title][ - season_dict["Identifiers"]["show_guids"] - ][season_dict["Identifiers"]["season_index"]] = season_dict[ - "Episodes" - ] - logger( - f"Jellyfin: Added {season_dict['Episodes']} to {user_name} {season_dict['Identifiers']['show_guids']} watched list", - 1, - ) - - logger( - f"Jellyfin: Got watched for {user_name} in library {library_title}", 1 - ) - if library_title in user_watched[user_name]: - logger(f"Jellyfin: {user_watched[user_name][library_title]}", 3) - - return user_watched - except Exception as e: - logger( - f"Jellyfin: Failed to get watched for {user_name} in library {library_title}, Error: {e}", - 2, - ) - - logger(traceback.format_exc(), 2) - return {} - - def get_users_watched( - self, - user_name, - user_id, - blacklist_library, - whitelist_library, - blacklist_library_type, - whitelist_library_type, - library_mapping, - ): - try: - # Get all libraries - user_name = user_name.lower() - watched = [] - - libraries = [] - - all_libraries = self.query(f"/Users/{user_id}/Views", "get") - for library in all_libraries["Items"]: - library_id = library["Id"] - library_title = library["Name"] - identifiers = { - "library_id": library_id, - "library_title": library_title, - } - libraries.append( - self.query( - f"/Users/{user_id}/Items" - + f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100", - "get", - identifiers=identifiers, - ) - ) - - for library in libraries: - if len(library["Items"]) == 0: - continue - - library_id = library["Identifiers"]["library_id"] - library_title = library["Identifiers"]["library_title"] - # Get all library types excluding "Folder" - types = set( - [ - x["Type"] - for x in library["Items"] - if x["Type"] in ["Movie", "Series", "Episode"] - ] - ) - - skip_reason = check_skip_logic( - library_title, - types, - blacklist_library, - whitelist_library, - blacklist_library_type, - whitelist_library_type, - library_mapping, - ) - - if skip_reason: - logger( - f"Jellyfin: Skipping library {library_title}: {skip_reason}", - 1, - ) - continue - - # If there are multiple types in library raise error - if types is None or len(types) < 1: - all_types = set([x["Type"] for x in library["Items"]]) - logger( - f"Jellyfin: Skipping Library {library_title} found types: {types}, all types: {all_types}", - 1, - ) - continue - - for library_type in types: - # Get watched for user - watched.append( - self.get_user_library_watched( - user_name, - user_id, - library_type, - library_id, - library_title, - ) - ) - - return watched - except Exception as e: - logger(f"Jellyfin: Failed to get users watched, Error: {e}", 2) - raise Exception(e) - - def get_watched( - self, - users, - blacklist_library, - whitelist_library, - blacklist_library_type, - whitelist_library_type, - library_mapping=None, - ): - try: - users_watched = {} - watched = [] - - for user_name, user_id in users.items(): - watched.append( - self.get_users_watched( - user_name, - user_id, - blacklist_library, - whitelist_library, - blacklist_library_type, - whitelist_library_type, - library_mapping, - ) - ) - - for user_watched in watched: - user_watched_combine = combine_watched_dicts(user_watched) - for user, user_watched_temp in user_watched_combine.items(): - if user not in users_watched: - users_watched[user] = {} - users_watched[user].update(user_watched_temp) - - return users_watched - except Exception as e: - logger(f"Jellyfin: Failed to get watched, Error: {e}", 2) - raise Exception(e) - - def update_user_watched( - self, user_name, user_id, library, library_id, videos, dryrun - ): - try: - logger( - f"Jellyfin: Updating watched for {user_name} in library {library}", 1 - ) - ( - videos_shows_ids, - videos_episodes_ids, - videos_movies_ids, - ) = generate_library_guids_dict(videos) - - if ( - not videos_movies_ids - and not videos_shows_ids - and not videos_episodes_ids - ): - logger( - f"Jellyfin: No videos to mark as watched for {user_name} in library {library}", - 1, - ) - - return - - logger( - f"Jellyfin: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}", - 1, - ) - - if videos_movies_ids: - jellyfin_search = self.query( - f"/Users/{user_id}/Items" - + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" - + "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie", - "get", - ) - for jellyfin_video in jellyfin_search["Items"]: - movie_status = get_video_status( - jellyfin_video, videos_movies_ids, videos - ) - - if movie_status: - jellyfin_video_id = jellyfin_video["Id"] - if movie_status["completed"]: - msg = f"Jellyfin: {jellyfin_video.get('Name')} as watched for {user_name} in {library}" - if not dryrun: - logger(msg, 5) - self.query( - f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", - "post", - ) - else: - logger(msg, 6) - - log_marked( - user_name, - library, - jellyfin_video.get("Name"), - ) - else: - # TODO add support for partially watched movies - msg = f"Jellyfin: {jellyfin_video.get('Name')} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library}" - """ - if not dryrun: - pass - # logger(msg, 5) - else: - pass - # logger(msg, 6) - - log_marked( - user_name, - library, - jellyfin_video.get("Name"), - duration=floor(movie_status["time"] / 60_000), - )""" - else: - logger( - f"Jellyfin: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}", - 3, - ) - - # TV Shows - if videos_shows_ids and videos_episodes_ids: - jellyfin_search = self.query( - f"/Users/{user_id}/Items" - + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" - + "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series", - "get", - ) - jellyfin_shows = [x for x in jellyfin_search["Items"]] - - for jellyfin_show in jellyfin_shows: - show_found = False - episode_videos = [] - - if generate_locations: - if "Path" in jellyfin_show: - if ( - contains_nested( - jellyfin_show["Path"].split("/")[-1], - videos_shows_ids["locations"], - ) - is not None - ): - show_found = True - for shows, seasons in videos.items(): - show = {k: v for k, v in shows} - if ( - contains_nested( - jellyfin_show["Path"].split("/")[-1], - show["locations"], - ) - is not None - ): - for season in seasons.values(): - for episode in season: - episode_videos.append(episode) - - break - - if generate_guids: - 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 - for show, seasons in videos.items(): - show = {k: v for k, v in show} - if show_provider_id.lower() in show.get( - show_provider_source.lower(), [] - ): - for season in seasons.values(): - for episode in season: - episode_videos.append(episode) - - break - - if show_found: - logger( - f"Jellyfin: Updating watched for {user_name} in library {library} for show {jellyfin_show.get('Name')}", - 1, - ) - jellyfin_show_id = jellyfin_show["Id"] - jellyfin_episodes = self.query( - f"/Shows/{jellyfin_show_id}/Episodes" - + f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", - "get", - ) - - for jellyfin_episode in jellyfin_episodes["Items"]: - episode_status = get_video_status( - jellyfin_episode, videos_episodes_ids, episode_videos - ) - - if episode_status: - jellyfin_episode_id = jellyfin_episode["Id"] - if episode_status["completed"]: - msg = ( - f"Jellyfin: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" - + f" as watched for {user_name} in {library}" - ) - if not dryrun: - logger(msg, 5) - self.query( - f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", - "post", - ) - else: - logger(msg, 6) - - log_marked( - user_name, - library, - jellyfin_episode.get("SeriesName"), - jellyfin_episode.get("Name"), - ) - else: - # TODO add support for partially watched episodes - msg = ( - f"Jellyfin: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" - + f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library}" - ) - """ - if not dryrun: - pass - # logger(f"Marked {msg}", 0) - else: - pass - # logger(f"Dryrun {msg}", 0) - - log_marked( - user_name, - library, - jellyfin_episode.get("SeriesName"), - jellyfin_episode.get('Name'), - duration=floor(episode_status["time"] / 60_000), - )""" - else: - logger( - f"Jellyfin: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}", - 3, - ) - else: - logger( - f"Jellyfin: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}", - 3, - ) - - except Exception as e: - logger( - f"Jellyfin: Error updating watched for {user_name} in library {library}, {e}", - 2, - ) - logger(traceback.format_exc(), 2) - raise Exception(e) - - def update_watched( - self, watched_list, user_mapping=None, library_mapping=None, dryrun=False - ): - try: - for user, libraries in watched_list.items(): - logger(f"Jellyfin: Updating for entry {user}, {libraries}", 1) - user_other = None - user_name = 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: - if user.lower() == key.lower(): - user_id = self.users[key] - user_name = key - break - elif user_other and user_other.lower() == key.lower(): - user_id = self.users[key] - user_name = 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", - ) - jellyfin_libraries = [x for x in jellyfin_libraries["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", - 1, - ) - continue - else: - logger( - f"Jellyfin: Library {library} not found in library list", - 1, - ) - continue - - library_id = None - for jellyfin_library in jellyfin_libraries: - if jellyfin_library["Name"] == library: - library_id = jellyfin_library["Id"] - continue - - if library_id: - self.update_user_watched( - user_name, user_id, library, library_id, videos, dryrun - ) - - except Exception as e: - logger(f"Jellyfin: Error updating watched, {e}", 2) - raise Exception(e) +from src.jellyfin_emby import JellyfinEmby + + +class Jellyfin(JellyfinEmby): + def __init__(self, baseurl, token): + authorization = ( + "MediaBrowser , " + 'Client="JellyPlex-Watched", ' + 'Device="script", ' + 'DeviceId="script", ' + 'Version="5.2.0", ' + f'Token="{token}"' + ) + headers = { + "Accept": "application/json", + "Authorization": authorization, + } + + super().__init__( + server_type="Jellyfin", baseurl=baseurl, token=token, headers=headers + ) diff --git a/src/jellyfin_emby.py b/src/jellyfin_emby.py new file mode 100644 index 0000000..c33c49c --- /dev/null +++ b/src/jellyfin_emby.py @@ -0,0 +1,884 @@ +# Functions for Jellyfin and Emby + +import traceback, os +from math import floor +from dotenv import load_dotenv +import requests + +from src.functions import ( + logger, + search_mapping, + contains_nested, + log_marked, + str_to_bool, +) +from src.library import ( + check_skip_logic, + generate_library_guids_dict, +) +from src.watched import ( + combine_watched_dicts, +) + +load_dotenv(override=True) + +generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True")) +generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True")) + + +def get_guids(server_type, item): + if item.get("Name"): + guids = {"title": item.get("Name")} + else: + logger(f"{server_type}: Name not found in {item.get('Id')}", 1) + guids = {"title": None} + + if "ProviderIds" in item: + guids.update({k.lower(): v for k, v in item["ProviderIds"].items()}) + else: + logger(f"{server_type}: ProviderIds not found in {item.get('Name')}", 1) + + if "MediaSources" in item: + guids["locations"] = tuple( + [x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x] + ) + else: + logger(f"{server_type}: MediaSources not found in {item.get('Name')}", 1) + guids["locations"] = tuple() + + if "UserData" in item: + guids["status"] = { + "completed": item["UserData"]["Played"], + # Convert ticks to milliseconds to match Plex + "time": floor(item["UserData"]["PlaybackPositionTicks"] / 10000), + } + else: + logger(f"{server_type}: UserData not found in {item.get('Name')}", 1) + guids["status"] = {} + + return guids + + +def get_video_status(server_video, videos_ids, videos): + video_status = None + + if generate_locations: + if "MediaSources" in server_video: + for video_location in server_video["MediaSources"]: + if "Path" in video_location: + if ( + contains_nested( + video_location["Path"].split("/")[-1], + videos_ids["locations"], + ) + is not None + ): + for video in videos: + if ( + contains_nested( + video_location["Path"].split("/")[-1], + video["locations"], + ) + is not None + ): + video_status = video["status"] + break + break + + if generate_guids: + if not video_status: + for ( + video_provider_source, + video_provider_id, + ) in server_video["ProviderIds"].items(): + if video_provider_source.lower() in videos_ids: + if ( + video_provider_id.lower() + in videos_ids[video_provider_source.lower()] + ): + for video in videos: + if video_provider_id.lower() in video.get( + video_provider_source.lower(), [] + ): + video_status = video["status"] + break + break + + return video_status + + +class JellyfinEmby: + def __init__(self, server_type, baseurl, token, headers): + if server_type not in ["Jellyfin", "Emby"]: + raise Exception(f"Server type {server_type} not supported") + + self.server_type = server_type + self.baseurl = baseurl + self.token = token + self.headers = headers + self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300)) + + if not self.baseurl: + raise Exception(f"{self.server_type} baseurl not set") + + if not self.token: + raise Exception(f"{self.server_type} token not set") + + self.session = requests.Session() + self.users = self.get_users() + + def query(self, query, query_type, identifiers=None, json=None): + try: + results = None + + if query_type == "get": + response = self.session.get( + self.baseurl + query, headers=self.headers, timeout=self.timeout + ) + if response.status_code not in [200, 204]: + raise Exception( + f"Query failed with status {response.status_code} {response.reason}" + ) + if response.status_code == 204: + results = None + else: + results = response.json() + + elif query_type == "post": + response = self.session.post( + self.baseurl + query, + headers=self.headers, + json=json, + timeout=self.timeout, + ) + if response.status_code not in [200, 204]: + raise Exception( + f"Query failed with status {response.status_code} {response.reason}" + ) + if response.status_code == 204: + results = None + else: + results = response.json() + + if results is not None: + if not isinstance(results, list) and not isinstance(results, dict): + raise Exception("Query result is not of type list or dict") + + # append identifiers to results + if identifiers: + results["Identifiers"] = identifiers + + return results + + except Exception as e: + logger( + f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}", + 2, + ) + raise Exception(e) + + def info(self) -> str: + try: + query_string = "/System/Info/Public" + + response = self.query(query_string, "get") + + if response: + return f"{response['ServerName']}: {response['Version']}" + else: + return None + + except Exception as e: + logger(f"{self.server_type}: Get server name failed {e}", 2) + raise Exception(e) + + def get_users(self): + try: + users = {} + + query_string = "/Users" + response = self.query(query_string, "get") + + # If response is not empty + if response: + for user in response: + users[user["Name"]] = user["Id"] + + return users + except Exception as e: + logger(f"{self.server_type}: Get users failed {e}", 2) + raise Exception(e) + + def get_user_library_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"{self.server_type}: 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" + + f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", + "get", + ) + + in_progress = self.query( + f"/Users/{user_id}/Items" + + f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", + "get", + ) + + for movie in watched["Items"] + in_progress["Items"]: + if "MediaSources" in movie and movie["MediaSources"] != {}: + if "UserData" not in movie: + continue + + # Skip if not watched or watched less than a minute + if ( + movie["UserData"]["Played"] == True + or movie["UserData"]["PlaybackPositionTicks"] > 600000000 + ): + logger( + f"{self.server_type}: Adding {movie.get('Name')} to {user_name} watched list", + 3, + ) + + # Get the movie's GUIDs + movie_guids = get_guids(self.server_type, movie) + + # Append the movie dictionary to the list for the given user and library + user_watched[user_name][library_title].append(movie_guids) + logger( + f"{self.server_type}: Added {movie_guids} to {user_name} watched list", + 3, + ) + + # TV Shows + if library_type in ["Series", "Episode"]: + # Initialize an empty dictionary for the given user and library + user_watched[user_name][library_title] = {} + + # Retrieve a list of watched TV shows + watched_shows = self.query( + f"/Users/{user_id}/Items" + + f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount", + "get", + ) + + # Filter the list of shows to only include those that have been partially or fully watched + watched_shows_filtered = [] + for show in watched_shows["Items"]: + if not "UserData" in show: + continue + + if "PlayedPercentage" in show["UserData"]: + if show["UserData"]["PlayedPercentage"] > 0: + watched_shows_filtered.append(show) + + # Retrieve the seasons of each watched show + seasons_watched = [] + for show in watched_shows_filtered: + logger( + f"{self.server_type}: Adding {show.get('Name')} to {user_name} watched list", + 3, + ) + 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]]) + if "Path" in show + else tuple() + ) + show_guids = frozenset(show_guids.items()) + show_identifiers = { + "show_guids": show_guids, + "show_id": show["Id"], + } + + seasons_watched.append( + self.query( + f"/Shows/{show['Id']}/Seasons" + + f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount", + "get", + identifiers=frozenset(show_identifiers.items()), + ) + ) + + # Filter the list of seasons to only include those that have been partially or fully watched + seasons_watched_filtered = [] + for seasons in seasons_watched: + seasons_watched_filtered_dict = {} + seasons_watched_filtered_dict["Identifiers"] = seasons[ + "Identifiers" + ] + seasons_watched_filtered_dict["Items"] = [] + for season in seasons["Items"]: + if "PlayedPercentage" in season["UserData"]: + if season["UserData"]["PlayedPercentage"] > 0: + seasons_watched_filtered_dict["Items"].append(season) + + if seasons_watched_filtered_dict["Items"]: + seasons_watched_filtered.append(seasons_watched_filtered_dict) + + # Create a list of tasks to retrieve the episodes of each watched season + watched_episodes = [] + for seasons in seasons_watched_filtered: + if len(seasons["Items"]) > 0: + for season in seasons["Items"]: + if "IndexNumber" not in season: + logger( + f"Jellyfin: Skipping show {season.get('SeriesName')} season {season.get('Name')} as it has no index number", + 3, + ) + + continue + season_identifiers = dict(seasons["Identifiers"]) + season_identifiers["season_index"] = season["IndexNumber"] + watched_task = self.query( + f"/Shows/{season_identifiers['show_id']}/Episodes" + + f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsPlayed&Fields=ProviderIds,MediaSources", + "get", + identifiers=frozenset(season_identifiers.items()), + ) + + in_progress_task = self.query( + f"/Shows/{season_identifiers['show_id']}/Episodes" + + f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsResumable&Fields=ProviderIds,MediaSources", + "get", + identifiers=frozenset(season_identifiers.items()), + ) + watched_episodes.append(watched_task) + watched_episodes.append(in_progress_task) + + # Iterate through the watched episodes + for episodes in watched_episodes: + # If the season has any watched episodes + if len(episodes["Items"]) > 0: + # Create a dictionary for the season with its identifier and episodes + season_dict = {} + season_dict["Identifiers"] = dict(episodes["Identifiers"]) + season_dict["Episodes"] = [] + for episode in episodes["Items"]: + if ( + "MediaSources" in episode + and episode["MediaSources"] != {} + ): + # If watched or watched more than a minute + if ( + episode["UserData"]["Played"] == True + or episode["UserData"]["PlaybackPositionTicks"] + > 600000000 + ): + episode_dict = get_guids(self.server_type, episode) + # Add the episode dictionary to the season's list of episodes + season_dict["Episodes"].append(episode_dict) + + # Add the season dictionary to the show's list of seasons + if ( + season_dict["Identifiers"]["show_guids"] + not in user_watched[user_name][library_title] + ): + user_watched[user_name][library_title][ + season_dict["Identifiers"]["show_guids"] + ] = {} + + if ( + season_dict["Identifiers"]["season_index"] + not in user_watched[user_name][library_title][ + season_dict["Identifiers"]["show_guids"] + ] + ): + user_watched[user_name][library_title][ + season_dict["Identifiers"]["show_guids"] + ][season_dict["Identifiers"]["season_index"]] = [] + + user_watched[user_name][library_title][ + season_dict["Identifiers"]["show_guids"] + ][season_dict["Identifiers"]["season_index"]] = season_dict[ + "Episodes" + ] + logger( + f"{self.server_type}: Added {season_dict['Episodes']} to {user_name} {season_dict['Identifiers']['show_guids']} watched list", + 1, + ) + + logger( + f"{self.server_type}: Got watched for {user_name} in library {library_title}", + 1, + ) + if library_title in user_watched[user_name]: + logger( + f"{self.server_type}: {user_watched[user_name][library_title]}", 3 + ) + + return user_watched + except Exception as e: + logger( + f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}", + 2, + ) + + logger(traceback.format_exc(), 2) + return {} + + def get_users_watched( + self, + user_name, + user_id, + blacklist_library, + whitelist_library, + blacklist_library_type, + whitelist_library_type, + library_mapping, + ): + try: + # Get all libraries + user_name = user_name.lower() + watched = [] + + libraries = [] + + all_libraries = self.query(f"/Users/{user_id}/Views", "get") + for library in all_libraries["Items"]: + library_id = library["Id"] + library_title = library["Name"] + identifiers = { + "library_id": library_id, + "library_title": library_title, + } + libraries.append( + self.query( + f"/Users/{user_id}/Items" + + f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100", + "get", + identifiers=identifiers, + ) + ) + + for library in libraries: + if len(library["Items"]) == 0: + continue + + library_id = library["Identifiers"]["library_id"] + library_title = library["Identifiers"]["library_title"] + # Get all library types excluding "Folder" + types = set( + [ + x["Type"] + for x in library["Items"] + if x["Type"] in ["Movie", "Series", "Episode"] + ] + ) + + skip_reason = check_skip_logic( + library_title, + types, + blacklist_library, + whitelist_library, + blacklist_library_type, + whitelist_library_type, + library_mapping, + ) + + if skip_reason: + logger( + f"{self.server_type}: Skipping library {library_title}: {skip_reason}", + 1, + ) + continue + + # If there are multiple types in library raise error + if types is None or len(types) < 1: + all_types = set([x["Type"] for x in library["Items"]]) + logger( + f"{self.server_type}: Skipping Library {library_title} found types: {types}, all types: {all_types}", + 1, + ) + continue + + for library_type in types: + # Get watched for user + watched.append( + self.get_user_library_watched( + user_name, + user_id, + library_type, + library_id, + library_title, + ) + ) + + return watched + except Exception as e: + logger(f"{self.server_type}: Failed to get users watched, Error: {e}", 2) + raise Exception(e) + + def get_watched( + self, + users, + blacklist_library, + whitelist_library, + blacklist_library_type, + whitelist_library_type, + library_mapping=None, + ): + try: + users_watched = {} + watched = [] + + for user_name, user_id in users.items(): + watched.append( + self.get_users_watched( + user_name, + user_id, + blacklist_library, + whitelist_library, + blacklist_library_type, + whitelist_library_type, + library_mapping, + ) + ) + + for user_watched in watched: + user_watched_combine = combine_watched_dicts(user_watched) + for user, user_watched_temp in user_watched_combine.items(): + if user not in users_watched: + users_watched[user] = {} + users_watched[user].update(user_watched_temp) + + return users_watched + except Exception as e: + logger(f"{self.server_type}: Failed to get watched, Error: {e}", 2) + raise Exception(e) + + def update_user_watched( + self, user_name, user_id, library, library_id, videos, dryrun + ): + try: + logger( + f"{self.server_type}: Updating watched for {user_name} in library {library}", + 1, + ) + ( + videos_shows_ids, + videos_episodes_ids, + videos_movies_ids, + ) = generate_library_guids_dict(videos) + + if ( + not videos_movies_ids + and not videos_shows_ids + and not videos_episodes_ids + ): + logger( + f"{self.server_type}: No videos to mark as watched for {user_name} in library {library}", + 1, + ) + + return + + logger( + f"{self.server_type}: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}", + 1, + ) + + if videos_movies_ids: + jellyfin_search = self.query( + f"/Users/{user_id}/Items" + + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" + + "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie", + "get", + ) + for jellyfin_video in jellyfin_search["Items"]: + movie_status = get_video_status( + jellyfin_video, videos_movies_ids, videos + ) + + if movie_status: + jellyfin_video_id = jellyfin_video["Id"] + if movie_status["completed"]: + msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library}" + if not dryrun: + logger(msg, 5) + self.query( + f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", + "post", + ) + else: + logger(msg, 6) + + log_marked( + user_name, + library, + jellyfin_video.get("Name"), + ) + else: + msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library}" + + if not dryrun: + logger(msg, 5) + playback_position_payload = { + "PlaybackPositionTicks": movie_status["time"] + * 10_000, + } + self.query( + f"/Users/{user_id}/Items/{jellyfin_video_id}/UserData", + "post", + json=playback_position_payload, + ) + else: + logger(msg, 6) + + log_marked( + user_name, + library, + jellyfin_video.get("Name"), + duration=floor(movie_status["time"] / 60_000), + ) + else: + logger( + f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}", + 3, + ) + + # TV Shows + if videos_shows_ids and videos_episodes_ids: + jellyfin_search = self.query( + f"/Users/{user_id}/Items" + + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" + + "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series", + "get", + ) + jellyfin_shows = [x for x in jellyfin_search["Items"]] + + for jellyfin_show in jellyfin_shows: + show_found = False + episode_videos = [] + + if generate_locations: + if "Path" in jellyfin_show: + if ( + contains_nested( + jellyfin_show["Path"].split("/")[-1], + videos_shows_ids["locations"], + ) + is not None + ): + show_found = True + for shows, seasons in videos.items(): + show = {k: v for k, v in shows} + if ( + contains_nested( + jellyfin_show["Path"].split("/")[-1], + show["locations"], + ) + is not None + ): + for season in seasons.values(): + for episode in season: + episode_videos.append(episode) + + break + + if generate_guids: + 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 + for show, seasons in videos.items(): + show = {k: v for k, v in show} + if show_provider_id.lower() in show.get( + show_provider_source.lower(), [] + ): + for season in seasons.values(): + for episode in season: + episode_videos.append(episode) + + break + + if show_found: + logger( + f"{self.server_type}: Updating watched for {user_name} in library {library} for show {jellyfin_show.get('Name')}", + 1, + ) + jellyfin_show_id = jellyfin_show["Id"] + jellyfin_episodes = self.query( + f"/Shows/{jellyfin_show_id}/Episodes" + + f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", + "get", + ) + + for jellyfin_episode in jellyfin_episodes["Items"]: + episode_status = get_video_status( + jellyfin_episode, videos_episodes_ids, episode_videos + ) + + if episode_status: + jellyfin_episode_id = jellyfin_episode["Id"] + if episode_status["completed"]: + msg = ( + f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" + + f" as watched for {user_name} in {library}" + ) + if not dryrun: + logger(msg, 5) + self.query( + f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", + "post", + ) + else: + logger(msg, 6) + + log_marked( + user_name, + library, + jellyfin_episode.get("SeriesName"), + jellyfin_episode.get("Name"), + ) + else: + msg = ( + f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" + + f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library}" + ) + + if not dryrun: + logger(msg, 5) + playback_position_payload = { + "PlaybackPositionTicks": episode_status[ + "time" + ] + * 10_000, + } + self.query( + f"/Users/{user_id}/Items/{jellyfin_episode_id}/UserData", + "post", + json=playback_position_payload, + ) + else: + logger(msg, 6) + + log_marked( + user_name, + library, + jellyfin_episode.get("SeriesName"), + jellyfin_episode.get("Name"), + duration=floor(episode_status["time"] / 60_000), + ) + else: + logger( + f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}", + 3, + ) + else: + logger( + f"{self.server_type}: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}", + 3, + ) + + except Exception as e: + logger( + f"{self.server_type}: Error updating watched for {user_name} in library {library}, {e}", + 2, + ) + logger(traceback.format_exc(), 2) + raise Exception(e) + + def update_watched( + self, watched_list, user_mapping=None, library_mapping=None, dryrun=False + ): + try: + for user, libraries in watched_list.items(): + logger(f"{self.server_type}: Updating for entry {user}, {libraries}", 1) + user_other = None + user_name = 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: + if user.lower() == key.lower(): + user_id = self.users[key] + user_name = key + break + elif user_other and user_other.lower() == key.lower(): + user_id = self.users[key] + user_name = 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", + ) + jellyfin_libraries = [x for x in jellyfin_libraries["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"{self.server_type}: Library {library} not found, but {library_other} found, using {library_other}", + 1, + ) + library = library_other + else: + logger( + f"{self.server_type}: Library {library} or {library_other} not found in library list", + 1, + ) + continue + else: + logger( + f"{self.server_type}: Library {library} not found in library list", + 1, + ) + continue + + library_id = None + for jellyfin_library in jellyfin_libraries: + if jellyfin_library["Name"] == library: + library_id = jellyfin_library["Id"] + continue + + if library_id: + self.update_user_watched( + user_name, user_id, library, library_id, videos, dryrun + ) + + except Exception as e: + logger(f"{self.server_type}: Error updating watched, {e}", 2) + raise Exception(e) diff --git a/src/main.py b/src/main.py index 53b97e2..0ee4b9c 100644 --- a/src/main.py +++ b/src/main.py @@ -19,6 +19,7 @@ from src.plex import Plex from src.jellyfin import Jellyfin +from src.emby import Emby load_dotenv(override=True) @@ -65,6 +66,47 @@ def setup_users( return output_server_1_users, output_server_2_users +def jellyfin_emby_server_connection(server_baseurl, server_token, server_type): + servers = [] + + server_baseurl = server_baseurl.split(",") + server_token = server_token.split(",") + + if len(server_baseurl) != len(server_token): + raise Exception( + f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries" + ) + + for i, baseurl in enumerate(server_baseurl): + baseurl = baseurl.strip() + if baseurl[-1] == "/": + baseurl = baseurl[:-1] + + if server_type == "jellyfin": + server = Jellyfin(baseurl=baseurl, token=server_token[i].strip()) + servers.append( + ( + "jellyfin", + server, + ) + ) + + elif server_type == "emby": + server = Emby(baseurl=baseurl, token=server_token[i].strip()) + servers.append( + ( + "emby", + server, + ) + ) + else: + raise Exception("Unknown server type") + + logger(f"{server_type} Server {i} info: {server.info()}", 3) + + return servers + + def generate_server_connections(): servers = [] @@ -137,121 +179,84 @@ def generate_server_connections(): 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" + servers.extend( + jellyfin_emby_server_connection( + jellyfin_baseurl, jellyfin_token, "jellyfin" ) - - for i, baseurl in enumerate(jellyfin_baseurl): - baseurl = baseurl.strip() - if baseurl[-1] == "/": - baseurl = baseurl[:-1] - - server = Jellyfin(baseurl=baseurl, token=jellyfin_token[i].strip()) - - logger(f"Jellyfin Server {i} info: {server.info()}", 3) - servers.append( - ( - "jellyfin", - server, - ) - ) - - return servers - - -def get_server_watched( - server_connection: list, - users: dict, - blacklist_library: list, - whitelist_library: list, - blacklist_library_type: list, - whitelist_library_type: list, - library_mapping: dict, -): - if server_connection[0] == "plex": - return server_connection[1].get_watched( - users, - blacklist_library, - whitelist_library, - blacklist_library_type, - whitelist_library_type, - library_mapping, - ) - elif server_connection[0] == "jellyfin": - return server_connection[1].get_watched( - users, - blacklist_library, - whitelist_library, - blacklist_library_type, - whitelist_library_type, - library_mapping, ) + emby_baseurl = os.getenv("EMBY_BASEURL", None) + emby_token = os.getenv("EMBY_TOKEN", None) -def update_server_watched( - server_connection: list, - server_watched_filtered: dict, - user_mapping: dict, - library_mapping: dict, - dryrun: bool, -): - if server_connection[0] == "plex": - server_connection[1].update_watched( - server_watched_filtered, user_mapping, library_mapping, dryrun - ) - elif server_connection[0] == "jellyfin": - server_connection[1].update_watched( - server_watched_filtered, user_mapping, library_mapping, dryrun + if emby_baseurl and emby_token: + servers.extend( + jellyfin_emby_server_connection(emby_baseurl, emby_token, "emby") ) + return servers + def should_sync_server(server_1_type, server_2_type): sync_from_plex_to_jellyfin = str_to_bool( os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True") ) + sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True")) + sync_from_plex_to_emby = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_EMBY", "True")) + sync_from_jelly_to_plex = str_to_bool( os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True") ) - sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True")) sync_from_jelly_to_jellyfin = str_to_bool( os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True") ) + sync_from_jelly_to_emby = str_to_bool( + os.getenv("SYNC_FROM_JELLYFIN_TO_EMBY", "True") + ) - if ( - server_1_type == "plex" - and server_2_type == "plex" - and not sync_from_plex_to_plex - ): - logger("Sync between plex and plex is disabled", 1) - return False + sync_from_emby_to_plex = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_PLEX", "True")) + sync_from_emby_to_jellyfin = str_to_bool( + os.getenv("SYNC_FROM_EMBY_TO_JELLYFIN", "True") + ) + sync_from_emby_to_emby = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_EMBY", "True")) - if ( - server_1_type == "plex" - and server_2_type == "jellyfin" - and not sync_from_jelly_to_plex - ): - logger("Sync from jellyfin to plex disabled", 1) - return False + if server_1_type == "plex": + if server_2_type == "jellyfin" and not sync_from_plex_to_jellyfin: + logger("Sync from plex -> jellyfin is disabled", 1) + return False - if ( - server_1_type == "jellyfin" - and server_2_type == "jellyfin" - and not sync_from_jelly_to_jellyfin - ): - logger("Sync between jellyfin and jellyfin is disabled", 1) - return False + if server_2_type == "emby" and not sync_from_plex_to_emby: + logger("Sync from plex -> emby is disabled", 1) + return False - if ( - server_1_type == "jellyfin" - and server_2_type == "plex" - and not sync_from_plex_to_jellyfin - ): - logger("Sync from plex to jellyfin is disabled", 1) - return False + if server_2_type == "plex" and not sync_from_plex_to_plex: + logger("Sync from plex -> plex is disabled", 1) + return False + + if server_1_type == "jellyfin": + if server_2_type == "plex" and not sync_from_jelly_to_plex: + logger("Sync from jellyfin -> plex is disabled", 1) + return False + + if server_2_type == "jellyfin" and not sync_from_jelly_to_jellyfin: + logger("Sync from jellyfin -> jellyfin is disabled", 1) + return False + + if server_2_type == "emby" and not sync_from_jelly_to_emby: + logger("Sync from jellyfin -> emby is disabled", 1) + return False + + if server_1_type == "emby": + if server_2_type == "plex" and not sync_from_emby_to_plex: + logger("Sync from emby -> plex is disabled", 1) + return False + + if server_2_type == "jellyfin" and not sync_from_emby_to_jellyfin: + logger("Sync from emby -> jellyfin is disabled", 1) + return False + + if server_2_type == "emby" and not sync_from_emby_to_emby: + logger("Sync from emby -> emby is disabled", 1) + return False return True @@ -313,6 +318,12 @@ def main_loop(): # Start server_2 at the next server in the list for server_2 in servers[servers.index(server_1) + 1 :]: + # Check if server 1 and server 2 are going to be synced in either direction, skip if not + if not should_sync_server( + server_1[0], server_2[0] + ) and not should_sync_server(server_2[0], server_1[0]): + continue + logger(f"Server 1: {server_1[0].capitalize()}: {server_1[1].info()}", 0) logger(f"Server 2: {server_2[0].capitalize()}: {server_2[1].info()}", 0) @@ -323,8 +334,7 @@ def main_loop(): ) logger("Creating watched lists", 1) - server_1_watched = get_server_watched( - server_1, + server_1_watched = server_1[1].get_watched( server_1_users, blacklist_library, whitelist_library, @@ -333,8 +343,8 @@ def main_loop(): library_mapping, ) logger("Finished creating watched list server 1", 1) - server_2_watched = get_server_watched( - server_2, + + server_2_watched = server_2[1].get_watched( server_2_users, blacklist_library, whitelist_library, @@ -343,6 +353,7 @@ def main_loop(): library_mapping, ) logger("Finished creating watched list server 2", 1) + logger(f"Server 1 watched: {server_1_watched}", 3) logger(f"Server 2 watched: {server_2_watched}", 3) @@ -365,18 +376,18 @@ def main_loop(): 1, ) - if should_sync_server(server_1[0], server_2[0]): - update_server_watched( - server_1, + if should_sync_server(server_2[0], server_1[0]): + logger(f"Syncing {server_2[1].info()} -> {server_1[1].info()}", 0) + server_1[1].update_watched( server_2_watched_filtered, user_mapping, library_mapping, dryrun, ) - if should_sync_server(server_2[0], server_1[0]): - update_server_watched( - server_2, + if should_sync_server(server_1[0], server_2[0]): + logger(f"Syncing {server_1[1].info()} -> {server_2[1].info()}", 0) + server_2[1].update_watched( server_1_watched_filtered, user_mapping, library_mapping, diff --git a/src/users.py b/src/users.py index 4f1e280..f8d4a68 100644 --- a/src/users.py +++ b/src/users.py @@ -16,7 +16,7 @@ def generate_user_list(server): user.username.lower() if user.username else user.title.lower() ) - elif server_type == "jellyfin": + elif server_type in ["jellyfin", "emby"]: server_users = [key.lower() for key in server_connection.users.keys()] return server_users @@ -79,7 +79,7 @@ def generate_server_users(server, users): or username_title.lower() in users.values() ): server_users.append(plex_user) - elif server[0] == "jellyfin": + elif server[0] in ["jellyfin", "emby"]: server_users = {} for jellyfin_user, jellyfin_id in server[1].users.items(): if ( diff --git a/test/ci_emby.env b/test/ci_emby.env new file mode 100644 index 0000000..73c7af5 --- /dev/null +++ b/test/ci_emby.env @@ -0,0 +1,116 @@ +# Global Settings + +## Do not mark any shows/movies as played and instead just output to log if they would of been marked. +DRYRUN = "True" + +## Additional logging information +DEBUG = "True" + +## Debugging level, "info" is default, "debug" is more verbose +DEBUG_LEVEL = "info" + +## If set to true then the script will only run once and then exit +RUN_ONLY_ONCE = "True" + +## How often to run the script in seconds +SLEEP_DURATION = 10 + +## Log file where all output will be written to +LOG_FILE = "log.log" + +## Mark file where all shows/movies that have been marked as played will be written to +MARK_FILE = "mark.log" + +## Timeout for requests for jellyfin +REQUEST_TIMEOUT = 300 + +## Max threads for processing +MAX_THREADS = 2 + +## Generate guids +## Generating guids is a slow process, so this is a way to speed up the process +# by using the location only, useful when using same files on multiple servers +GENERATE_GUIDS = "True" + +## Generate locations +## Generating locations is a slow process, so this is a way to speed up the process +## by using the guid only, useful when using different files on multiple servers +GENERATE_LOCATIONS = "True" + +## Map usernames between servers in the event that they are different, order does not matter +## Comma seperated for multiple options +USER_MAPPING = {"JellyUser":"jellyplex_watched"} + +## Map libraries between servers in the even that they are different, order does not matter +## Comma seperated for multiple options +LIBRARY_MAPPING = { "Shows": "TV Shows" } + + +## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. +## Comma seperated for multiple options +#BLACKLIST_LIBRARY = "" +#WHITELIST_LIBRARY = "Movies" +#BLACKLIST_LIBRARY_TYPE = "Series" +#WHITELIST_LIBRARY_TYPE = "Movies, movie" +#BLACKLIST_USERS = "" +WHITELIST_USERS = "jellyplex_watched" + + + +# Plex + +## 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 = "https://localhost:32400" + +## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +## Comma seperated list for multiple servers +PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y" + +## If not using plex token then use username and password of the server admin along with the servername +## Comma seperated for multiple options +#PLEX_USERNAME = "PlexUser, PlexUser2" +#PLEX_PASSWORD = "SuperSecret, SuperSecret2" +#PLEX_SERVERNAME = "Plex Server1, Plex Server2" + +## Skip hostname validation for ssl certificates. +## Set to True if running into ssl certificate errors +SSL_BYPASS = "True" + +# Jellyfin + +## 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 +## Comma seperated list for multiple servers +JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" + +# Emby + +## Emby server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +EMBY_BASEURL = "http://localhost:8097" + +## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key +## Comma seperated list for multiple servers +EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" + + +# Syncing Options + +## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex +## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers +SYNC_FROM_PLEX_TO_JELLYFIN = "False" +SYNC_FROM_PLEX_TO_PLEX = "False" +SYNC_FROM_PLEX_TO_EMBY = "False" + +SYNC_FROM_JELLYFIN_TO_PLEX = "False" +SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False" +SYNC_FROM_JELLYFIN_TO_EMBY = "False" + +SYNC_FROM_EMBY_TO_PLEX = "True" +SYNC_FROM_EMBY_TO_JELLYFIN = "True" +SYNC_FROM_EMBY_TO_EMBY = "True" \ No newline at end of file diff --git a/test/ci2.env b/test/ci_guids.env similarity index 85% rename from test/ci2.env rename to test/ci_guids.env index 90c69bb..485d8e8 100644 --- a/test/ci2.env +++ b/test/ci_guids.env @@ -7,7 +7,7 @@ DRYRUN = "True" DEBUG = "True" ## Debugging level, "info" is default, "debug" is more verbose -DEBUG_LEVEL = "debug" +DEBUG_LEVEL = "info" ## If set to true then the script will only run once and then exit RUN_ONLY_ONCE = "True" @@ -78,13 +78,6 @@ PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y" ## Set to True if running into ssl certificate errors SSL_BYPASS = "True" -## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex -## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers -SYNC_FROM_PLEX_TO_JELLYFIN = "True" -SYNC_FROM_JELLYFIN_TO_PLEX = "True" -SYNC_FROM_PLEX_TO_PLEX = "True" -SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" - # Jellyfin ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly @@ -94,3 +87,30 @@ JELLYFIN_BASEURL = "http://localhost:8096" ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Comma seperated list for multiple servers JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" + +# Emby + +## Emby server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +EMBY_BASEURL = "http://localhost:8097" + +## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key +## Comma seperated list for multiple servers +EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" + + +# Syncing Options + +## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex +## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers +SYNC_FROM_PLEX_TO_JELLYFIN = "True" +SYNC_FROM_PLEX_TO_PLEX = "True" +SYNC_FROM_PLEX_TO_EMBY = "True" + +SYNC_FROM_JELLYFIN_TO_PLEX = "True" +SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" +SYNC_FROM_JELLYFIN_TO_EMBY = "True" + +SYNC_FROM_EMBY_TO_PLEX = "True" +SYNC_FROM_EMBY_TO_JELLYFIN = "True" +SYNC_FROM_EMBY_TO_EMBY = "True" \ No newline at end of file diff --git a/test/ci_jellyfin.env b/test/ci_jellyfin.env new file mode 100644 index 0000000..4202f37 --- /dev/null +++ b/test/ci_jellyfin.env @@ -0,0 +1,116 @@ +# Global Settings + +## Do not mark any shows/movies as played and instead just output to log if they would of been marked. +DRYRUN = "True" + +## Additional logging information +DEBUG = "True" + +## Debugging level, "info" is default, "debug" is more verbose +DEBUG_LEVEL = "info" + +## If set to true then the script will only run once and then exit +RUN_ONLY_ONCE = "True" + +## How often to run the script in seconds +SLEEP_DURATION = 10 + +## Log file where all output will be written to +LOG_FILE = "log.log" + +## Mark file where all shows/movies that have been marked as played will be written to +MARK_FILE = "mark.log" + +## Timeout for requests for jellyfin +REQUEST_TIMEOUT = 300 + +## Max threads for processing +MAX_THREADS = 2 + +## Generate guids +## Generating guids is a slow process, so this is a way to speed up the process +# by using the location only, useful when using same files on multiple servers +GENERATE_GUIDS = "True" + +## Generate locations +## Generating locations is a slow process, so this is a way to speed up the process +## by using the guid only, useful when using different files on multiple servers +GENERATE_LOCATIONS = "True" + +## Map usernames between servers in the event that they are different, order does not matter +## Comma seperated for multiple options +USER_MAPPING = {"JellyUser":"jellyplex_watched"} + +## Map libraries between servers in the even that they are different, order does not matter +## Comma seperated for multiple options +LIBRARY_MAPPING = { "Shows": "TV Shows" } + + +## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. +## Comma seperated for multiple options +#BLACKLIST_LIBRARY = "" +#WHITELIST_LIBRARY = "Movies" +#BLACKLIST_LIBRARY_TYPE = "Series" +#WHITELIST_LIBRARY_TYPE = "Movies, movie" +#BLACKLIST_USERS = "" +WHITELIST_USERS = "jellyplex_watched" + + + +# Plex + +## 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 = "https://localhost:32400" + +## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +## Comma seperated list for multiple servers +PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y" + +## If not using plex token then use username and password of the server admin along with the servername +## Comma seperated for multiple options +#PLEX_USERNAME = "PlexUser, PlexUser2" +#PLEX_PASSWORD = "SuperSecret, SuperSecret2" +#PLEX_SERVERNAME = "Plex Server1, Plex Server2" + +## Skip hostname validation for ssl certificates. +## Set to True if running into ssl certificate errors +SSL_BYPASS = "True" + +# Jellyfin + +## 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 +## Comma seperated list for multiple servers +JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" + +# Emby + +## Emby server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +EMBY_BASEURL = "http://localhost:8097" + +## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key +## Comma seperated list for multiple servers +EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" + + +# Syncing Options + +## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex +## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers +SYNC_FROM_PLEX_TO_JELLYFIN = "False" +SYNC_FROM_PLEX_TO_PLEX = "False" +SYNC_FROM_PLEX_TO_EMBY = "False" + +SYNC_FROM_JELLYFIN_TO_PLEX = "True" +SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" +SYNC_FROM_JELLYFIN_TO_EMBY = "True" + +SYNC_FROM_EMBY_TO_PLEX = "False" +SYNC_FROM_EMBY_TO_JELLYFIN = "False" +SYNC_FROM_EMBY_TO_EMBY = "False" \ No newline at end of file diff --git a/test/ci1.env b/test/ci_locations.env similarity index 85% rename from test/ci1.env rename to test/ci_locations.env index 8c0e0f8..bc1bedf 100644 --- a/test/ci1.env +++ b/test/ci_locations.env @@ -7,7 +7,7 @@ DRYRUN = "True" DEBUG = "True" ## Debugging level, "info" is default, "debug" is more verbose -DEBUG_LEVEL = "debug" +DEBUG_LEVEL = "info" ## If set to true then the script will only run once and then exit RUN_ONLY_ONCE = "True" @@ -78,13 +78,6 @@ PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y" ## Set to True if running into ssl certificate errors SSL_BYPASS = "True" -## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex -## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers -SYNC_FROM_PLEX_TO_JELLYFIN = "True" -SYNC_FROM_JELLYFIN_TO_PLEX = "True" -SYNC_FROM_PLEX_TO_PLEX = "True" -SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" - # Jellyfin ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly @@ -94,3 +87,30 @@ JELLYFIN_BASEURL = "http://localhost:8096" ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Comma seperated list for multiple servers JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" + +# Emby + +## Emby server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +EMBY_BASEURL = "http://localhost:8097" + +## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key +## Comma seperated list for multiple servers +EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" + + +# Syncing Options + +## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex +## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers +SYNC_FROM_PLEX_TO_JELLYFIN = "True" +SYNC_FROM_PLEX_TO_PLEX = "True" +SYNC_FROM_PLEX_TO_EMBY = "True" + +SYNC_FROM_JELLYFIN_TO_PLEX = "True" +SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" +SYNC_FROM_JELLYFIN_TO_EMBY = "True" + +SYNC_FROM_EMBY_TO_PLEX = "True" +SYNC_FROM_EMBY_TO_JELLYFIN = "True" +SYNC_FROM_EMBY_TO_EMBY = "True" \ No newline at end of file diff --git a/test/ci_plex.env b/test/ci_plex.env new file mode 100644 index 0000000..97d01dc --- /dev/null +++ b/test/ci_plex.env @@ -0,0 +1,116 @@ +# Global Settings + +## Do not mark any shows/movies as played and instead just output to log if they would of been marked. +DRYRUN = "True" + +## Additional logging information +DEBUG = "True" + +## Debugging level, "info" is default, "debug" is more verbose +DEBUG_LEVEL = "info" + +## If set to true then the script will only run once and then exit +RUN_ONLY_ONCE = "True" + +## How often to run the script in seconds +SLEEP_DURATION = 10 + +## Log file where all output will be written to +LOG_FILE = "log.log" + +## Mark file where all shows/movies that have been marked as played will be written to +MARK_FILE = "mark.log" + +## Timeout for requests for jellyfin +REQUEST_TIMEOUT = 300 + +## Max threads for processing +MAX_THREADS = 2 + +## Generate guids +## Generating guids is a slow process, so this is a way to speed up the process +# by using the location only, useful when using same files on multiple servers +GENERATE_GUIDS = "True" + +## Generate locations +## Generating locations is a slow process, so this is a way to speed up the process +## by using the guid only, useful when using different files on multiple servers +GENERATE_LOCATIONS = "True" + +## Map usernames between servers in the event that they are different, order does not matter +## Comma seperated for multiple options +USER_MAPPING = {"JellyUser":"jellyplex_watched"} + +## Map libraries between servers in the even that they are different, order does not matter +## Comma seperated for multiple options +LIBRARY_MAPPING = { "Shows": "TV Shows" } + + +## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. +## Comma seperated for multiple options +#BLACKLIST_LIBRARY = "" +#WHITELIST_LIBRARY = "Movies" +#BLACKLIST_LIBRARY_TYPE = "Series" +#WHITELIST_LIBRARY_TYPE = "Movies, movie" +#BLACKLIST_USERS = "" +WHITELIST_USERS = "jellyplex_watched" + + + +# Plex + +## 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 = "https://localhost:32400" + +## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +## Comma seperated list for multiple servers +PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y" + +## If not using plex token then use username and password of the server admin along with the servername +## Comma seperated for multiple options +#PLEX_USERNAME = "PlexUser, PlexUser2" +#PLEX_PASSWORD = "SuperSecret, SuperSecret2" +#PLEX_SERVERNAME = "Plex Server1, Plex Server2" + +## Skip hostname validation for ssl certificates. +## Set to True if running into ssl certificate errors +SSL_BYPASS = "True" + +# Jellyfin + +## 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 +## Comma seperated list for multiple servers +JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" + +# Emby + +## Emby server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +EMBY_BASEURL = "http://localhost:8097" + +## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key +## Comma seperated list for multiple servers +EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" + + +# Syncing Options + +## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex +## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers +SYNC_FROM_PLEX_TO_JELLYFIN = "True" +SYNC_FROM_PLEX_TO_PLEX = "True" +SYNC_FROM_PLEX_TO_EMBY = "True" + +SYNC_FROM_JELLYFIN_TO_PLEX = "False" +SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False" +SYNC_FROM_JELLYFIN_TO_EMBY = "False" + +SYNC_FROM_EMBY_TO_PLEX = "False" +SYNC_FROM_EMBY_TO_JELLYFIN = "False" +SYNC_FROM_EMBY_TO_EMBY = "False" \ No newline at end of file diff --git a/test/ci3.env b/test/ci_write.env similarity index 85% rename from test/ci3.env rename to test/ci_write.env index a6ca241..5e229f0 100644 --- a/test/ci3.env +++ b/test/ci_write.env @@ -7,7 +7,7 @@ DRYRUN = "False" DEBUG = "True" ## Debugging level, "info" is default, "debug" is more verbose -DEBUG_LEVEL = "debug" +DEBUG_LEVEL = "info" ## If set to true then the script will only run once and then exit RUN_ONLY_ONCE = "True" @@ -78,13 +78,6 @@ PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y" ## Set to True if running into ssl certificate errors SSL_BYPASS = "True" -## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex -## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers -SYNC_FROM_PLEX_TO_JELLYFIN = "True" -SYNC_FROM_JELLYFIN_TO_PLEX = "True" -SYNC_FROM_PLEX_TO_PLEX = "True" -SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" - # Jellyfin ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly @@ -94,3 +87,30 @@ JELLYFIN_BASEURL = "http://localhost:8096" ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Comma seperated list for multiple servers JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" + +# Emby + +## Emby server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma seperated list for multiple servers +EMBY_BASEURL = "http://localhost:8097" + +## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key +## Comma seperated list for multiple servers +EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" + + +# Syncing Options + +## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex +## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers +SYNC_FROM_PLEX_TO_JELLYFIN = "True" +SYNC_FROM_PLEX_TO_PLEX = "True" +SYNC_FROM_PLEX_TO_EMBY = "True" + +SYNC_FROM_JELLYFIN_TO_PLEX = "True" +SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" +SYNC_FROM_JELLYFIN_TO_EMBY = "True" + +SYNC_FROM_EMBY_TO_PLEX = "True" +SYNC_FROM_EMBY_TO_JELLYFIN = "True" +SYNC_FROM_EMBY_TO_EMBY = "True" \ No newline at end of file diff --git a/test/validate_ci_marklog.py b/test/validate_ci_marklog.py index 180b65c..801b134 100644 --- a/test/validate_ci_marklog.py +++ b/test/validate_ci_marklog.py @@ -1,6 +1,29 @@ # Check the mark.log file that is generated by the CI to make sure it contains the expected values -import os +import os, argparse + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Check the mark.log file that is generated by the CI to make sure it contains the expected values" + ) + parser.add_argument( + "--dry", action="store_true", help="Check the mark.log file for dry-run" + ) + parser.add_argument( + "--write", action="store_true", help="Check the mark.log file for write-run" + ) + parser.add_argument( + "--plex", action="store_true", help="Check the mark.log file for Plex" + ) + parser.add_argument( + "--jellyfin", action="store_true", help="Check the mark.log file for Jellyfin" + ) + parser.add_argument( + "--emby", action="store_true", help="Check the mark.log file for Emby" + ) + + return parser.parse_args() def read_marklog(): @@ -29,7 +52,8 @@ def check_marklog(lines, expected_values): + str(len(found_values)) + " values, expected " + str(len(expected_values)) - + " values" + + " values\n" + + "\n".join(found_values) ) # Check that the two lists contain the same values @@ -48,20 +72,110 @@ def check_marklog(lines, expected_values): def main(): - expected_values = [ - "jellyplex_watched/Movies/Five Nights at Freddy's", - "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215", - "jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", - "jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670", - "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath", - "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741", - "JellyUser/Movies/Big Buck Bunny", - "JellyUser/Shows/Doctor Who/The Unquiet Dead", - "JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", - ] - - # Triple the expected values because the CI runs three times - expected_values = expected_values * 3 + args = parse_args() + expected = { + "dry": [ + # Jellyfin -> Plex + "jellyplex_watched/Movies/Five Nights at Freddy's", + "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215", + "jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", + "jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670", + "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath", + "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741", + # Plex -> Jellyfin + "JellyUser/Movies/Big Buck Bunny", + "JellyUser/Movies/Killers of the Flower Moon/4", + "JellyUser/Shows/Doctor Who/The Unquiet Dead", + "JellyUser/Shows/Doctor Who/Aliens of London (1)/4", + "JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", + "JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", + # Emby -> Plex + "jellyplex_watched/Movies/Tears of Steel", + "jellyplex_watched/TV shows/Doctor Who (2005)/World War Three (2)", + "jellyplex_watched/TV shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429", + # Plex -> Emby + "jellyplex_watched/Movies/Big Buck Bunny", + "jellyplex_watched/Movies/The Family Plan", + "jellyplex_watched/Movies/Killers of the Flower Moon/4", + # Emby -> Jellyfin + "JellyUser/Movies/Tears of Steel", + # Jellyfin -> Emby + "jellyplex_watched/Movies/The Family Plan", + "jellyplex_watched/Movies/Five Nights at Freddy's", + "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5", + ], + "write": [ + "jellyplex_watched/Movies/Five Nights at Freddy's", + "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215", + "jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", + "jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670", + "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath", + "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741", + "JellyUser/Movies/Big Buck Bunny", + "JellyUser/Movies/Killers of the Flower Moon/4", + "JellyUser/Shows/Doctor Who/The Unquiet Dead", + "JellyUser/Shows/Doctor Who/Aliens of London (1)/4", + "JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", + "JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", + "jellyplex_watched/Movies/Tears of Steel", + "jellyplex_watched/TV shows/Doctor Who (2005)/World War Three (2)", + "jellyplex_watched/TV shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429", + "jellyplex_watched/Movies/Big Buck Bunny", + "jellyplex_watched/Movies/The Family Plan", + "jellyplex_watched/Movies/Five Nights at Freddy's", + "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5", + "jellyplex_watched/Movies/Killers of the Flower Moon/4", + "JellyUser/Movies/Tears of Steel", + "JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", + "jellyplex_watched/TV shows/Doctor Who (2005)/World War Three (2)", + "jellyplex_watched/TV shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429", + ], + "plex": [ + "JellyUser/Movies/Big Buck Bunny", + "JellyUser/Movies/Killers of the Flower Moon/4", + "JellyUser/Shows/Doctor Who/The Unquiet Dead", + "JellyUser/Shows/Doctor Who/Aliens of London (1)/4", + "JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", + "JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", + "jellyplex_watched/Movies/Big Buck Bunny", + "jellyplex_watched/Movies/The Family Plan", + "jellyplex_watched/Movies/Killers of the Flower Moon/4", + ], + "jellyfin": [ + "jellyplex_watched/Movies/Five Nights at Freddy's", + "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215", + "jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", + "jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670", + "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath", + "jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741", + "jellyplex_watched/Movies/The Family Plan", + "jellyplex_watched/Movies/Five Nights at Freddy's", + "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5", + ], + "emby": [ + "jellyplex_watched/Movies/Tears of Steel", + "jellyplex_watched/TV shows/Doctor Who (2005)/World War Three (2)", + "jellyplex_watched/TV shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429", + "JellyUser/Movies/Tears of Steel", + ], + } + + # Expected values for the mark.log file, dry-run is slightly different than write-run + # due to some of the items being copied over from one server to another and now being there + # for the next server run. + if args.dry: + expected_values = expected["dry"] + elif args.write: + expected_values = expected["write"] + elif args.plex: + expected_values = expected["plex"] + elif args.jellyfin: + expected_values = expected["jellyfin"] + elif args.emby: + expected_values = expected["emby"] + else: + print("No server specified") + exit(1) lines = read_marklog() if not check_marklog(lines, expected_values):