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 @@ from src.watched import ( ) +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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class Jellyfin: ] ): 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 @@ class Jellyfin: ) 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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class Jellyfin: 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 @@ class HostNameIgnoringAdapter(requests.adapters.HTTPAdapter): ) +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() + + for episode in show.episodes(): + if episode in watched: + 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_temp["locations"] = tuple( - [x.split("/")[-1] for x in episode.locations] - ) - - 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,20 @@ 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"]: - return True + 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() @@ -157,11 +220,46 @@ 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 + 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 False + return True, episode_videos + + return False, [] except Exception: - return False + 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"]: + 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() + 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]: + for video in videos: + if guid_source in video["ids"].keys(): + if guid_id in video["ids"][guid_source]: + return video["status"] + + return None + except Exception: + 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}",