From a5540b94d50f5c4aafc9a5d379b9b7fa3d9a0fcf Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Wed, 22 Mar 2023 19:48:19 -0600 Subject: [PATCH 01/12] Gather partially watched movie/episodes with todo for processing. --- src/jellyfin.py | 298 ++++++++++++++++++++++++++++++++++-------------- src/library.py | 36 +++--- src/plex.py | 247 +++++++++++++++++++++++++++++---------- 3 files changed, 418 insertions(+), 163 deletions(-) diff --git a/src/jellyfin.py b/src/jellyfin.py index 9cc7b38..c938b11 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -1,4 +1,5 @@ import asyncio, aiohttp, traceback +from math import floor from src.functions import ( logger, @@ -13,6 +14,56 @@ ) +def get_movie_guids(movie): + if "ProviderIds" in movie: + logger( + f"Jellyfin: {movie['Name']} {movie['ProviderIds']} {movie['MediaSources']}", + 3, + ) + else: + logger( + f"Jellyfin: {movie['Name']} {movie['MediaSources']['Path']}", + 3, + ) + + # Create a dictionary for the movie with its title + movie_guids = {"title": movie["Name"]} + + # If the movie has provider IDs, add them to the dictionary + if "ProviderIds" in movie: + movie_guids.update({k.lower(): v for k, v in movie["ProviderIds"].items()}) + + # If the movie has media sources, add them to the dictionary + if "MediaSources" in movie: + movie_guids["locations"] = tuple( + [x["Path"].split("/")[-1] for x in movie["MediaSources"]] + ) + + movie_guids["status"] = { + "completed": movie["UserData"]["Played"], + # Convert ticks to milliseconds to match Plex + "time": floor(movie["UserData"]["PlaybackPositionTicks"] / 10000), + } + + return movie_guids + + +def get_episode_guids(episode): + # Create a dictionary for the episode with its provider IDs and media sources + episode_dict = {k.lower(): v for k, v in episode["ProviderIds"].items()} + episode_dict["title"] = episode["Name"] + episode_dict["locations"] = tuple( + [x["Path"].split("/")[-1] for x in episode["MediaSources"]] + ) + + episode_dict["status"] = { + "completed": episode["UserData"]["Played"], + "time": floor(episode["UserData"]["PlaybackPositionTicks"] / 10000), + } + + return episode_dict + + class Jellyfin: def __init__(self, baseurl, token): self.baseurl = baseurl @@ -114,48 +165,43 @@ async def get_user_library_watched( session, ) + in_progress = await self.query( + f"/Users/{user_id}/Items" + + f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", + "get", + session, + ) + for movie in watched["Items"]: - # Check if the movie has been played - if ( - movie["UserData"]["Played"] is True - and "MediaSources" in movie - and movie["MediaSources"] is not {} - ): + if "MediaSources" in movie and movie["MediaSources"] is not {}: logger( f"Jellyfin: Adding {movie['Name']} to {user_name} watched list", 3, ) - if "ProviderIds" in movie: - logger( - f"Jellyfin: {movie['Name']} {movie['ProviderIds']} {movie['MediaSources']}", - 3, - ) - else: - logger( - f"Jellyfin: {movie['Name']} {movie['MediaSources']['Path']}", - 3, - ) - # Create a dictionary for the movie with its title - movie_guids = {"title": movie["Name"]} + # Get the movie's GUIDs + movie_guids = get_movie_guids(movie) - # If the movie has provider IDs, add them to the dictionary - if "ProviderIds" in movie: - movie_guids.update( - { - k.lower(): v - for k, v in movie["ProviderIds"].items() - } - ) + # 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, + ) - # If the movie has media sources, add them to the dictionary - if "MediaSources" in movie: - movie_guids["locations"] = tuple( - [ - x["Path"].split("/")[-1] - for x in movie["MediaSources"] - ] - ) + # Get all partially watched movies greater than 1 minute + for movie in in_progress["Items"]: + if "MediaSources" in movie and movie["MediaSources"] is not {}: + if movie["UserData"]["PlaybackPositionTicks"] < 600000000: + continue + + logger( + f"Jellyfin: Adding {movie['Name']} to {user_name} watched list", + 3, + ) + + # Get the movie's GUIDs + movie_guids = get_movie_guids(movie) # Append the movie dictionary to the list for the given user and library user_watched[user_name][library_title].append(movie_guids) @@ -244,16 +290,26 @@ async def get_user_library_watched( season_identifiers = dict(seasons["Identifiers"]) season_identifiers["season_id"] = season["Id"] season_identifiers["season_name"] = season["Name"] - episode_task = asyncio.ensure_future( + watched_task = asyncio.ensure_future( self.query( f"/Shows/{season_identifiers['show_id']}/Episodes" - + f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&isPlayed=true&Fields=ProviderIds,MediaSources", + + f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsPlayed&Fields=ProviderIds,MediaSources", "get", session, frozenset(season_identifiers.items()), ) ) - episodes_tasks.append(episode_task) + in_progress_task = asyncio.ensure_future( + self.query( + f"/Shows/{season_identifiers['show_id']}/Episodes" + + f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsResumable&Fields=ProviderIds,MediaSources", + "get", + session, + frozenset(season_identifiers.items()), + ) + ) + episodes_tasks.append(watched_task) + episodes_tasks.append(in_progress_task) # Retrieve the episodes for each watched season watched_episodes = await asyncio.gather(*episodes_tasks) @@ -268,24 +324,19 @@ async def get_user_library_watched( season_dict["Episodes"] = [] for episode in episodes["Items"]: if ( - episode["UserData"]["Played"] is True - and "MediaSources" in episode + "MediaSources" in episode and episode["MediaSources"] is not {} ): - # Create a dictionary for the episode with its provider IDs and media sources - episode_dict = { - k.lower(): v - for k, v in episode["ProviderIds"].items() - } - episode_dict["title"] = episode["Name"] - episode_dict["locations"] = tuple( - [ - x["Path"].split("/")[-1] - for x in episode["MediaSources"] - ] - ) - # Add the episode dictionary to the season's list of episodes - season_dict["Episodes"].append(episode_dict) + # If watched or watched more than a minute + if ( + episode["UserData"]["Played"] == True + or episode["UserData"]["PlaybackPositionTicks"] + > 600000000 + ): + episode_dict = get_episode_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"] @@ -498,7 +549,7 @@ async def update_user_watched( session, ) for jellyfin_video in jellyfin_search["Items"]: - movie_found = False + movie_status = None if "MediaSources" in jellyfin_video: for movie_location in jellyfin_video["MediaSources"]: @@ -506,10 +557,16 @@ async def update_user_watched( movie_location["Path"].split("/")[-1] in videos_movies_ids["locations"] ): - movie_found = True + for video in videos: + if ( + movie_location["Path"].split("/")[-1] + in video["locations"] + ): + movie_status = video["status"] + break break - if not movie_found: + if not movie_status: for ( movie_provider_source, movie_provider_id, @@ -521,21 +578,38 @@ async def update_user_watched( movie_provider_source.lower() ] ): - movie_found = True + for video in videos: + if ( + movie_provider_id.lower() + in video["ids"][ + movie_provider_source.lower() + ] + ): + movie_status = video["status"] + break break - if movie_found: - jellyfin_video_id = jellyfin_video["Id"] - msg = f"{jellyfin_video['Name']} as watched for {user_name} in {library} for Jellyfin" - if not dryrun: - logger(f"Marking {msg}", 0) - await self.query( - f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", - "post", - session, - ) + if movie_status: + if movie_status["completed"]: + jellyfin_video_id = jellyfin_video["Id"] + msg = f"{jellyfin_video['Name']} as watched for {user_name} in {library} for Jellyfin" + if not dryrun: + logger(f"Marking {msg}", 0) + await self.query( + f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", + "post", + session, + ) + else: + logger(f"Dryrun {msg}", 0) else: - logger(f"Dryrun {msg}", 0) + # TODO add support for partially watched movies + jellyfin_video_id = jellyfin_video["Id"] + msg = f"{jellyfin_video['Name']} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin" + if not dryrun: + logger(f"Marking {msg}", 0) + else: + logger(f"Dryrun {msg}", 0) else: logger( f"Jellyfin: Skipping movie {jellyfin_video['Name']} as it is not in mark list for {user_name}", @@ -562,6 +636,16 @@ async def update_user_watched( in videos_shows_ids["locations"] ): show_found = True + episode_videos = [] + for show, seasons in videos.items(): + show = {k: v for k, v in show} + if ( + jellyfin_show["Path"].split("/")[-1] + in show["locations"] + ): + for season in seasons.values(): + for episode in season: + episode_videos.append(episode) if not show_found: for show_provider_source, show_provider_id in jellyfin_show[ @@ -575,7 +659,18 @@ async def update_user_watched( ] ): show_found = True - break + episode_videos = [] + for show, seasons in videos.items(): + show = {k: v for k, v in show} + if ( + show_provider_id.lower() + in show["ids"][ + show_provider_source.lower() + ] + ): + for season in seasons.values(): + for episode in season: + episode_videos.append(episode) if show_found: logger( @@ -591,7 +686,7 @@ async def update_user_watched( ) for jellyfin_episode in jellyfin_episodes["Items"]: - episode_found = False + episode_status = None if "MediaSources" in jellyfin_episode: for episode_location in jellyfin_episode[ @@ -601,10 +696,18 @@ async def update_user_watched( episode_location["Path"].split("/")[-1] in videos_episodes_ids["locations"] ): - episode_found = True + for episode in episode_videos: + if ( + episode_location["Path"].split("/")[ + -1 + ] + in episode["locations"] + ): + episode_status = episode["status"] + break break - if not episode_found: + if not episode_status: for ( episode_provider_source, episode_provider_id, @@ -619,24 +722,46 @@ async def update_user_watched( episode_provider_source.lower() ] ): - episode_found = True + for episode in episode_videos: + if ( + episode_provider_id.lower() + in episode["ids"][ + episode_provider_source.lower() + ] + ): + episode_status = episode[ + "status" + ] + break break - if episode_found: - jellyfin_episode_id = jellyfin_episode["Id"] - msg = ( - f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['Name']}" - + f" as watched for {user_name} in {library} for Jellyfin" - ) - if not dryrun: - logger(f"Marked {msg}", 0) - await self.query( - f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", - "post", - session, + if episode_status: + if episode_status["completed"]: + jellyfin_episode_id = jellyfin_episode["Id"] + msg = ( + f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']}" + + f" as watched for {user_name} in {library} for Jellyfin" ) + if not dryrun: + logger(f"Marked {msg}", 0) + await self.query( + f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", + "post", + session, + ) + else: + logger(f"Dryrun {msg}", 0) else: - logger(f"Dryrun {msg}", 0) + # TODO add support for partially watched episodes + jellyfin_episode_id = jellyfin_episode["Id"] + msg = ( + f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']}" + + f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin" + ) + if not dryrun: + logger(f"Marked {msg}", 0) + else: + logger(f"Dryrun {msg}", 0) else: logger( f"Jellyfin: Skipping episode {jellyfin_episode['Name']} as it is not in mark list for {user_name}", @@ -663,6 +788,7 @@ async def update_user_watched( f"Jellyfin: Error updating watched for {user_name} in library {library}, {e}", 2, ) + logger(traceback.format_exc(), 2) raise Exception(e) async def update_watched( diff --git a/src/library.py b/src/library.py index 65cb3e3..f63a766 100644 --- a/src/library.py +++ b/src/library.py @@ -163,17 +163,18 @@ def episode_title_dict(user_list: dict): for season in user_list[show]: for episode in user_list[show][season]: for episode_key, episode_value in episode.items(): - if episode_key.lower() not in episode_output_dict: - episode_output_dict[episode_key.lower()] = [] - if episode_key == "locations": - for episode_location in episode_value: + if episode_key != "status": + if episode_key.lower() not in episode_output_dict: + episode_output_dict[episode_key.lower()] = [] + if episode_key == "locations": + for episode_location in episode_value: + episode_output_dict[episode_key.lower()].append( + episode_location + ) + else: episode_output_dict[episode_key.lower()].append( - episode_location + episode_value.lower() ) - else: - episode_output_dict[episode_key.lower()].append( - episode_value.lower() - ) return episode_output_dict except Exception: @@ -186,13 +187,16 @@ def movies_title_dict(user_list: dict): movies_output_dict = {} for movie in user_list: for movie_key, movie_value in movie.items(): - if movie_key.lower() not in movies_output_dict: - movies_output_dict[movie_key.lower()] = [] - if movie_key == "locations": - for movie_location in movie_value: - movies_output_dict[movie_key.lower()].append(movie_location) - else: - movies_output_dict[movie_key.lower()].append(movie_value.lower()) + if movie_key != "status": + if movie_key.lower() not in movies_output_dict: + movies_output_dict[movie_key.lower()] = [] + if movie_key == "locations": + for movie_location in movie_value: + movies_output_dict[movie_key.lower()].append(movie_location) + else: + movies_output_dict[movie_key.lower()].append( + movie_value.lower() + ) return movies_output_dict except Exception: diff --git a/src/plex.py b/src/plex.py index f902742..34fe9e4 100644 --- a/src/plex.py +++ b/src/plex.py @@ -1,5 +1,6 @@ import re, requests, os, traceback from urllib3.poolmanager import PoolManager +from math import floor from plexapi.server import PlexServer from plexapi.myplex import MyPlexAccount @@ -27,14 +28,69 @@ def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs): ) +def get_movie_guids(video, completed=True): + logger(f"Plex: {video.title} {video.guids} {video.locations}", 3) + + movie_guids = {} + try: + for guid in video.guids: + # Extract source and id from guid.id + m = re.match(r"(.*)://(.*)", guid.id) + guid_source, guid_id = m.group(1).lower(), m.group(2) + movie_guids[guid_source] = guid_id + except Exception: + logger(f"Plex: Failed to get guids for {video.title}, Using location only", 1) + + movie_guids["title"] = video.title + movie_guids["locations"] = tuple([x.split("/")[-1] for x in video.locations]) + + movie_guids["status"] = { + "completed": completed, + "time": video.viewOffset, + } + + return movie_guids + + +def get_episode_guids(episode, show, completed=True): + episode_guids_temp = {} + try: + for guid in episode.guids: + # Extract after :// from guid.id + m = re.match(r"(.*)://(.*)", guid.id) + guid_source, guid_id = m.group(1).lower(), m.group(2) + episode_guids_temp[guid_source] = guid_id + except Exception: + logger( + f"Plex: Failed to get guids for {episode.title} in {show.title}, Using location only", + 1, + ) + + episode_guids_temp["locations"] = tuple( + [x.split("/")[-1] for x in episode.locations] + ) + + episode_guids_temp["status"] = { + "completed": completed, + "time": episode.viewOffset, + } + + return episode_guids_temp + + def get_user_library_watched_show(show): try: show_guids = {} - for show_guid in show.guids: - # Extract source and id from guid.id - m = re.match(r"(.*)://(.*)", show_guid.id) - show_guid_source, show_guid_id = m.group(1).lower(), m.group(2) - show_guids[show_guid_source] = show_guid_id + try: + for show_guid in show.guids: + # Extract source and id from guid.id + m = re.match(r"(.*)://(.*)", show_guid.id) + show_guid_source, show_guid_id = m.group(1).lower(), m.group(2) + show_guids[show_guid_source] = show_guid_id + except Exception: + logger( + f"Plex: Failed to get guids for {show.title}, Using location only", 1 + ) show_guids["title"] = show.title show_guids["locations"] = tuple([x.split("/")[-1] for x in show.locations]) @@ -42,30 +98,23 @@ def get_user_library_watched_show(show): # Get all watched episodes for show episode_guids = {} - watched_episodes = show.watched() - for episode in watched_episodes: - episode_guids_temp = {} - try: - if len(episode.guids) > 0: - for guid in episode.guids: - # Extract after :// from guid.id - m = re.match(r"(.*)://(.*)", guid.id) - guid_source, guid_id = m.group(1).lower(), m.group(2) - episode_guids_temp[guid_source] = guid_id - except Exception: - logger( - f"Plex: Failed to get guids for {episode.title} in {show.title}, Using location only", - 1, - ) + watched = show.watched() - episode_guids_temp["locations"] = tuple( - [x.split("/")[-1] for x in episode.locations] - ) + for episode in show.episodes(): + if episode in watched: + if episode.parentTitle not in episode_guids: + episode_guids[episode.parentTitle] = [] - if episode.parentTitle not in episode_guids: - episode_guids[episode.parentTitle] = [] + episode_guids[episode.parentTitle].append( + get_episode_guids(episode, show, completed=True) + ) + elif episode.viewOffset > 0: + if episode.parentTitle not in episode_guids: + episode_guids[episode.parentTitle] = [] - episode_guids[episode.parentTitle].append(episode_guids_temp) + episode_guids[episode.parentTitle].append( + get_episode_guids(episode, show, completed=False) + ) return show_guids, episode_guids @@ -89,32 +138,37 @@ def get_user_library_watched(user, user_plex, library): if library.type == "movie": user_watched[user_name][library.title] = [] + # Get all watched movies for video in library_videos.search(unwatched=False): logger(f"Plex: Adding {video.title} to {user_name} watched list", 3) - logger(f"Plex: {video.title} {video.guids} {video.locations}", 3) - - movie_guids = {} - for guid in video.guids: - # Extract source and id from guid.id - m = re.match(r"(.*)://(.*)", guid.id) - guid_source, guid_id = m.group(1).lower(), m.group(2) - movie_guids[guid_source] = guid_id - - movie_guids["title"] = video.title - movie_guids["locations"] = tuple( - [x.split("/")[-1] for x in video.locations] - ) + + movie_guids = get_movie_guids(video, completed=True) + + user_watched[user_name][library.title].append(movie_guids) + + # Get all partially watched movies greater than 1 minute + for video in library_videos.search(inProgress=True): + if video.viewOffset < 60000: + continue + + logger(f"Plex: Adding {video.title} to {user_name} watched list", 3) + + movie_guids = get_movie_guids(video, completed=False) user_watched[user_name][library.title].append(movie_guids) - logger(f"Plex: Added {movie_guids} to {user_name} watched list", 3) elif library.type == "show": user_watched[user_name][library.title] = {} - shows = library_videos.search(unwatched=False) # Parallelize show processing args = [] - for show in shows: + + # Get all watched shows + for show in library_videos.search(unwatched=False): + args.append([get_user_library_watched_show, show]) + + # Get all partially watched shows + for show in library_videos.search(inProgress=True): args.append([get_user_library_watched_show, show]) for show_guids, episode_guids in future_thread_executor( @@ -144,11 +198,52 @@ def get_user_library_watched(user, user_plex, library): return {} -def find_video(plex_search, video_ids): +def find_video(plex_search, video_ids, videos=None): + try: + for location in plex_search.locations: + if location.split("/")[-1] in video_ids["locations"]: + episode_videos = [] + if videos: + for show, seasons in videos.items(): + show = {k: v for k, v in show} + if location.split("/")[-1] in show["locations"]: + for season in seasons.values(): + for episode in season: + episode_videos.append(episode) + + return True, episode_videos + + for guid in plex_search.guids: + guid_source = re.search(r"(.*)://", guid.id).group(1).lower() + guid_id = re.search(r"://(.*)", guid.id).group(1) + + # If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list + if guid_source in video_ids.keys(): + if guid_id in video_ids[guid_source]: + episode_videos = [] + if videos: + for show, seasons in videos.items(): + show = {k: v for k, v in show} + if guid_source in show["ids"].keys(): + if guid_id in show["ids"][guid_source]: + for season in seasons: + for episode in season: + episode_videos.append(episode) + + return True, episode_videos + + return False, [] + except Exception: + return False, [] + + +def get_video_status(plex_search, video_ids, videos): try: for location in plex_search.locations: if location.split("/")[-1] in video_ids["locations"]: - return True + for video in videos: + if location.split("/")[-1] in video["locations"]: + return video["status"] for guid in plex_search.guids: guid_source = re.search(r"(.*)://", guid.id).group(1).lower() @@ -157,11 +252,14 @@ def find_video(plex_search, video_ids): # If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list if guid_source in video_ids.keys(): if guid_id in video_ids[guid_source]: - return True + for video in videos: + if guid_source in video["ids"].keys(): + if guid_id in video["ids"][guid_source]: + return video["status"] - return False + return None except Exception: - return False + return None def update_user_watched(user, user_plex, library, videos, dryrun): @@ -180,13 +278,26 @@ def update_user_watched(user, user_plex, library, videos, dryrun): library_videos = user_plex.library.section(library) if videos_movies_ids: for movies_search in library_videos.search(unwatched=True): - if find_video(movies_search, videos_movies_ids): - msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex" - if not dryrun: - logger(f"Marked {msg}", 0) - movies_search.markWatched() - else: - logger(f"Dryrun {msg}", 0) + video_status = get_video_status( + movies_search, videos_movies_ids, videos + ) + if video_status: + if video_status["completed"]: + msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex" + if not dryrun: + logger(f"Marked {msg}", 0) + movies_search.markWatched() + else: + logger(f"Dryrun {msg}", 0) + elif video_status["time"] > 60_000: + # Only mark partially watched if watched for more than 1 minute + # TODO add support for partially watched movies + msg = f"{movies_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex" + if not dryrun: + logger(f"Marked {msg}", 0) + + else: + logger(f"Dryrun {msg}", 0) else: logger( f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}", @@ -195,15 +306,29 @@ def update_user_watched(user, user_plex, library, videos, dryrun): if videos_shows_ids and videos_episodes_ids: for show_search in library_videos.search(unwatched=True): - if find_video(show_search, videos_shows_ids): + show_found, episode_videos = find_video( + show_search, videos_shows_ids, videos + ) + if show_found: for episode_search in show_search.episodes(): - if find_video(episode_search, videos_episodes_ids): - msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex" - if not dryrun: - logger(f"Marked {msg}", 0) - episode_search.markWatched() + video_status = get_video_status( + episode_search, videos_episodes_ids, episode_videos + ) + if video_status: + if video_status["completed"]: + msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex" + if not dryrun: + logger(f"Marked {msg}", 0) + episode_search.markWatched() + else: + logger(f"Dryrun {msg}", 0) else: - logger(f"Dryrun {msg}", 0) + # TODO add support for partially watched episodes + msg = f"{show_search.title} {episode_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex" + if not dryrun: + logger(f"Marked {msg}", 0) + else: + logger(f"Dryrun {msg}", 0) else: logger( f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}", From 0774735f0ffdba4cd90ae9d341eca770e10c9774 Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Thu, 23 Mar 2023 22:49:14 -0600 Subject: [PATCH 02/12] Plex: Add title to episode_guids --- src/plex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plex.py b/src/plex.py index 34fe9e4..2ebc2e3 100644 --- a/src/plex.py +++ b/src/plex.py @@ -66,6 +66,7 @@ def get_episode_guids(episode, show, completed=True): 1, ) + episode_guids_temp["title"] = episode.title episode_guids_temp["locations"] = tuple( [x.split("/")[-1] for x in episode.locations] ) From 8d53b5b8c0a6fc3ef6955a0de647a997bd5d737a Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Thu, 23 Mar 2023 22:50:13 -0600 Subject: [PATCH 03/12] Take into account comparing two partially watched/one watched video --- src/library.py | 46 +++++++--- src/watched.py | 102 ++++++++++++++++----- test/test_library.py | 6 ++ test/test_watched.py | 207 +++++++++++++++++++++++++++++-------------- 4 files changed, 258 insertions(+), 103 deletions(-) diff --git a/src/library.py b/src/library.py index f63a766..36d5060 100644 --- a/src/library.py +++ b/src/library.py @@ -166,15 +166,26 @@ def episode_title_dict(user_list: dict): if episode_key != "status": if episode_key.lower() not in episode_output_dict: episode_output_dict[episode_key.lower()] = [] - if episode_key == "locations": - for episode_location in episode_value: - episode_output_dict[episode_key.lower()].append( - episode_location - ) - else: + + if "completed" not in episode_output_dict: + episode_output_dict["completed"] = [] + if "time" not in episode_output_dict: + episode_output_dict["time"] = [] + + if episode_key == "locations": + for episode_location in episode_value: episode_output_dict[episode_key.lower()].append( - episode_value.lower() + episode_location ) + elif episode_key == "status": + episode_output_dict["completed"].append( + episode_value["completed"] + ) + episode_output_dict["time"].append(episode_value["time"]) + else: + episode_output_dict[episode_key.lower()].append( + episode_value.lower() + ) return episode_output_dict except Exception: @@ -190,13 +201,20 @@ def movies_title_dict(user_list: dict): if movie_key != "status": if movie_key.lower() not in movies_output_dict: movies_output_dict[movie_key.lower()] = [] - if movie_key == "locations": - for movie_location in movie_value: - movies_output_dict[movie_key.lower()].append(movie_location) - else: - movies_output_dict[movie_key.lower()].append( - movie_value.lower() - ) + + if "completed" not in movies_output_dict: + movies_output_dict["completed"] = [] + if "time" not in movies_output_dict: + movies_output_dict["time"] = [] + + if movie_key == "locations": + for movie_location in movie_value: + movies_output_dict[movie_key.lower()].append(movie_location) + elif movie_key == "status": + movies_output_dict["completed"].append(movie_value["completed"]) + movies_output_dict["time"].append(movie_value["time"]) + else: + movies_output_dict[movie_key.lower()].append(movie_value.lower()) return movies_output_dict except Exception: diff --git a/src/watched.py b/src/watched.py index 1cd3a03..28d2cc8 100644 --- a/src/watched.py +++ b/src/watched.py @@ -29,6 +29,48 @@ def combine_watched_dicts(dicts: list): return combined_dict +def check_remove_entry(video, library, video_index, library_watched_list_2): + if video_index is not None: + if ( + library_watched_list_2["completed"][video_index] + == video["status"]["completed"] + ) and (library_watched_list_2["time"][video_index] == video["status"]["time"]): + logger( + f"Removing {video['title']} from {library} due to exact match", + 3, + ) + return True + elif ( + library_watched_list_2["completed"][video_index] == True + and video["status"]["completed"] == False + ): + logger( + f"Removing {video['title']} from {library} due to being complete in one library and not the other", + 3, + ) + return True + elif ( + library_watched_list_2["completed"][video_index] == False + and video["status"]["completed"] == False + ) and (video["status"]["time"] < library_watched_list_2["time"][video_index]): + logger( + f"Removing {video['title']} from {library} due to more time watched in one library than the other", + 3, + ) + return True + elif ( + library_watched_list_2["completed"][video_index] == True + and video["status"]["completed"] == True + ): + logger( + f"Removing {video['title']} from {library} due to being complete in both libraries", + 3, + ) + return True + + return False + + def cleanup_watched( watched_list_1, watched_list_2, user_mapping=None, library_mapping=None ): @@ -60,9 +102,17 @@ def cleanup_watched( # Movies if isinstance(watched_list_1[user_1][library_1], list): for movie in watched_list_1[user_1][library_1]: - if is_movie_in_dict(movie, movies_watched_list_2_keys_dict): - logger(f"Removing {movie} from {library_1}", 3) - modified_watched_list_1[user_1][library_1].remove(movie) + movie_index = get_movie_index_in_dict( + movie, movies_watched_list_2_keys_dict + ) + if movie_index is not None: + if check_remove_entry( + movie, + library_1, + movie_index, + movies_watched_list_2_keys_dict, + ): + modified_watched_list_1[user_1][library_1].remove(movie) # TV Shows elif isinstance(watched_list_1[user_1][library_1], dict): @@ -72,19 +122,16 @@ def cleanup_watched( for episode in watched_list_1[user_1][library_1][show_key_1][ season ]: - if is_episode_in_dict( + episode_index = get_episode_index_in_dict( episode, episode_watched_list_2_keys_dict - ): - if ( - episode - in modified_watched_list_1[user_1][library_1][ - show_key_1 - ][season] + ) + if episode_index is not None: + if check_remove_entry( + episode, + library_1, + episode_index, + episode_watched_list_2_keys_dict, ): - logger( - f"Removing {episode} from {show_key_dict['title']}", - 3, - ) modified_watched_list_1[user_1][library_1][ show_key_1 ][season].remove(episode) @@ -148,7 +195,7 @@ def get_other(watched_list, object_1, object_2): return None -def is_movie_in_dict(movie, movies_watched_list_2_keys_dict): +def get_movie_index_in_dict(movie, movies_watched_list_2_keys_dict): # Iterate through the keys and values of the movie dictionary for movie_key, movie_value in movie.items(): # If the key is "locations", check if the "locations" key is present in the movies_watched_list_2_keys_dict dictionary @@ -156,21 +203,24 @@ def is_movie_in_dict(movie, movies_watched_list_2_keys_dict): if "locations" in movies_watched_list_2_keys_dict.keys(): # Iterate through the locations in the movie dictionary for location in movie_value: - # If the location is in the movies_watched_list_2_keys_dict dictionary, return True + # If the location is in the movies_watched_list_2_keys_dict dictionary, return index of the key if location in movies_watched_list_2_keys_dict["locations"]: - return True + return movies_watched_list_2_keys_dict["locations"].index( + location + ) + # If the key is not "locations", check if the movie_key is present in the movies_watched_list_2_keys_dict dictionary else: if movie_key in movies_watched_list_2_keys_dict.keys(): # If the movie_value is in the movies_watched_list_2_keys_dict dictionary, return True if movie_value in movies_watched_list_2_keys_dict[movie_key]: - return True + return movies_watched_list_2_keys_dict[movie_key].index(movie_value) # If the loop completes without finding a match, return False - return False + return None -def is_episode_in_dict(episode, episode_watched_list_2_keys_dict): +def get_episode_index_in_dict(episode, episode_watched_list_2_keys_dict): # Iterate through the keys and values of the episode dictionary for episode_key, episode_value in episode.items(): # If the key is "locations", check if the "locations" key is present in the episode_watched_list_2_keys_dict dictionary @@ -178,15 +228,19 @@ def is_episode_in_dict(episode, episode_watched_list_2_keys_dict): if "locations" in episode_watched_list_2_keys_dict.keys(): # Iterate through the locations in the episode dictionary for location in episode_value: - # If the location is in the episode_watched_list_2_keys_dict dictionary, return True + # If the location is in the episode_watched_list_2_keys_dict dictionary, return index of the key if location in episode_watched_list_2_keys_dict["locations"]: - return True + return episode_watched_list_2_keys_dict["locations"].index( + location + ) # If the key is not "locations", check if the episode_key is present in the episode_watched_list_2_keys_dict dictionary else: if episode_key in episode_watched_list_2_keys_dict.keys(): # If the episode_value is in the episode_watched_list_2_keys_dict dictionary, return True if episode_value in episode_watched_list_2_keys_dict[episode_key]: - return True + return episode_watched_list_2_keys_dict[episode_key].index( + episode_value + ) # If the loop completes without finding a match, return False - return False + return None diff --git a/test/test_library.py b/test/test_library.py index 2ffd1da..e9fd02e 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -51,6 +51,7 @@ "locations": ( "The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv", ), + "status": {"completed": True, "time": 0}, } ] } @@ -61,6 +62,7 @@ "imdb": "tt2380307", "tmdb": "354912", "locations": ("Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv"), + "status": {"completed": True, "time": 0}, } ] @@ -77,12 +79,16 @@ ], "tmdb": ["2181581"], "tvdb": ["8444132"], + "completed": [True], + "time": [0], } movie_titles = { "imdb": ["tt2380307"], "locations": ["Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv"], "title": ["coco"], "tmdb": ["354912"], + "completed": [True], + "time": [0], } diff --git a/test/test_watched.py b/test/test_watched.py index 8257457..105541a 100644 --- a/test/test_watched.py +++ b/test/test_watched.py @@ -30,42 +30,43 @@ "imdb": "tt0550489", "tmdb": "282843", "tvdb": "176357", + "title": "Extreme Aggressor", "locations": ( "Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", ), + "status": {"completed": True, "time": 0}, }, { "imdb": "tt0550487", "tmdb": "282861", "tvdb": "300385", + "title": "Compulsion", "locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",), + "status": {"completed": True, "time": 0}, }, ] }, frozenset({("title", "Test"), ("locations", ("Test",))}): { "Season 1": [ - {"locations": ("Test S01E01.mkv",)}, - {"locations": ("Test S01E02.mkv",)}, + { + "title": "S01E01", + "locations": ("Test S01E01.mkv",), + "status": {"completed": True, "time": 0}, + }, + { + "title": "S01E02", + "locations": ("Test S01E02.mkv",), + "status": {"completed": True, "time": 0}, + }, + { + "title": "S01E04", + "locations": ("Test S01E04.mkv",), + "status": {"completed": False, "time": 5}, + }, ] }, } -movies_watched_list_1 = [ - { - "imdb": "tt2380307", - "tmdb": "354912", - "title": "Coco", - "locations": ("Coco (2017) Remux-1080p.mkv",), - }, - { - "tmdbcollection": "448150", - "imdb": "tt1431045", - "tmdb": "293660", - "title": "Deadpool", - "locations": ("Deadpool (2016) Remux-1080p.mkv",), - }, -] - tv_shows_watched_list_2 = { frozenset( { @@ -81,44 +82,44 @@ "imdb": "tt0550487", "tmdb": "282861", "tvdb": "300385", + "title": "Compulsion", "locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",), + "status": {"completed": True, "time": 0}, }, { "imdb": "tt0550498", "tmdb": "282865", "tvdb": "300474", + "title": "Won't Get Fooled Again", "locations": ( "Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv", ), + "status": {"completed": True, "time": 0}, }, ] }, frozenset({("title", "Test"), ("locations", ("Test",))}): { "Season 1": [ - {"locations": ("Test S01E02.mkv",)}, - {"locations": ("Test S01E03.mkv",)}, + { + "title": "S01E02", + "locations": ("Test S01E02.mkv",), + "status": {"completed": False, "time": 10}, + }, + { + "title": "S01E03", + "locations": ("Test S01E03.mkv",), + "status": {"completed": True, "time": 0}, + }, + { + "title": "S01E04", + "locations": ("Test S01E04.mkv",), + "status": {"completed": False, "time": 10}, + }, ] }, } -movies_watched_list_2 = [ - { - "imdb": "tt2380307", - "tmdb": "354912", - "title": "Coco", - "locations": ("Coco (2017) Remux-1080p.mkv",), - }, - { - "imdb": "tt0384793", - "tmdb": "9788", - "tvdb": "9103", - "title": "Accepted", - "locations": ("Accepted (2006) Remux-1080p.mkv",), - }, -] - -# Test to see if objects get deleted all the way up to the root. -tv_shows_2_watched_list_1 = { +expected_tv_show_watched_list_1 = { frozenset( { ("tvdb", "75710"), @@ -133,15 +134,31 @@ "imdb": "tt0550489", "tmdb": "282843", "tvdb": "176357", + "title": "Extreme Aggressor", "locations": ( "Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", ), + "status": {"completed": True, "time": 0}, + } + ] + }, + frozenset({("title", "Test"), ("locations", ("Test",))}): { + "Season 1": [ + { + "title": "S01E01", + "locations": ("Test S01E01.mkv",), + "status": {"completed": True, "time": 0}, + }, + { + "title": "S01E02", + "locations": ("Test S01E02.mkv",), + "status": {"completed": True, "time": 0}, }, ] - } + }, } -expected_tv_show_watched_list_1 = { +expected_tv_show_watched_list_2 = { frozenset( { ("tvdb", "75710"), @@ -153,20 +170,70 @@ ): { "Season 1": [ { - "imdb": "tt0550489", - "tmdb": "282843", - "tvdb": "176357", + "imdb": "tt0550498", + "tmdb": "282865", + "tvdb": "300474", + "title": "Won't Get Fooled Again", "locations": ( - "Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", + "Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv", ), + "status": {"completed": True, "time": 0}, } ] }, frozenset({("title", "Test"), ("locations", ("Test",))}): { - "Season 1": [{"locations": ("Test S01E01.mkv",)}] + "Season 1": [ + { + "title": "S01E03", + "locations": ("Test S01E03.mkv",), + "status": {"completed": True, "time": 0}, + }, + { + "title": "S01E04", + "locations": ("Test S01E04.mkv",), + "status": {"completed": False, "time": 10}, + }, + ] }, } +movies_watched_list_1 = [ + { + "imdb": "tt2380307", + "tmdb": "354912", + "title": "Coco", + "locations": ("Coco (2017) Remux-1080p.mkv",), + "status": {"completed": True, "time": 0}, + }, + { + "tmdbcollection": "448150", + "imdb": "tt1431045", + "tmdb": "293660", + "title": "Deadpool", + "locations": ("Deadpool (2016) Remux-1080p.mkv",), + "status": {"completed": True, "time": 0}, + }, +] + +movies_watched_list_2 = [ + { + "imdb": "tt2380307", + "tmdb": "354912", + "title": "Coco", + "locations": ("Coco (2017) Remux-1080p.mkv",), + "status": {"completed": True, "time": 0}, + }, + { + "imdb": "tt0384793", + "tmdb": "9788", + "tvdb": "9103", + "title": "Accepted", + "locations": ("Accepted (2006) Remux-1080p.mkv",), + "status": {"completed": True, "time": 0}, + }, +] + + expected_movie_watched_list_1 = [ { "tmdbcollection": "448150", @@ -174,10 +241,23 @@ "tmdb": "293660", "title": "Deadpool", "locations": ("Deadpool (2016) Remux-1080p.mkv",), + "status": {"completed": True, "time": 0}, } ] -expected_tv_show_watched_list_2 = { +expected_movie_watched_list_2 = [ + { + "imdb": "tt0384793", + "tmdb": "9788", + "tvdb": "9103", + "title": "Accepted", + "locations": ("Accepted (2006) Remux-1080p.mkv",), + "status": {"completed": True, "time": 0}, + } +] + +# Test to see if objects get deleted all the way up to the root. +tv_shows_2_watched_list_1 = { frozenset( { ("tvdb", "75710"), @@ -189,29 +269,18 @@ ): { "Season 1": [ { - "imdb": "tt0550498", - "tmdb": "282865", - "tvdb": "300474", + "imdb": "tt0550489", + "tmdb": "282843", + "tvdb": "176357", + "title": "Extreme Aggressor", "locations": ( - "Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv", + "Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", ), - } + "status": {"completed": True, "time": 0}, + }, ] - }, - frozenset({("title", "Test"), ("locations", ("Test",))}): { - "Season 1": [{"locations": ("Test S01E03.mkv",)}] - }, -} - -expected_movie_watched_list_2 = [ - { - "imdb": "tt0384793", - "tmdb": "9788", - "tvdb": "9103", - "title": "Accepted", - "locations": ("Accepted (2006) Remux-1080p.mkv",), } -] +} def test_simple_cleanup_watched(): @@ -311,18 +380,21 @@ def test_combine_watched_dicts(): "tmdb": "12429", "imdb": "tt0876563", "locations": ("Ponyo (2008) Bluray-1080p.mkv",), + "status": {"completed": True, "time": 0}, }, { "title": "Spirited Away", "tmdb": "129", "imdb": "tt0245429", "locations": ("Spirited Away (2001) Bluray-1080p.mkv",), + "status": {"completed": True, "time": 0}, }, { "title": "Castle in the Sky", "tmdb": "10515", "imdb": "tt0092067", "locations": ("Castle in the Sky (1986) Bluray-1080p.mkv",), + "status": {"completed": True, "time": 0}, }, ] } @@ -349,6 +421,7 @@ def test_combine_watched_dicts(): "locations": ( "11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv", ), + "status": {"completed": True, "time": 0}, } ] } @@ -365,18 +438,21 @@ def test_combine_watched_dicts(): "tmdb": "12429", "imdb": "tt0876563", "locations": ("Ponyo (2008) Bluray-1080p.mkv",), + "status": {"completed": True, "time": 0}, }, { "title": "Spirited Away", "tmdb": "129", "imdb": "tt0245429", "locations": ("Spirited Away (2001) Bluray-1080p.mkv",), + "status": {"completed": True, "time": 0}, }, { "title": "Castle in the Sky", "tmdb": "10515", "imdb": "tt0092067", "locations": ("Castle in the Sky (1986) Bluray-1080p.mkv",), + "status": {"completed": True, "time": 0}, }, ], "Anime Shows": {}, @@ -399,6 +475,7 @@ def test_combine_watched_dicts(): "locations": ( "11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv", ), + "status": {"completed": True, "time": 0}, } ] } From 25fe4267200a1ea16598bf421fc4e470a2136125 Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Sun, 26 Mar 2023 23:55:56 -0600 Subject: [PATCH 04/12] Plex: Implement partial play syncing --- src/plex.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plex.py b/src/plex.py index 2ebc2e3..0f1e8f0 100644 --- a/src/plex.py +++ b/src/plex.py @@ -291,12 +291,10 @@ def update_user_watched(user, user_plex, library, videos, dryrun): else: logger(f"Dryrun {msg}", 0) elif video_status["time"] > 60_000: - # Only mark partially watched if watched for more than 1 minute - # TODO add support for partially watched movies msg = f"{movies_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex" if not dryrun: logger(f"Marked {msg}", 0) - + movies_search.updateProgress(video_status["time"]) else: logger(f"Dryrun {msg}", 0) else: @@ -324,10 +322,10 @@ def update_user_watched(user, user_plex, library, videos, dryrun): else: logger(f"Dryrun {msg}", 0) else: - # TODO add support for partially watched episodes msg = f"{show_search.title} {episode_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex" if not dryrun: logger(f"Marked {msg}", 0) + episode_search.updateProgress(video_status["time"]) else: logger(f"Dryrun {msg}", 0) else: From 658361383a161c215717dcc5a25f29be9d235c8d Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Fri, 7 Apr 2023 13:41:39 -0600 Subject: [PATCH 05/12] Update README.md --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index a17e56e..3059b5a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,35 @@ Sync watched between jellyfin and plex locally 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. +## Features + +### Plex +- [x] Match via Filenames +- [x] Match via provider ids +- [x] Map usersnames +- [x] Use single login +- [x] One Way/Multi Way sync +- [x] Sync Watched +- [x] Sync Inprogress + +### Jellyfin +- [x] Match via Filenames +- [x] Match via provider ids +- [x] Map usersnames +- [x] Use single login +- [x] One Way/Multi Way sync +- [x] Sync Watched +- [ ] Sync Inprogress + +### Emby +- [ ] Match via Filenames +- [ ] Match via provider ids +- [ ] Map usersnames +- [ ] Use single login +- [ ] One Way/Multi Way sync +- [ ] Sync Watched +- [ ] Sync Inprogress + ## Configuration ```bash From fffb04728a3d2af9fff2f4a747c5240215b176a1 Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Fri, 7 Apr 2023 15:17:00 -0600 Subject: [PATCH 06/12] Jellfyin: Fix issue with ids. Do not show marked for partial --- src/jellyfin.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/jellyfin.py b/src/jellyfin.py index c938b11..862e410 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -607,9 +607,11 @@ async def update_user_watched( jellyfin_video_id = jellyfin_video["Id"] msg = f"{jellyfin_video['Name']} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin" if not dryrun: - logger(f"Marking {msg}", 0) + pass + # logger(f"Marked {msg}", 0) else: - logger(f"Dryrun {msg}", 0) + pass + # logger(f"Dryrun {msg}", 0) else: logger( f"Jellyfin: Skipping movie {jellyfin_video['Name']} as it is not in mark list for {user_name}", @@ -637,6 +639,7 @@ async def update_user_watched( ): show_found = True episode_videos = [] + for show, seasons in videos.items(): show = {k: v for k, v in show} if ( @@ -725,7 +728,7 @@ async def update_user_watched( for episode in episode_videos: if ( episode_provider_id.lower() - in episode["ids"][ + in episode[ episode_provider_source.lower() ] ): @@ -759,9 +762,11 @@ async def update_user_watched( + f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin" ) if not dryrun: - logger(f"Marked {msg}", 0) + pass + # logger(f"Marked {msg}", 0) else: - logger(f"Dryrun {msg}", 0) + pass + # logger(f"Dryrun {msg}", 0) else: logger( f"Jellyfin: Skipping episode {jellyfin_episode['Name']} as it is not in mark list for {user_name}", From a178d230de0c90a6de5c10265545a9075d5ebbbb Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Fri, 7 Apr 2023 17:31:25 -0600 Subject: [PATCH 07/12] Jellfyfin: Fix more issues with ids --- src/jellyfin.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/jellyfin.py b/src/jellyfin.py index 862e410..70520b4 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -581,9 +581,7 @@ async def update_user_watched( for video in videos: if ( movie_provider_id.lower() - in video["ids"][ - movie_provider_source.lower() - ] + in video[movie_provider_source.lower()] ): movie_status = video["status"] break @@ -667,9 +665,7 @@ async def update_user_watched( show = {k: v for k, v in show} if ( show_provider_id.lower() - in show["ids"][ - show_provider_source.lower() - ] + in show[show_provider_source.lower()] ): for season in seasons.values(): for episode in season: From 916b16b12c8c3f4e1404ea22374489b71f6b1f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Morantes?= Date: Mon, 10 Apr 2023 12:57:03 -0300 Subject: [PATCH 08/12] Add "RUN_ONLY_ONCE" option --- .env.sample | 5 ++++- src/main.py | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index c8ff463..2f231ea 100644 --- a/.env.sample +++ b/.env.sample @@ -9,6 +9,9 @@ DEBUG = "False" ## 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 = "False" + ## How often to run the script in seconds SLEEP_DURATION = "3600" @@ -27,7 +30,7 @@ LOGFILE = "log.log" ## Comma separated for multiple options #BLACKLIST_LIBRARY = "" #WHITELIST_LIBRARY = "" -#BLACKLIST_LIBRARY_TYPE = "" +#BLACKLIST_LIBRARY_TYPE = "" #WHITELIST_LIBRARY_TYPE = "" #BLACKLIST_USERS = "" WHITELIST_USERS = "testuser1,testuser2" diff --git a/src/main.py b/src/main.py index ccdbf6c..f308310 100644 --- a/src/main.py +++ b/src/main.py @@ -365,6 +365,7 @@ def main_loop(): def main(): + run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False")) sleep_duration = float(os.getenv("SLEEP_DURATION", "3600")) times = [] while True: @@ -377,6 +378,9 @@ def main(): if len(times) > 0: logger(f"Average time: {sum(times) / len(times)}", 0) + if run_only_once: + break + logger(f"Looping in {sleep_duration}") sleep(sleep_duration) @@ -389,6 +393,9 @@ def main(): logger(traceback.format_exc(), 2) + if run_only_once: + break + logger(f"Retrying in {sleep_duration}", log_type=0) sleep(sleep_duration) From 68e3f25ba40c9764684fc59c2900c5131b36dce7 Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Mon, 10 Apr 2023 15:17:16 -0600 Subject: [PATCH 09/12] Fix indexing --- src/functions.py | 8 +++++ src/jellyfin.py | 51 ++++++++++++++++++++------------ src/library.py | 70 +++++++++++++++++++++++++++++++------------- src/plex.py | 21 ++++++++++--- src/watched.py | 30 ++++++++----------- test/test_library.py | 19 ++++++++---- 6 files changed, 134 insertions(+), 65 deletions(-) diff --git a/src/functions.py b/src/functions.py index 5f5ffcd..2e7529b 100644 --- a/src/functions.py +++ b/src/functions.py @@ -39,6 +39,14 @@ def str_to_bool(value: any) -> bool: return str(value).lower() in ("y", "yes", "t", "true", "on", "1") +# Search for nested element in list +def contains_nested(element, lst): + for i, item in enumerate(lst): + if element in item: + return i + return None + + # Get mapped value def search_mapping(dictionary: dict, key_value: str): if key_value in dictionary.keys(): diff --git a/src/jellyfin.py b/src/jellyfin.py index 70520b4..b3d84c6 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -1,10 +1,7 @@ import asyncio, aiohttp, traceback from math import floor -from src.functions import ( - logger, - search_mapping, -) +from src.functions import logger, search_mapping, contains_nested from src.library import ( check_skip_logic, generate_library_guids_dict, @@ -554,13 +551,19 @@ async def update_user_watched( if "MediaSources" in jellyfin_video: for movie_location in jellyfin_video["MediaSources"]: if ( - movie_location["Path"].split("/")[-1] - in videos_movies_ids["locations"] + contains_nested( + movie_location["Path"].split("/")[-1], + videos_movies_ids["locations"], + ) + is not None ): for video in videos: if ( - movie_location["Path"].split("/")[-1] - in video["locations"] + contains_nested( + movie_location["Path"].split("/")[-1], + video["locations"], + ) + is not None ): movie_status = video["status"] break @@ -632,8 +635,11 @@ async def update_user_watched( if "Path" in jellyfin_show: if ( - jellyfin_show["Path"].split("/")[-1] - in videos_shows_ids["locations"] + contains_nested( + jellyfin_show["Path"].split("/")[-1], + videos_shows_ids["locations"], + ) + is not None ): show_found = True episode_videos = [] @@ -641,8 +647,11 @@ async def update_user_watched( for show, seasons in videos.items(): show = {k: v for k, v in show} if ( - jellyfin_show["Path"].split("/")[-1] - in show["locations"] + contains_nested( + jellyfin_show["Path"].split("/")[-1], + show["locations"], + ) + is not None ): for season in seasons.values(): for episode in season: @@ -692,15 +701,21 @@ async def update_user_watched( "MediaSources" ]: if ( - episode_location["Path"].split("/")[-1] - in videos_episodes_ids["locations"] + contains_nested( + episode_location["Path"].split("/")[-1], + videos_episodes_ids["locations"], + ) + is not None ): for episode in episode_videos: if ( - episode_location["Path"].split("/")[ - -1 - ] - in episode["locations"] + contains_nested( + episode_location["Path"].split( + "/" + )[-1], + episode["locations"], + ) + is not None ): episode_status = episode["status"] break diff --git a/src/library.py b/src/library.py index 36d5060..20792bc 100644 --- a/src/library.py +++ b/src/library.py @@ -132,6 +132,8 @@ def check_whitelist_logic( def show_title_dict(user_list: dict): try: show_output_dict = {} + show_output_dict["locations"] = [] + show_counter = 0 # Initialize a counter for the current show position show_output_keys = user_list.keys() show_output_keys = [dict(x) for x in list(show_output_keys)] @@ -141,15 +143,19 @@ def show_title_dict(user_list: dict): if provider_key.lower() == "title": continue if provider_key.lower() not in show_output_dict: - show_output_dict[provider_key.lower()] = [] + show_output_dict[provider_key.lower()] = [None] * show_counter if provider_key.lower() == "locations": - for show_location in provider_value: - show_output_dict[provider_key.lower()].append(show_location) + show_output_dict[provider_key.lower()].append(provider_value) else: show_output_dict[provider_key.lower()].append( provider_value.lower() ) + show_counter += 1 + for key in show_output_dict: + if len(show_output_dict[key]) < show_counter: + show_output_dict[key].append(None) + return show_output_dict except Exception: logger("Generating show_output_dict failed, skipping", 1) @@ -159,34 +165,54 @@ def show_title_dict(user_list: dict): def episode_title_dict(user_list: dict): try: episode_output_dict = {} + episode_output_dict["completed"] = [] + episode_output_dict["time"] = [] + episode_output_dict["locations"] = [] + episode_counter = 0 # Initialize a counter for the current episode position + + # Iterate through the shows, seasons, and episodes in user_list for show in user_list: for season in user_list[show]: for episode in user_list[show][season]: + + # Iterate through the keys and values in each episode for episode_key, episode_value in episode.items(): + + # If the key is not "status", add the key to episode_output_dict if it doesn't exist if episode_key != "status": if episode_key.lower() not in episode_output_dict: - episode_output_dict[episode_key.lower()] = [] - - if "completed" not in episode_output_dict: - episode_output_dict["completed"] = [] - if "time" not in episode_output_dict: - episode_output_dict["time"] = [] + # Initialize the list with None values up to the current episode position + episode_output_dict[episode_key.lower()] = [ + None + ] * episode_counter + # If the key is "locations", append each location to the list if episode_key == "locations": - for episode_location in episode_value: - episode_output_dict[episode_key.lower()].append( - episode_location - ) + episode_output_dict[episode_key.lower()].append( + episode_value + ) + + # If the key is "status", append the "completed" and "time" values elif episode_key == "status": episode_output_dict["completed"].append( episode_value["completed"] ) episode_output_dict["time"].append(episode_value["time"]) + + # For other keys, append the value to the list else: episode_output_dict[episode_key.lower()].append( episode_value.lower() ) + # Increment the episode_counter + episode_counter += 1 + + # Extend the lists in episode_output_dict with None values to match the current episode_counter + for key in episode_output_dict: + if len(episode_output_dict[key]) < episode_counter: + episode_output_dict[key].append(None) + return episode_output_dict except Exception: logger("Generating episode_output_dict failed, skipping", 1) @@ -196,26 +222,30 @@ def episode_title_dict(user_list: dict): def movies_title_dict(user_list: dict): try: movies_output_dict = {} + movies_output_dict["completed"] = [] + movies_output_dict["time"] = [] + movies_output_dict["locations"] = [] + movie_counter = 0 # Initialize a counter for the current movie position + for movie in user_list: for movie_key, movie_value in movie.items(): if movie_key != "status": if movie_key.lower() not in movies_output_dict: movies_output_dict[movie_key.lower()] = [] - if "completed" not in movies_output_dict: - movies_output_dict["completed"] = [] - if "time" not in movies_output_dict: - movies_output_dict["time"] = [] - if movie_key == "locations": - for movie_location in movie_value: - movies_output_dict[movie_key.lower()].append(movie_location) + movies_output_dict[movie_key.lower()].append(movie_value) elif movie_key == "status": movies_output_dict["completed"].append(movie_value["completed"]) movies_output_dict["time"].append(movie_value["time"]) else: movies_output_dict[movie_key.lower()].append(movie_value.lower()) + movie_counter += 1 + for key in movies_output_dict: + if len(movies_output_dict[key]) < movie_counter: + movies_output_dict[key].append(None) + return movies_output_dict except Exception: logger("Generating movies_output_dict failed, skipping", 1) diff --git a/src/plex.py b/src/plex.py index 0f1e8f0..889078d 100644 --- a/src/plex.py +++ b/src/plex.py @@ -9,6 +9,7 @@ logger, search_mapping, future_thread_executor, + contains_nested, ) from src.library import ( check_skip_logic, @@ -202,12 +203,18 @@ def get_user_library_watched(user, user_plex, library): def find_video(plex_search, video_ids, videos=None): try: for location in plex_search.locations: - if location.split("/")[-1] in video_ids["locations"]: + if ( + contains_nested(location.split("/")[-1], video_ids["locations"]) + is not None + ): episode_videos = [] if videos: for show, seasons in videos.items(): show = {k: v for k, v in show} - if location.split("/")[-1] in show["locations"]: + if ( + contains_nested(location.split("/")[-1], show["locations"]) + is not None + ): for season in seasons.values(): for episode in season: episode_videos.append(episode) @@ -241,9 +248,15 @@ def find_video(plex_search, video_ids, videos=None): def get_video_status(plex_search, video_ids, videos): try: for location in plex_search.locations: - if location.split("/")[-1] in video_ids["locations"]: + if ( + contains_nested(location.split("/")[-1], video_ids["locations"]) + is not None + ): for video in videos: - if location.split("/")[-1] in video["locations"]: + if ( + contains_nested(location.split("/")[-1], video["locations"]) + is not None + ): return video["status"] for guid in plex_search.guids: diff --git a/src/watched.py b/src/watched.py index 28d2cc8..2498eae 100644 --- a/src/watched.py +++ b/src/watched.py @@ -1,9 +1,6 @@ import copy -from src.functions import ( - logger, - search_mapping, -) +from src.functions import logger, search_mapping, contains_nested from src.library import generate_library_guids_dict @@ -118,6 +115,7 @@ def cleanup_watched( elif isinstance(watched_list_1[user_1][library_1], dict): for show_key_1 in watched_list_1[user_1][library_1].keys(): show_key_dict = dict(show_key_1) + for season in watched_list_1[user_1][library_1][show_key_1]: for episode in watched_list_1[user_1][library_1][show_key_1][ season @@ -204,10 +202,9 @@ def get_movie_index_in_dict(movie, movies_watched_list_2_keys_dict): # Iterate through the locations in the movie dictionary for location in movie_value: # If the location is in the movies_watched_list_2_keys_dict dictionary, return index of the key - if location in movies_watched_list_2_keys_dict["locations"]: - return movies_watched_list_2_keys_dict["locations"].index( - location - ) + return contains_nested( + location, movies_watched_list_2_keys_dict["locations"] + ) # If the key is not "locations", check if the movie_key is present in the movies_watched_list_2_keys_dict dictionary else: @@ -223,19 +220,16 @@ def get_movie_index_in_dict(movie, movies_watched_list_2_keys_dict): def get_episode_index_in_dict(episode, episode_watched_list_2_keys_dict): # Iterate through the keys and values of the episode dictionary for episode_key, episode_value in episode.items(): - # If the key is "locations", check if the "locations" key is present in the episode_watched_list_2_keys_dict dictionary - if episode_key == "locations": - if "locations" in episode_watched_list_2_keys_dict.keys(): + if episode_key in episode_watched_list_2_keys_dict.keys(): + if episode_key == "locations": # Iterate through the locations in the episode dictionary for location in episode_value: # If the location is in the episode_watched_list_2_keys_dict dictionary, return index of the key - if location in episode_watched_list_2_keys_dict["locations"]: - return episode_watched_list_2_keys_dict["locations"].index( - location - ) - # If the key is not "locations", check if the episode_key is present in the episode_watched_list_2_keys_dict dictionary - else: - if episode_key in episode_watched_list_2_keys_dict.keys(): + return contains_nested( + location, episode_watched_list_2_keys_dict["locations"] + ) + + else: # If the episode_value is in the episode_watched_list_2_keys_dict dictionary, return True if episode_value in episode_watched_list_2_keys_dict[episode_key]: return episode_watched_list_2_keys_dict[episode_key].index( diff --git a/test/test_library.py b/test/test_library.py index e9fd02e..3111f91 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -49,7 +49,9 @@ "tmdb": "2181581", "tvdb": "8444132", "locations": ( - "The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv", + ( + "The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv", + ) ), "status": {"completed": True, "time": 0}, } @@ -61,21 +63,21 @@ "title": "Coco", "imdb": "tt2380307", "tmdb": "354912", - "locations": ("Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv"), + "locations": [("Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv")], "status": {"completed": True, "time": 0}, } ] show_titles = { "imdb": ["tt3581920"], - "locations": ["The Last of Us"], + "locations": [("The Last of Us",)], "tmdb": ["100088"], "tvdb": ["392256"], } episode_titles = { "imdb": ["tt11957006"], "locations": [ - "The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv" + ("The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",) ], "tmdb": ["2181581"], "tvdb": ["8444132"], @@ -84,7 +86,14 @@ } movie_titles = { "imdb": ["tt2380307"], - "locations": ["Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv"], + "locations": [ + [ + ( + "Coco (2017) Remux-2160p.mkv", + "Coco (2017) Remux-1080p.mkv", + ) + ] + ], "title": ["coco"], "tmdb": ["354912"], "completed": [True], From 58337bd38c28172112b704c94ed7f2db58c773c4 Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Mon, 10 Apr 2023 23:05:22 -0600 Subject: [PATCH 10/12] Test: Use is None --- test/test_library.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/test_library.py b/test/test_library.py index 3111f91..834ff6e 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -148,7 +148,7 @@ def test_check_skip_logic(): library_mapping, ) - assert skip_reason == None + assert skip_reason is None def test_check_blacklist_logic(): @@ -197,7 +197,7 @@ def test_check_blacklist_logic(): library_other, ) - assert skip_reason == None + assert skip_reason is None library_title = "Movies" library_type = "movies" @@ -210,7 +210,7 @@ def test_check_blacklist_logic(): library_other, ) - assert skip_reason == None + assert skip_reason is None def test_check_whitelist_logic(): @@ -259,7 +259,7 @@ def test_check_whitelist_logic(): library_other, ) - assert skip_reason == None + assert skip_reason is None library_title = "Movies" library_type = "movies" @@ -272,7 +272,7 @@ def test_check_whitelist_logic(): library_other, ) - assert skip_reason == None + assert skip_reason is None def test_show_title_dict(): From 4870ff9e7a267cbee4e5f457ffe883dc702a8995 Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Tue, 11 Apr 2023 08:48:30 -0600 Subject: [PATCH 11/12] Cleanup --- README.md | 362 ++++++++++++++++++++++++------------------------ src/jellyfin.py | 3 +- src/library.py | 2 - 3 files changed, 185 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index 3059b5a..35e1c76 100644 --- a/README.md +++ b/README.md @@ -1,178 +1,184 @@ -# JellyPlex-Watched - -[![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 - -## 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. - -## Features - -### Plex -- [x] Match via Filenames -- [x] Match via provider ids -- [x] Map usersnames -- [x] Use single login -- [x] One Way/Multi Way sync -- [x] Sync Watched -- [x] Sync Inprogress - -### Jellyfin -- [x] Match via Filenames -- [x] Match via provider ids -- [x] Map usersnames -- [x] Use single login -- [x] One Way/Multi Way sync -- [x] Sync Watched -- [ ] Sync Inprogress - -### Emby -- [ ] Match via Filenames -- [ ] Match via provider ids -- [ ] Map usersnames -- [ ] Use single login -- [ ] One Way/Multi Way sync -- [ ] Sync Watched -- [ ] Sync Inprogress - -## Configuration - -```bash -# 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 = "False" - -## Debugging level, "info" is default, "debug" is more verbose -DEBUG_LEVEL = "info" - -## How often to run the script in seconds -SLEEP_DURATION = "3600" - -## Log file where all output will be written to -LOGFILE = "log.log" - -## Map usernames between servers in the event that they are different, order does not matter -## Comma separated for multiple options -USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" } - -## Map libraries between servers in the even that they are different, order does not matter -## Comma separated for multiple options -LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" } - -## 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 separated for multiple options -BLACKLIST_LIBRARY = "" -WHITELIST_LIBRARY = "" -BLACKLIST_LIBRARY_TYPE = "" -WHITELIST_LIBRARY_TYPE = "" -BLACKLIST_USERS = "" -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 -## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly -## Comma separated list for multiple servers -PLEX_BASEURL = "http://localhost:32400, https://nas:32400" - -## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ -## Comma separated list for multiple servers -PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2" - -## If not using plex token then use username and password of the server admin along with the servername -## Comma separated 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 = "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 - -## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly -## Comma separated list for multiple servers -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" -``` - -## Installation - -### Baremetal - -- Setup virtualenv of your choice - -- Install dependencies - - ```bash - pip install -r requirements.txt - ``` - -- Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens - -- Run - - ```bash - python main.py - ``` - -### Docker - -- Build docker image - - ```bash - docker build -t jellyplex-watched . - ``` - -- or use pre-built image - - ```bash - docker pull luigi311/jellyplex-watched:latest - ``` - -#### With variables - -- Run - - ```bash - docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest - ``` - -#### With .env - -- Create a .env file similar to .env.sample and set the variables to match your setup - -- Run - - ```bash - docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest - ``` - -## Contributing - -I am open to receiving pull requests. If you are submitting a pull request, please make sure run it locally for a day or two to make sure it is working as expected and stable. Make all pull requests against the dev branch and nothing will be merged into the main without going through the lower branches. - -## License - -This is currently under the GNU General Public License v3.0. +# JellyPlex-Watched + +[![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 + +## 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. + +## Features + +### Plex + +* \[x] Match via Filenames +* \[x] Match via provider ids +* \[x] Map usersnames +* \[x] Use single login +* \[x] One Way/Multi Way sync +* \[x] Sync Watched +* \[x] Sync Inprogress + +### Jellyfin + +* \[x] Match via Filenames +* \[x] Match via provider ids +* \[x] Map usersnames +* \[x] Use single login +* \[x] One Way/Multi Way sync +* \[x] Sync Watched +* \[ ] Sync Inprogress + +### Emby + +* \[ ] Match via Filenames +* \[ ] Match via provider ids +* \[ ] Map usersnames +* \[ ] Use single login +* \[ ] One Way/Multi Way sync +* \[ ] Sync Watched +* \[ ] Sync Inprogress + +## Configuration + +```bash +# 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 = "False" + +## 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 = "False" + +## How often to run the script in seconds +SLEEP_DURATION = "3600" + +## Log file where all output will be written to +LOGFILE = "log.log" + +## Map usernames between servers in the event that they are different, order does not matter +## Comma separated for multiple options +USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" } + +## Map libraries between servers in the even that they are different, order does not matter +## Comma separated for multiple options +LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" } + +## 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 separated for multiple options +BLACKLIST_LIBRARY = "" +WHITELIST_LIBRARY = "" +BLACKLIST_LIBRARY_TYPE = "" +WHITELIST_LIBRARY_TYPE = "" +BLACKLIST_USERS = "" +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 +## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly +## Comma separated list for multiple servers +PLEX_BASEURL = "http://localhost:32400, https://nas:32400" + +## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +## Comma separated list for multiple servers +PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2" + +## If not using plex token then use username and password of the server admin along with the servername +## Comma separated 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 = "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 + +## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly +## Comma separated list for multiple servers +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" +``` + +## Installation + +### Baremetal + +* Setup virtualenv of your choice + +* Install dependencies + + ```bash + pip install -r requirements.txt + ``` + +* Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens + +* Run + + ```bash + python main.py + ``` + +### Docker + +* Build docker image + + ```bash + docker build -t jellyplex-watched . + ``` + +* or use pre-built image + + ```bash + docker pull luigi311/jellyplex-watched:latest + ``` + +#### With variables + +* Run + + ```bash + docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest + ``` + +#### With .env + +* Create a .env file similar to .env.sample and set the variables to match your setup + +* Run + + ```bash + docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest + ``` + +## Contributing + +I am open to receiving pull requests. If you are submitting a pull request, please make sure run it locally for a day or two to make sure it is working as expected and stable. Make all pull requests against the dev branch and nothing will be merged into the main without going through the lower branches. + +## License + +This is currently under the GNU General Public License v3.0. diff --git a/src/jellyfin.py b/src/jellyfin.py index b3d84c6..d54afef 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -591,8 +591,8 @@ async def update_user_watched( break if movie_status: + jellyfin_video_id = jellyfin_video["Id"] if movie_status["completed"]: - jellyfin_video_id = jellyfin_video["Id"] msg = f"{jellyfin_video['Name']} as watched for {user_name} in {library} for Jellyfin" if not dryrun: logger(f"Marking {msg}", 0) @@ -605,7 +605,6 @@ async def update_user_watched( logger(f"Dryrun {msg}", 0) else: # TODO add support for partially watched movies - jellyfin_video_id = jellyfin_video["Id"] msg = f"{jellyfin_video['Name']} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin" if not dryrun: pass diff --git a/src/library.py b/src/library.py index 20792bc..6754f14 100644 --- a/src/library.py +++ b/src/library.py @@ -174,10 +174,8 @@ def episode_title_dict(user_list: dict): for show in user_list: for season in user_list[show]: for episode in user_list[show][season]: - # Iterate through the keys and values in each episode for episode_key, episode_value in episode.items(): - # If the key is not "status", add the key to episode_output_dict if it doesn't exist if episode_key != "status": if episode_key.lower() not in episode_output_dict: From fc80f5056013411da70a5a1cf513a268c3437b95 Mon Sep 17 00:00:00 2001 From: Luigi311 Date: Tue, 11 Apr 2023 08:57:49 -0600 Subject: [PATCH 12/12] Fix codeql issues --- src/jellyfin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jellyfin.py b/src/jellyfin.py index d54afef..f17f445 100644 --- a/src/jellyfin.py +++ b/src/jellyfin.py @@ -170,7 +170,7 @@ async def get_user_library_watched( ) for movie in watched["Items"]: - if "MediaSources" in movie and movie["MediaSources"] is not {}: + if "MediaSources" in movie and movie["MediaSources"] != {}: logger( f"Jellyfin: Adding {movie['Name']} to {user_name} watched list", 3, @@ -188,7 +188,7 @@ async def get_user_library_watched( # Get all partially watched movies greater than 1 minute for movie in in_progress["Items"]: - if "MediaSources" in movie and movie["MediaSources"] is not {}: + if "MediaSources" in movie and movie["MediaSources"] != {}: if movie["UserData"]["PlaybackPositionTicks"] < 600000000: continue