Use pydantic for structure

Complete redesign of everything using pydantic to create the
watched structure. This brings in type checking support and
simplifies a lot of things

Signed-off-by: Luis Garcia <git@luigi311.com>
This commit is contained in:
Luis Garcia
2025-02-19 00:51:21 -07:00
parent a1e1ccde42
commit ce5b810a5b
8 changed files with 1342 additions and 1662 deletions

View File

@@ -1,6 +1,5 @@
import os, requests, traceback
import os, requests
from dotenv import load_dotenv
from typing import Dict, Union, FrozenSet
from urllib3.poolmanager import PoolManager
from math import floor
@@ -14,13 +13,18 @@ from plexapi.myplex import MyPlexAccount
from src.functions import (
logger,
search_mapping,
future_thread_executor,
contains_nested,
log_marked,
str_to_bool,
)
from src.library import generate_library_guids_dict
from src.watched import (
LibraryData,
MediaIdentifiers,
MediaItem,
WatchedStatus,
Series,
UserData,
check_same_identifiers,
)
load_dotenv(override=True)
@@ -40,27 +44,23 @@ class HostNameIgnoringAdapter(RequestsHTTPAdapter):
)
def extract_guids_from_item(item: Union[Movie, Show, Episode]) -> Dict[str, str]:
def extract_guids_from_item(item: Movie | Show | Episode) -> dict[str, str]:
# If GENERATE_GUIDS is set to False, then return an empty dict
if not generate_guids:
return {}
guids: Dict[str, str] = dict(
guids: dict[str, str] = dict(
guid.id.split("://")
for guid in item.guids
if guid.id is not None and len(guid.id.strip()) > 0
)
if len(guids) == 0:
logger(
f"Plex: Failed to get any guids for {item.title}",
1,
)
return guids
def get_guids(item: Union[Movie, Episode], completed=True):
def extract_identifiers_from_item(item: Movie | Show | Episode) -> MediaIdentifiers:
guids = extract_guids_from_item(item)
if not item.locations:
logger(
f"Plex: {item.title} has no locations",
@@ -73,356 +73,156 @@ def get_guids(item: Union[Movie, Episode], completed=True):
1,
)
return {
"title": item.title,
"locations": (
return MediaIdentifiers(
title=item.title,
locations=(
tuple([location.split("/")[-1] for location in item.locations])
if generate_locations
else tuple()
),
"status": {
"completed": completed,
"time": item.viewOffset,
},
} | extract_guids_from_item(
item
) # Merge the metadata and guid dictionaries
imdb_id=guids.get("imdb", None),
tvdb_id=guids.get("tvdb", None),
tmdb_id=guids.get("tmdb", None),
)
def get_user_library_watched_show(show, process_episodes, threads=None):
def get_mediaitem(item: Movie | Episode, completed=True) -> MediaItem:
return MediaItem(
identifiers=extract_identifiers_from_item(item),
status=WatchedStatus(completed=completed, time=item.viewOffset),
)
def update_user_watched(
user: MyPlexAccount,
user_plex: PlexServer,
library_data: LibraryData,
library_name: str,
dryrun: bool,
):
try:
show_guids: FrozenSet = frozenset(
(
{
"title": show.title,
"locations": (
tuple([location.split("/")[-1] for location in show.locations])
if generate_locations
else tuple()
),
}
| extract_guids_from_item(show)
).items() # Merge the metadata and guid dictionaries
)
episode_guids_args = []
for episode in process_episodes:
episode_guids_args.append([get_guids, episode, episode.isWatched])
episode_guids_results = future_thread_executor(
episode_guids_args, threads=threads
)
episode_guids = []
for index, episode in enumerate(process_episodes):
episode_guids.append(episode_guids_results[index])
return show_guids, episode_guids
except Exception:
return {}, {}
def get_user_library_watched(user, user_plex, library):
user_name: str = user.username.lower() if user.username else user.title.lower()
try:
logger(
f"Plex: Generating watched for {user_name} in library {library.title}",
0,
)
library_videos = user_plex.library.section(library.title)
if library.type == "movie":
watched = []
args = [
[get_guids, video, video.isWatched]
for video in library_videos.search(unwatched=False)
+ library_videos.search(inProgress=True)
if video.isWatched or video.viewOffset >= 60000
]
for guid in future_thread_executor(args, threads=len(args)):
logger(f"Plex: Adding {guid['title']} to {user_name} watched list", 3)
watched.append(guid)
elif library.type == "show":
watched = {}
# Get all watched shows and partially watched shows
parallel_show_task = []
parallel_episodes_task = []
for show in library_videos.search(unwatched=False) + library_videos.search(
inProgress=True
):
process_episodes = []
for episode in show.episodes():
if episode.isWatched or episode.viewOffset >= 60000:
process_episodes.append(episode)
# Shows with more than 24 episodes has its episodes processed in parallel
# Shows with less than 24 episodes has its episodes processed in serial but the shows are processed in parallel
if len(process_episodes) >= 24:
parallel_episodes_task.append(
[
get_user_library_watched_show,
show,
process_episodes,
len(process_episodes),
]
)
else:
parallel_show_task.append(
[get_user_library_watched_show, show, process_episodes, 1]
)
for show_guids, episode_guids in future_thread_executor(
parallel_show_task, threads=len(parallel_show_task)
) + future_thread_executor(parallel_episodes_task, threads=1):
if show_guids and episode_guids:
watched[show_guids] = episode_guids
logger(
f"Plex: Added {episode_guids} to {user_name} watched list",
3,
)
else:
watched = None
logger(f"Plex: Got watched for {user_name} in library {library.title}", 1)
logger(f"Plex: {watched}", 3)
return {user_name: {library.title: watched} if watched is not None else {}}
except Exception as e:
logger(
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
2,
)
return {}
def find_video(plex_search, video_ids, videos=None):
try:
if not generate_guids and not generate_locations:
return None
if generate_locations:
for location in plex_search.locations:
if (
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
episode_videos = []
if videos:
for show, episodes in videos.items():
show = {k: v for k, v in show}
if (
contains_nested(
location.split("/")[-1], show["locations"]
)
is not None
):
for episode in episodes:
episode_videos.append(episode)
return episode_videos
if generate_guids:
for guid in plex_search.guids:
guid_source, guid_id = guid.id.split("://")
# 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, episodes in videos.items():
show = {k: v for k, v in show}
if guid_source in show.keys():
if guid_id == show[guid_source]:
for episode in episodes:
episode_videos.append(episode)
return episode_videos
return None
except Exception:
return None
def get_video_status(plex_search, video_ids, videos):
try:
if not generate_guids and not generate_locations:
return None
if generate_locations:
for location in plex_search.locations:
if (
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
for video in videos:
if (
contains_nested(location.split("/")[-1], video["locations"])
is not None
):
return video["status"]
if generate_guids:
for guid in plex_search.guids:
guid_source, guid_id = guid.id.split("://")
# 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.keys():
if guid_id == video[guid_source]:
return video["status"]
return None
except Exception:
return None
def update_user_watched(user, user_plex, library, watched_videos, dryrun):
try:
logger(f"Plex: Updating watched for {user.title} in library {library}", 1)
(
watched_shows_ids,
watched_episodes_ids,
watched_movies_ids,
) = generate_library_guids_dict(watched_videos)
if (
not watched_movies_ids
and not watched_shows_ids
and not watched_episodes_ids
):
logger(
f"Jellyfin: No videos to mark as watched for {user.title} in library {library}",
1,
)
# If there are no movies or shows to update, exit early.
if not library_data.series and not library_data.movies:
return
logger(
f"Plex: mark list\nShows: {watched_shows_ids}\nEpisodes: {watched_episodes_ids}\nMovies: {watched_movies_ids}",
1,
)
logger(f"Plex: Updating watched for {user.title} in library {library_name}", 1)
library_section = user_plex.library.section(library_name)
library_videos = user_plex.library.section(library)
if watched_movies_ids:
for plex_movie in library_videos.search(unwatched=True):
watched_movie_status = get_video_status(
plex_movie, watched_movies_ids, watched_videos
)
if watched_movie_status:
if watched_movie_status["completed"]:
msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
plex_movie.markWatched()
else:
logger(msg, 6)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library,
plex_movie.title,
None,
None,
)
elif watched_movie_status["time"] > 60_000:
msg = f"Plex: {plex_movie.title} as partially watched for {floor(watched_movie_status['time'] / 60_000)} minutes for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
plex_movie.updateTimeline(watched_movie_status["time"])
else:
logger(msg, 6)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library,
plex_movie.title,
duration=watched_movie_status["time"],
)
else:
logger(
f"Plex: Skipping movie {plex_movie.title} as it is not in mark list for {user.title}",
1,
)
if watched_shows_ids and watched_episodes_ids:
for plex_show in library_videos.search(unwatched=True):
watched_show_episodes_status = find_video(
plex_show, watched_shows_ids, watched_videos
)
if watched_show_episodes_status:
for plex_episode in plex_show.episodes():
watched_episode_status = get_video_status(
plex_episode,
watched_episodes_ids,
watched_show_episodes_status,
)
if watched_episode_status:
if watched_episode_status["completed"]:
msg = f"Plex: {plex_show.title} {plex_episode.title} as watched for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
plex_episode.markWatched()
else:
logger(msg, 6)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library,
plex_show.title,
plex_episode.title,
)
# Update movies.
if library_data.movies:
# Search for Plex movies that are currently marked as unwatched.
for plex_movie in library_section.search(unwatched=True):
plex_identifiers = extract_identifiers_from_item(plex_movie)
# Check each stored movie for a match.
for stored_movie in library_data.movies:
if check_same_identifiers(
plex_identifiers, stored_movie.identifiers
):
# If the stored movie is marked as watched (or has enough progress),
# update the Plex movie accordingly.
if stored_movie.status.completed:
msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}"
if not dryrun:
logger(msg, 5)
plex_movie.markWatched()
else:
msg = f"Plex: {plex_show.title} {plex_episode.title} as partially watched for {floor(watched_episode_status['time'] / 60_000)} minutes for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
plex_episode.updateTimeline(
watched_episode_status["time"]
)
else:
logger(msg, 6)
logger(msg, 6)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library,
plex_show.title,
plex_episode.title,
watched_episode_status["time"],
)
else:
logger(
f"Plex: Skipping episode {plex_episode.title} as it is not in mark list for {user.title}",
3,
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_movie.title,
None,
None,
)
else:
logger(
f"Plex: Skipping show {plex_show.title} as it is not in mark list for {user.title}",
3,
)
else:
msg = f"Plex: {plex_movie.title} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user.title} in {library_name}"
if not dryrun:
logger(msg, 5)
plex_movie.updateTimeline(stored_movie.status.time)
else:
logger(msg, 6)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_movie.title,
duration=stored_movie.status.time,
)
# Once matched, no need to check further.
break
# Update TV Shows (series/episodes).
if library_data.series:
# For each Plex show in the library section:
plex_shows = library_section.search(unwatched=True)
for plex_show in plex_shows:
# Extract identifiers from the Plex show.
plex_show_identifiers = extract_identifiers_from_item(plex_show)
# Try to find a matching series in your stored library.
for stored_series in library_data.series:
if check_same_identifiers(
plex_show_identifiers, stored_series.identifiers
):
logger(f"Found matching show for '{plex_show.title}'", 1)
# Now update episodes.
# Get the list of Plex episodes for this show.
plex_episodes = plex_show.episodes()
for plex_episode in plex_episodes:
plex_episode_identifiers = extract_identifiers_from_item(
plex_episode
)
for stored_ep in stored_series.episodes:
if check_same_identifiers(
plex_episode_identifiers, stored_ep.identifiers
):
if stored_ep.status.completed:
msg = f"Plex: {plex_show.title} {plex_episode.title} as watched for {user.title} in {library_name}"
if not dryrun:
logger(msg, 5)
plex_episode.markWatched()
else:
logger(msg, 6)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_show.title,
plex_episode.title,
)
else:
msg = f"Plex: {plex_show.title} {plex_episode.title} as partially watched for {floor(stored_ep.status.time / 60_000)} minutes for {user.title} in {library_name}"
if not dryrun:
logger(msg, 5)
plex_episode.updateTimeline(
stored_ep.status.time
)
else:
logger(msg, 6)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_show.title,
plex_episode.title,
stored_ep.status.time,
)
break # Found a matching episode.
break # Found a matching show.
except Exception as e:
logger(
f"Plex: Failed to update watched for {user.title} in library {library}, Error: {e}",
f"Plex: Failed to update watched for {user.title} in library {library_name}, Error: {e}",
2,
)
logger(traceback.format_exc(), 2)
raise e
# class plex accept base url and token and username and password but default with none
@@ -505,10 +305,77 @@ class Plex:
logger(f"Plex: Failed to get libraries, Error: {e}", 2)
raise Exception(e)
def get_watched(self, users, sync_libraries):
def get_user_library_watched(self, user, user_plex, library) -> LibraryData:
user_name: str = user.username.lower() if user.username else user.title.lower()
try:
# Get all libraries
users_watched = {}
logger(
f"Plex: Generating watched for {user_name} in library {library.title}",
0,
)
watched = LibraryData(title=library.title)
library_videos = user_plex.library.section(library.title)
if library.type == "movie":
for video in library_videos.search(
unwatched=False
) + library_videos.search(inProgress=True):
if video.isWatched or video.viewOffset >= 60000:
watched.movies.append(get_mediaitem(video, video.isWatched))
elif library.type == "show":
# Keep track of processed shows to reduce duplicate shows
processed_shows = []
for show in library_videos.search(
unwatched=False
) + library_videos.search(inProgress=True):
if show.key in processed_shows:
continue
processed_shows.append(show.key)
show_guids = extract_guids_from_item(show)
episode_mediaitem = []
for episode in show.episodes():
if episode.isWatched or episode.viewOffset >= 60000:
episode_mediaitem.append(
get_mediaitem(episode, episode.isWatched)
)
if episode_mediaitem:
watched.series.append(
Series(
identifiers=MediaIdentifiers(
title=show.title,
locations=(
tuple(
[
location.split("/")[-1]
for location in show.locations
]
)
if generate_locations
else tuple()
),
imdb_id=show_guids.get("imdb", None),
tvdb_id=show_guids.get("tvdb", None),
tmdb_id=show_guids.get("tmdb", None),
),
episodes=episode_mediaitem,
)
)
return watched
except Exception as e:
logger(
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
2,
)
return LibraryData(title=library.title)
def get_watched(self, users, sync_libraries) -> dict[str, UserData]:
try:
users_watched: dict[str, UserData] = {}
for user in users:
if self.admin_user == user:
@@ -525,7 +392,6 @@ class Plex:
f"Plex: Failed to get token for {user.title}, skipping",
2,
)
users_watched[user.title] = {}
continue
libraries = user_plex.library.sections()
@@ -534,12 +400,16 @@ class Plex:
if library.title not in sync_libraries:
continue
user_watched = get_user_library_watched(user, user_plex, library)
library_data = self.get_user_library_watched(
user, user_plex, library
)
for user_watched, user_watched_temp in user_watched.items():
if user_watched not in users_watched:
users_watched[user_watched] = {}
users_watched[user_watched].update(user_watched_temp)
if user.title.lower() not in users_watched:
users_watched[user.title.lower()] = UserData()
users_watched[user.title.lower()].libraries[
library.title
] = library_data
return users_watched
except Exception as e:
@@ -547,12 +417,14 @@ class Plex:
raise Exception(e)
def update_watched(
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
self,
watched_list: dict[str, UserData],
user_mapping=None,
library_mapping=None,
dryrun=False,
):
try:
args = []
for user, libraries in watched_list.items():
for user, user_data in watched_list.items():
user_other = None
# If type of user is dict
if user_mapping:
@@ -596,48 +468,46 @@ class Plex:
)
continue
for library, watched_videos in libraries.items():
for library_name in user_data.libraries:
library_data = user_data.libraries[library_name]
library_other = None
if library_mapping:
library_other = search_mapping(library_mapping, library)
library_other = search_mapping(library_mapping, library_name)
# if library in plex library list
library_list = user_plex.library.sections()
if library.lower() not in [x.title.lower() for x in library_list]:
if library_name.lower() not in [
x.title.lower() for x in library_list
]:
if library_other:
if library_other.lower() in [
x.title.lower() for x in library_list
]:
logger(
f"Plex: Library {library} not found, but {library_other} found, using {library_other}",
f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}",
1,
)
library = library_other
library_name = library_other
else:
logger(
f"Plex: Library {library} or {library_other} not found in library list",
f"Plex: Library {library_name} or {library_other} not found in library list",
1,
)
continue
else:
logger(
f"Plex: Library {library} not found in library list",
f"Plex: Library {library_name} not found in library list",
1,
)
continue
args.append(
[
update_user_watched,
user,
user_plex,
library,
watched_videos,
dryrun,
]
update_user_watched(
user,
user_plex,
library_data,
library_name,
dryrun,
)
future_thread_executor(args)
except Exception as e:
logger(f"Plex: Failed to update watched, Error: {e}", 2)
raise Exception(e)