From 1f7da2f60970bd209ce3c1dcad5d8c5ec4dd71d2 Mon Sep 17 00:00:00 2001 From: Luis Garcia Date: Mon, 6 May 2024 21:28:48 -0600 Subject: [PATCH 1/7] Add support for emby --- .env.sample | 36 +- .github/workflows/ci.yml | 39 +- README.md | 16 +- src/emby.py | 21 + src/jellyfin.py | 880 +---------------------------- src/jellyfin_emby.py | 860 ++++++++++++++++++++++++++++ src/main.py | 213 +++---- src/users.py | 4 +- test/{ci2.env => ci_guids.env} | 34 +- test/{ci1.env => ci_locations.env} | 34 +- test/{ci3.env => ci_write.env} | 34 +- test/validate_ci_marklog.py | 83 ++- 12 files changed, 1217 insertions(+), 1037 deletions(-) create mode 100644 src/emby.py create mode 100644 src/jellyfin_emby.py rename test/{ci2.env => ci_guids.env} (86%) rename test/{ci1.env => ci_locations.env} (86%) rename test/{ci3.env => ci_write.env} (86%) 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..a389162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,43 +40,42 @@ 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" run: | - # Test ci1 - mv test/ci1.env .env + # Test guids + mv test/ci_guids.env .env python main.py - # Test ci2 - mv test/ci2.env .env + cat mark.log + python test/validate_ci_marklog.py --dry + rm mark.log + + # Test locations + mv test/ci_locations.env .env python main.py - # Test ci3 - mv test/ci3.env .env + cat mark.log + python test/validate_ci_marklog.py --dry + rm mark.log + + # 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 docker: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 476f933..aef716f 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 @@ -32,12 +32,12 @@ Keep in sync all your users watched history between jellyfin and plex servers lo ### Emby -* \[ ] Match via filenames -* \[ ] Match via provider ids -* \[ ] Map usernames -* \[ ] Use single login -* \[ ] One way/multi way sync -* \[ ] Sync watched +* \[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 * \[ ] Sync in progress ## Configuration 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..db38fa7 --- /dev/null +++ b/src/jellyfin_emby.py @@ -0,0 +1,860 @@ +# 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): + try: + results = None + + if query_type == "get": + response = self.session.get( + self.baseurl + query, headers=self.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=self.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"{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: + # TODO add support for partially watched movies + 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: + 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"{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: + # TODO add support for partially watched episodes + 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: + 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"{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..aef2e12 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 to 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 to 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 to 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 to plex is disabled", 1) + return False + + if server_2_type == "jellyfin" and not sync_from_jelly_to_jellyfin: + logger("Sync from jellyfin to jellyfin is disabled", 1) + return False + + if server_2_type == "emby" and not sync_from_jelly_to_emby: + logger("Sync from jellyfin to 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 to plex is disabled", 1) + return False + + if server_2_type == "jellyfin" and not sync_from_emby_to_jellyfin: + logger("Sync from emby to jellyfin is disabled", 1) + return False + + if server_2_type == "emby" and not sync_from_emby_to_emby: + logger("Sync from emby to emby is disabled", 1) + return False return True @@ -323,8 +328,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 +337,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 +347,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 +370,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/ci2.env b/test/ci_guids.env similarity index 86% rename from test/ci2.env rename to test/ci_guids.env index 90c69bb..48a20da 100644 --- a/test/ci2.env +++ b/test/ci_guids.env @@ -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/ci1.env b/test/ci_locations.env similarity index 86% rename from test/ci1.env rename to test/ci_locations.env index 8c0e0f8..6628f39 100644 --- a/test/ci1.env +++ b/test/ci_locations.env @@ -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/ci3.env b/test/ci_write.env similarity index 86% rename from test/ci3.env rename to test/ci_write.env index a6ca241..7afa0b5 100644 --- a/test/ci3.env +++ b/test/ci_write.env @@ -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..1416100 100644 --- a/test/validate_ci_marklog.py +++ b/test/validate_ci_marklog.py @@ -1,6 +1,20 @@ # 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" + ) + + return parser.parse_args() def read_marklog(): @@ -48,20 +62,59 @@ 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 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 = [ + # 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/Shows/Doctor Who/The Unquiet Dead", + "JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", + # 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", + # Emby -> Jellyfin + "JellyUser/Movies/Tears of Steel", + # Jellyfin -> Emby + "jellyplex_watched/Movies/The Family Plan", + "jellyplex_watched/Movies/Five Nights at Freddy's", + ] + + elif args.write: + 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", + "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", + "JellyUser/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", + ] lines = read_marklog() if not check_marklog(lines, expected_values): From 632dfbcadb2ff5511d988dd249fc7b6f7756be79 Mon Sep 17 00:00:00 2001 From: Luis Garcia Date: Mon, 6 May 2024 22:30:44 -0600 Subject: [PATCH 2/7] CI: Add more tests --- .github/workflows/ci.yml | 41 ++++++++++--- test/ci_emby.env | 116 ++++++++++++++++++++++++++++++++++++ test/ci_guids.env | 2 +- test/ci_jellyfin.env | 116 ++++++++++++++++++++++++++++++++++++ test/ci_locations.env | 2 +- test/ci_plex.env | 116 ++++++++++++++++++++++++++++++++++++ test/ci_write.env | 2 +- test/validate_ci_marklog.py | 75 ++++++++++++++++------- 8 files changed, 438 insertions(+), 32 deletions(-) create mode 100644 test/ci_emby.env create mode 100644 test/ci_jellyfin.env create mode 100644 test/ci_plex.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a389162..a8882e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,24 +49,48 @@ jobs: docker-compose -f "${FOLDER}/docker-compose.yml" logs done - - name: "Run tests" + - name: "Test Plex" run: | - # Test guids - mv test/ci_guids.env .env + 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 - cat mark.log + - name: "Test Emby" + run: | + mv test/ci_emby.env .env + python main.py + python test/validate_ci_marklog.py --emby + + rm mark.log + + - name: "Test Guids" + run: | + mv test/ci_guids.env .env + python main.py python test/validate_ci_marklog.py --dry + rm mark.log - # Test locations + - name: "Test Locations" + run: | mv test/ci_locations.env .env python main.py - - cat mark.log 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 @@ -74,8 +98,9 @@ jobs: # Test again to test if it can handle existing data python main.py - cat mark.log python test/validate_ci_marklog.py --write + + rm mark.log docker: runs-on: ubuntu-latest 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/ci_guids.env b/test/ci_guids.env index 48a20da..485d8e8 100644 --- a/test/ci_guids.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" 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/ci_locations.env b/test/ci_locations.env index 6628f39..bc1bedf 100644 --- a/test/ci_locations.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" 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/ci_write.env b/test/ci_write.env index 7afa0b5..5e229f0 100644 --- a/test/ci_write.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" diff --git a/test/validate_ci_marklog.py b/test/validate_ci_marklog.py index 1416100..5d18785 100644 --- a/test/validate_ci_marklog.py +++ b/test/validate_ci_marklog.py @@ -4,15 +4,12 @@ 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 = 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() @@ -43,7 +40,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 @@ -63,12 +61,8 @@ def check_marklog(lines, expected_values): def main(): args = parse_args() - - # 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": [ # Jellyfin -> Plex "jellyplex_watched/Movies/Five Nights at Freddy's", "jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215", @@ -91,11 +85,9 @@ def main(): "JellyUser/Movies/Tears of Steel", # Jellyfin -> Emby "jellyplex_watched/Movies/The Family Plan", - "jellyplex_watched/Movies/Five Nights at Freddy's", - ] - - elif args.write: - expected_values = [ + "jellyplex_watched/Movies/Five Nights at Freddy's" + ], + "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", @@ -113,8 +105,49 @@ def main(): "jellyplex_watched/Movies/Five Nights at Freddy's", "JellyUser/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": [ + "JellyUser/Movies/Big Buck Bunny", + "JellyUser/Shows/Doctor Who/The Unquiet Dead", + "JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", + "jellyplex_watched/Movies/Big Buck Bunny", + "jellyplex_watched/Movies/The Family Plan", + ], + "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" + ], + "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): From b67e6d7257decf1ab0f983e0377643db96223e70 Mon Sep 17 00:00:00 2001 From: Luis Garcia Date: Tue, 7 May 2024 11:42:01 -0600 Subject: [PATCH 3/7] Add skip if nothing is being synced --- src/main.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main.py b/src/main.py index aef2e12..c56a0c2 100644 --- a/src/main.py +++ b/src/main.py @@ -221,41 +221,41 @@ def should_sync_server(server_1_type, server_2_type): if server_1_type == "plex": if server_2_type == "jellyfin" and not sync_from_plex_to_jellyfin: - logger("Sync from plex to jellyfin is disabled", 1) + logger("Sync from plex -> jellyfin is disabled", 1) return False if server_2_type == "emby" and not sync_from_plex_to_emby: - logger("Sync from plex to emby is disabled", 1) + logger("Sync from plex -> emby is disabled", 1) return False if server_2_type == "plex" and not sync_from_plex_to_plex: - logger("Sync from plex to plex is disabled", 1) + 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 to plex is disabled", 1) + 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 to jellyfin is disabled", 1) + 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 to emby is disabled", 1) + 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 to plex is disabled", 1) + 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 to jellyfin is disabled", 1) + 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 to emby is disabled", 1) + logger("Sync from emby -> emby is disabled", 1) return False return True @@ -318,6 +318,10 @@ 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) From 2b871c58ed344e63db901d1367fa840524be7b9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 05:35:48 +0000 Subject: [PATCH 4/7] --- updated-dependencies: - dependency-name: requests dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 0fa2a698acffcc38ad26bdb27d9382149b42a421 Mon Sep 17 00:00:00 2001 From: awakenedhaggis Date: Sun, 26 May 2024 02:01:51 -0600 Subject: [PATCH 5/7] feat(jellyfin+emby): support marking item as partially watched - this feature uses the new endpoint introduced with 24cbd64 --- src/jellyfin_emby.py | 63 ++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/src/jellyfin_emby.py b/src/jellyfin_emby.py index db38fa7..777629e 100644 --- a/src/jellyfin_emby.py +++ b/src/jellyfin_emby.py @@ -127,7 +127,9 @@ def __init__(self, server_type, baseurl, token, headers): self.session = requests.Session() self.users = self.get_users() - def query(self, query, query_type, identifiers=None): + def query( + self, query, query_type, identifiers=None, json=None + ): try: results = None @@ -135,24 +137,31 @@ def query(self, query, query_type, identifiers=None): response = self.session.get( self.baseurl + query, headers=self.headers, timeout=self.timeout ) - if response.status_code != 200: + if response.status_code not in [200, 204]: raise Exception( f"Query failed with status {response.status_code} {response.reason}" ) - results = response.json() + 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, timeout=self.timeout + self.baseurl + query, headers=self.headers, json=json, timeout=self.timeout ) - if response.status_code != 200: + if response.status_code not in [200, 204]: raise Exception( f"Query failed with status {response.status_code} {response.reason}" ) - results = response.json() + if response.status_code == 204: + results = None + else: + results = response.json() - if not isinstance(results, list) and not isinstance(results, dict): - raise Exception("Query result is not of type list or dict") + 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: @@ -612,22 +621,27 @@ def update_user_watched( jellyfin_video.get("Name"), ) else: - # TODO add support for partially watched movies 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: - pass - # logger(msg, 5) + 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: - pass - # logger(msg, 6) + 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}", @@ -737,18 +751,23 @@ def update_user_watched( jellyfin_episode.get("Name"), ) else: - # TODO add support for partially watched episodes 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: - pass - # logger(f"Marked {msg}", 0) + 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: - pass - # logger(f"Dryrun {msg}", 0) + logger(msg, 6) log_marked( user_name, @@ -756,7 +775,7 @@ def update_user_watched( 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}", From 4185f5fc94930d026562bba2857482f84b4f9e37 Mon Sep 17 00:00:00 2001 From: Luis Garcia Date: Sun, 2 Jun 2024 18:19:48 -0600 Subject: [PATCH 6/7] Fix tests to include partially watched jellyfin and emby --- src/jellyfin_emby.py | 25 +++++++++++-------- src/main.py | 4 ++- test/validate_ci_marklog.py | 50 +++++++++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/jellyfin_emby.py b/src/jellyfin_emby.py index 777629e..c33c49c 100644 --- a/src/jellyfin_emby.py +++ b/src/jellyfin_emby.py @@ -127,9 +127,7 @@ def __init__(self, server_type, baseurl, token, headers): self.session = requests.Session() self.users = self.get_users() - def query( - self, query, query_type, identifiers=None, json=None - ): + def query(self, query, query_type, identifiers=None, json=None): try: results = None @@ -148,7 +146,10 @@ def query( elif query_type == "post": response = self.session.post( - self.baseurl + query, headers=self.headers, json=json, timeout=self.timeout + self.baseurl + query, + headers=self.headers, + json=json, + timeout=self.timeout, ) if response.status_code not in [200, 204]: raise Exception( @@ -626,12 +627,13 @@ def update_user_watched( if not dryrun: logger(msg, 5) playback_position_payload = { - "PlaybackPositionTicks": movie_status['time'] * 10_000, + "PlaybackPositionTicks": movie_status["time"] + * 10_000, } self.query( f"/Users/{user_id}/Items/{jellyfin_video_id}/UserData", "post", - json=playback_position_payload + json=playback_position_payload, ) else: logger(msg, 6) @@ -755,16 +757,19 @@ def update_user_watched( 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, + "PlaybackPositionTicks": episode_status[ + "time" + ] + * 10_000, } self.query( f"/Users/{user_id}/Items/{jellyfin_episode_id}/UserData", "post", - json=playback_position_payload + json=playback_position_payload, ) else: logger(msg, 6) @@ -773,7 +778,7 @@ def update_user_watched( user_name, library, jellyfin_episode.get("SeriesName"), - jellyfin_episode.get('Name'), + jellyfin_episode.get("Name"), duration=floor(episode_status["time"] / 60_000), ) else: diff --git a/src/main.py b/src/main.py index c56a0c2..0ee4b9c 100644 --- a/src/main.py +++ b/src/main.py @@ -319,7 +319,9 @@ 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]): + 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) diff --git a/test/validate_ci_marklog.py b/test/validate_ci_marklog.py index 5d18785..801b134 100644 --- a/test/validate_ci_marklog.py +++ b/test/validate_ci_marklog.py @@ -4,12 +4,24 @@ 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") + 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() @@ -72,8 +84,11 @@ def main(): "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)", @@ -81,11 +96,13 @@ def main(): # 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/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", @@ -95,24 +112,34 @@ def main(): "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" + "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", @@ -122,14 +149,15 @@ def main(): "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/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" - ] + "JellyUser/Movies/Tears of Steel", + ], } # Expected values for the mark.log file, dry-run is slightly different than write-run From 21ffce674f54106a2aae4e0c39cb341859069ccd Mon Sep 17 00:00:00 2001 From: Luis Garcia Date: Sun, 2 Jun 2024 18:22:30 -0600 Subject: [PATCH 7/7] README: Add sync in progress ticks for Jellyfin and Emby --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aef716f..37880ed 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Keep in sync all your users watched history between jellyfin, plex and emby serv * \[x] Use single login * \[x] One way/multi way sync * \[x] Sync watched -* \[ ] Sync in progress +* \[x] Sync in progress ### Emby @@ -38,7 +38,7 @@ Keep in sync all your users watched history between jellyfin, plex and emby serv * \[x] Use single login * \[x] One way/multi way sync * \[x] Sync watched -* \[ ] Sync in progress +* \[x] Sync in progress ## Configuration