17 Commits

Author SHA1 Message Date
Luis Garcia
46b60bb866 Tag version 7.0.4
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-05-16 04:06:05 +00:00
Luigi311
5670c3ad97 Merge pull request #278 from luigi311/update_deps
Update dependencies
2025-05-15 22:05:19 -06:00
Luis Garcia
7e0f4babda Update dependencies
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-05-16 04:01:59 +00:00
Luigi311
d5c36c61ec Merge pull request #277 from luigi311/error_raised
Do not fail on some errors
2025-05-15 18:25:11 -06:00
Luis Garcia
69cd73d965 Functions: Remove fstring from mark_file file path
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-05-15 03:46:51 +00:00
Luis Garcia
229ab59b44 Do not fail on some errors
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-05-15 03:38:33 +00:00
Luigi311
3e474a4593 Merge pull request #267 from masesisaac/main
fix: case-insensitive library name check for jellyfin/emby
2025-04-10 16:28:19 -06:00
masesisaac
69958a257b fix: case-insensitive library name check 2025-04-11 00:34:10 +03:00
Luis Garcia
64c1823e5b tag 7.0.3
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-04-03 19:25:20 +00:00
Luigi311
446f6df470 Merge pull request #259 from luigi311/fallback_library_types
Jellyfin/Emby: Add fallback to media files for library types
2025-04-03 13:23:41 -06:00
Luis Garcia
91ea5d76f6 Jellyfin/Emby: Add fallback to media files for library types
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-04-02 04:27:39 +00:00
Luigi311
dc26b9a7b1 Merge pull request #249 from luigi311/simplify_get_watched
Jellyfin/Emby: Simplify get watched
2025-03-07 16:35:11 -07:00
Luis Garcia
d98b7c3e09 Jellyfin/Emby: Simplify get watched
Shouldn't need to do library type checks as that is handed in the
get libraries function and then used with the sync libraries name
check

Signed-off-by: Luis Garcia <git@luigi311.com>
2025-03-07 23:29:36 +00:00
Luigi311
93d9471333 Merge pull request #248 from luigi311/reliable
Improve reliability
2025-03-07 16:27:55 -07:00
Luis Garcia
e6fa8ae745 Treewide: MyPy type fixes
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-03-07 23:24:33 +00:00
Luis Garcia
5b644a54a2 Plex: Better reliability 2025-03-07 20:23:02 +00:00
Luis Garcia
5a17c5f7a1 Jellyfin/Emby: Better reliability 2025-03-07 19:34:37 +00:00
13 changed files with 811 additions and 611 deletions

View File

@@ -1,22 +1,24 @@
[project] [project]
name = "jellyplex-watched" name = "jellyplex-watched"
version = "6.1.2" version = "7.0.4"
description = "Sync watched between media servers locally" description = "Sync watched between media servers locally"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"loguru>=0.7.3", "loguru>=0.7.3",
"packaging==24.2", "packaging==25.0",
"plexapi==4.16.1", "plexapi==4.17.0",
"pydantic==2.10.6", "pydantic==2.11.4",
"python-dotenv==1.0.0", "python-dotenv==1.1.0",
"requests==2.32.3", "requests==2.32.3",
] ]
[dependency-groups] [dependency-groups]
lint = [ lint = [
"ruff>=0.9.6", "ruff>=0.11.10",
] ]
dev = [ dev = [
"pytest>=8.3.4", "mypy>=1.15.0",
"pytest>=8.3.5",
"types-requests>=2.32.0.20250515",
] ]

View File

@@ -12,7 +12,7 @@ def setup_black_white_lists(
whitelist_users: list[str] | None, whitelist_users: list[str] | None,
library_mapping: dict[str, str] | None = None, library_mapping: dict[str, str] | None = None,
user_mapping: dict[str, str] | None = None, user_mapping: dict[str, str] | None = None,
): ) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str]]:
blacklist_library, blacklist_library_type, blacklist_users = setup_x_lists( blacklist_library, blacklist_library_type, blacklist_users = setup_x_lists(
blacklist_library, blacklist_library,
blacklist_library_type, blacklist_library_type,

View File

@@ -25,17 +25,17 @@ def jellyfin_emby_server_connection(
f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries" f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries"
) )
for i, baseurl in enumerate(server_baseurls): for i, base_url in enumerate(server_baseurls):
baseurl = baseurl.strip() base_url = base_url.strip()
if baseurl[-1] == "/": if base_url[-1] == "/":
baseurl = baseurl[:-1] base_url = base_url[:-1]
if server_type == "jellyfin": if server_type == "jellyfin":
server = Jellyfin(baseurl=baseurl, token=server_tokens[i].strip()) server = Jellyfin(base_url=base_url, token=server_tokens[i].strip())
servers.append(server) servers.append(server)
elif server_type == "emby": elif server_type == "emby":
server = Emby(baseurl=baseurl, token=server_tokens[i].strip()) server = Emby(base_url=base_url, token=server_tokens[i].strip())
servers.append(server) servers.append(server)
else: else:
raise Exception("Unknown server type") raise Exception("Unknown server type")
@@ -66,11 +66,11 @@ def generate_server_connections() -> list[Plex | Jellyfin | Emby]:
for i, url in enumerate(plex_baseurl): for i, url in enumerate(plex_baseurl):
server = Plex( server = Plex(
baseurl=url.strip(), base_url=url.strip(),
token=plex_token[i].strip(), token=plex_token[i].strip(),
username=None, user_name=None,
password=None, password=None,
servername=None, server_name=None,
ssl_bypass=ssl_bypass, ssl_bypass=ssl_bypass,
) )
@@ -92,11 +92,11 @@ def generate_server_connections() -> list[Plex | Jellyfin | Emby]:
for i, username in enumerate(plex_username): for i, username in enumerate(plex_username):
server = Plex( server = Plex(
baseurl=None, base_url=None,
token=None, token=None,
username=username.strip(), user_name=username.strip(),
password=plex_password[i].strip(), password=plex_password[i].strip(),
servername=plex_servername[i].strip(), server_name=plex_servername[i].strip(),
ssl_bypass=ssl_bypass, ssl_bypass=ssl_bypass,
) )

View File

@@ -1,9 +1,10 @@
from src.jellyfin_emby import JellyfinEmby from src.jellyfin_emby import JellyfinEmby
from packaging.version import parse, Version from packaging.version import parse, Version
from loguru import logger
class Emby(JellyfinEmby): class Emby(JellyfinEmby):
def __init__(self, baseurl, token): def __init__(self, base_url: str, token: str) -> None:
authorization = ( authorization = (
"Emby , " "Emby , "
'Client="JellyPlex-Watched", ' 'Client="JellyPlex-Watched", '
@@ -18,8 +19,14 @@ class Emby(JellyfinEmby):
} }
super().__init__( super().__init__(
server_type="Emby", baseurl=baseurl, token=token, headers=headers server_type="Emby", base_url=base_url, token=token, headers=headers
) )
def is_partial_update_supported(self, server_version: Version) -> bool: def is_partial_update_supported(self, server_version: Version) -> bool:
return server_version > parse("4.4") if not server_version >= parse("4.4"):
logger.info(
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
)
return False
return True

View File

@@ -16,7 +16,7 @@ def log_marked(
movie_show: str, movie_show: str,
episode: str | None = None, episode: str | None = None,
duration: float | None = None, duration: float | None = None,
): ) -> None:
output = f"{server_type}/{server_name}/{username}/{library}/{movie_show}" output = f"{server_type}/{server_name}/{username}/{library}/{movie_show}"
if episode: if episode:
@@ -25,7 +25,7 @@ def log_marked(
if duration: if duration:
output += f"/{duration}" output += f"/{duration}"
with open(f"{mark_file}", "a", encoding="utf-8") as file: with open(mark_file, "a", encoding="utf-8") as file:
file.write(output + "\n") file.write(output + "\n")

View File

@@ -1,9 +1,10 @@
from src.jellyfin_emby import JellyfinEmby from src.jellyfin_emby import JellyfinEmby
from packaging.version import parse, Version from packaging.version import parse, Version
from loguru import logger
class Jellyfin(JellyfinEmby): class Jellyfin(JellyfinEmby):
def __init__(self, baseurl, token): def __init__(self, base_url: str, token: str) -> None:
authorization = ( authorization = (
"MediaBrowser , " "MediaBrowser , "
'Client="JellyPlex-Watched", ' 'Client="JellyPlex-Watched", '
@@ -18,8 +19,14 @@ class Jellyfin(JellyfinEmby):
} }
super().__init__( super().__init__(
server_type="Jellyfin", baseurl=baseurl, token=token, headers=headers server_type="Jellyfin", base_url=base_url, token=token, headers=headers
) )
def is_partial_update_supported(self, server_version: Version) -> bool: def is_partial_update_supported(self, server_version: Version) -> bool:
return server_version >= parse("10.9.0") if not server_version >= parse("10.9.0"):
logger.info(
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
)
return False
return True

View File

@@ -30,28 +30,34 @@ generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True")) generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
def extract_identifiers_from_item(server_type, item: dict) -> MediaIdentifiers: def extract_identifiers_from_item(
title = item.get("Name", None) server_type: str, item: dict[str, Any]
) -> MediaIdentifiers:
title = item.get("Name")
id = None id = None
if not title: if not title:
id = item.get("Id") id = item.get("Id")
logger.info(f"{server_type}: Name not found in {id}") logger.info(f"{server_type}: Name not found for {id}")
guids = {} guids = {}
if generate_guids: if generate_guids:
guids = {k.lower(): v for k, v in item["ProviderIds"].items()} guids = {k.lower(): v for k, v in item.get("ProviderIds", {}).items()}
if not guids: if not guids:
logger.info( logger.info(
f"{server_type}: {title if title else id} has no guids", f"{server_type}: {title if title else id} has no guids",
) )
locations = tuple() locations: tuple[str, ...] = tuple()
if generate_locations: if generate_locations:
if "Path" in item: if item.get("Path"):
locations = tuple([item.get("Path").split("/")[-1]]) locations = tuple([item["Path"].split("/")[-1]])
elif "MediaSources" in item: elif item.get("MediaSources"):
locations = tuple( locations = tuple(
[x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x] [
x["Path"].split("/")[-1]
for x in item["MediaSources"]
if x.get("Path")
]
) )
if not locations: if not locations:
@@ -60,18 +66,20 @@ def extract_identifiers_from_item(server_type, item: dict) -> MediaIdentifiers:
return MediaIdentifiers( return MediaIdentifiers(
title=title, title=title,
locations=locations, locations=locations,
imdb_id=guids.get("imdb", None), imdb_id=guids.get("imdb"),
tvdb_id=guids.get("tvdb", None), tvdb_id=guids.get("tvdb"),
tmdb_id=guids.get("tmdb", None), tmdb_id=guids.get("tmdb"),
) )
def get_mediaitem(server_type, item: dict) -> MediaItem: def get_mediaitem(server_type: str, item: dict[str, Any]) -> MediaItem:
return MediaItem( return MediaItem(
identifiers=extract_identifiers_from_item(server_type, item), identifiers=extract_identifiers_from_item(server_type, item),
status=WatchedStatus( status=WatchedStatus(
completed=item["UserData"]["Played"], completed=item.get("UserData", {}).get("Played"),
time=floor(item["UserData"]["PlaybackPositionTicks"] / 10000), time=floor(
item.get("UserData", {}).get("PlaybackPositionTicks", 0) / 10000
),
), ),
) )
@@ -80,27 +88,31 @@ class JellyfinEmby:
def __init__( def __init__(
self, self,
server_type: Literal["Jellyfin", "Emby"], server_type: Literal["Jellyfin", "Emby"],
baseurl: str, base_url: str,
token: str, token: str,
headers: dict[str, str], headers: dict[str, str],
): ) -> None:
if server_type not in ["Jellyfin", "Emby"]: if server_type not in ["Jellyfin", "Emby"]:
raise Exception(f"Server type {server_type} not supported") raise Exception(f"Server type {server_type} not supported")
self.server_type = server_type self.server_type: str = server_type
self.baseurl = baseurl self.base_url: str = base_url
self.token = token self.token: str = token
self.headers = headers self.headers: dict[str, str] = headers
self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300)) self.timeout: int = int(os.getenv("REQUEST_TIMEOUT", 300))
if not self.baseurl: if not self.base_url:
raise Exception(f"{self.server_type} baseurl not set") raise Exception(f"{self.server_type} base_url not set")
if not self.token: if not self.token:
raise Exception(f"{self.server_type} token not set") raise Exception(f"{self.server_type} token not set")
self.session = requests.Session() self.session = requests.Session()
self.users = self.get_users() self.users: dict[str, str] = self.get_users()
self.server_name = self.info(name_only=True) self.server_name: str = self.info(name_only=True)
self.server_version: Version = self.info(version_only=True)
self.update_partial: bool = self.is_partial_update_supported(
self.server_version
)
def query( def query(
self, self,
@@ -108,15 +120,13 @@ class JellyfinEmby:
query_type: Literal["get", "post"], query_type: Literal["get", "post"],
identifiers: dict[str, str] | None = None, identifiers: dict[str, str] | None = None,
json: dict[str, float] | None = None, json: dict[str, float] | None = None,
) -> dict[str, Any] | list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | dict[str, Any] | None:
try: try:
results: ( results = None
dict[str, list[Any] | dict[str, str]] | list[dict[str, Any]] | None
) = None
if query_type == "get": if query_type == "get":
response = self.session.get( response = self.session.get(
self.baseurl + query, headers=self.headers, timeout=self.timeout self.base_url + query, headers=self.headers, timeout=self.timeout
) )
if response.status_code not in [200, 204]: if response.status_code not in [200, 204]:
raise Exception( raise Exception(
@@ -129,7 +139,7 @@ class JellyfinEmby:
elif query_type == "post": elif query_type == "post":
response = self.session.post( response = self.session.post(
self.baseurl + query, self.base_url + query,
headers=self.headers, headers=self.headers,
json=json, json=json,
timeout=self.timeout, timeout=self.timeout,
@@ -143,12 +153,12 @@ class JellyfinEmby:
else: else:
results = response.json() results = response.json()
if results is not None: if results:
if not isinstance(results, list) and not isinstance(results, dict): if not isinstance(results, list) and not isinstance(results, dict):
raise Exception("Query result is not of type list or dict") raise Exception("Query result is not of type list or dict")
# append identifiers to results # append identifiers to results
if identifiers and results: if identifiers and isinstance(results, dict):
results["Identifiers"] = identifiers results["Identifiers"] = identifiers
return results return results
@@ -165,13 +175,13 @@ class JellyfinEmby:
try: try:
query_string = "/System/Info/Public" query_string = "/System/Info/Public"
response: dict[str, Any] = self.query(query_string, "get") response = self.query(query_string, "get")
if response: if response and isinstance(response, dict):
if name_only: if name_only:
return response["ServerName"] return response.get("ServerName")
elif version_only: elif version_only:
return parse(response["Version"]) return parse(response.get("Version", ""))
return f"{self.server_type} {response.get('ServerName')}: {response.get('Version')}" return f"{self.server_type} {response.get('ServerName')}: {response.get('Version')}"
else: else:
@@ -186,13 +196,11 @@ class JellyfinEmby:
users: dict[str, str] = {} users: dict[str, str] = {}
query_string = "/Users" query_string = "/Users"
response: list[dict[str, str | bool]] = self.query(query_string, "get") response = self.query(query_string, "get")
# If response is not empty if response and isinstance(response, list):
if response:
for user in response: for user in response:
if isinstance(user["Name"], str) and isinstance(user["Id"], str): users[user["Name"]] = user["Id"]
users[user["Name"]] = user["Id"]
return users return users
except Exception as e: except Exception as e:
@@ -201,19 +209,61 @@ class JellyfinEmby:
def get_libraries(self) -> dict[str, str]: def get_libraries(self) -> dict[str, str]:
try: try:
libraries = {} libraries: dict[str, str] = {}
# Theres no way to get all libraries so individually get list of libraries from all users # Theres no way to get all libraries so individually get list of libraries from all users
users = self.get_users() users = self.get_users()
for user_name, user_id in users.items(): for user_name, user_id in users.items():
user_libraries: dict = self.query(f"/Users/{user_id}/Views", "get") user_libraries = self.query(f"/Users/{user_id}/Views", "get")
logger.debug(f"{self.server_type}: All Libraries for {user_name} {[library.get("Name") for library in user_libraries["Items"]]}")
for library in user_libraries["Items"]: if not user_libraries or not isinstance(user_libraries, dict):
library_title = library["Name"] logger.error(
f"{self.server_type}: Failed to get libraries for {user_name}"
)
return libraries
logger.debug(
f"{self.server_type}: All Libraries for {user_name} {[library.get('Name') for library in user_libraries.get('Items', [])]}"
)
for library in user_libraries.get("Items", []):
library_title = library.get("Name")
library_type = library.get("CollectionType") library_type = library.get("CollectionType")
# If collection type is not set, fallback based on media files
if not library_type:
library_id = library.get("Id")
# Get first 100 items in library
library_items = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Recursive=True&excludeItemTypes=Folder&limit=100",
"get",
)
if not library_items or not isinstance(library_items, dict):
logger.debug(
f"{self.server_type}: Failed to get library items for {user_name} {library_title}"
)
continue
all_types = set(
[x.get("Type") for x in library_items.get("Items", [])]
)
types = set([x for x in all_types if x in ["Movie", "Episode"]])
if not len(types) == 1:
logger.debug(
f"{self.server_type}: Skipping Library {library_title} didn't find just a single type, found {all_types}",
)
continue
library_type = types.pop()
library_type = (
"movies" if library_type == "Movie" else "tvshows"
)
if library_type not in ["movies", "tvshows"]: if library_type not in ["movies", "tvshows"]:
logger.debug( logger.debug(
f"{self.server_type}: Skipping Library {library_title} found type {library_type}", f"{self.server_type}: Skipping Library {library_title} found type {library_type}",
@@ -228,7 +278,12 @@ class JellyfinEmby:
raise Exception(e) raise Exception(e)
def get_user_library_watched( def get_user_library_watched(
self, user_name, user_id, library_type, library_id, library_title self,
user_name: str,
user_id: str,
library_type: Literal["movies", "tvshows"],
library_id: str,
library_title: str,
) -> LibraryData: ) -> LibraryData:
user_name = user_name.lower() user_name = user_name.lower()
try: try:
@@ -238,86 +293,105 @@ class JellyfinEmby:
watched = LibraryData(title=library_title) watched = LibraryData(title=library_title)
# Movies # Movies
if library_type == "Movie": if library_type == "movies":
movie_items = []
watched_items = self.query( watched_items = self.query(
f"/Users/{user_id}/Items" f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", + f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get", "get",
).get("Items", []) )
if watched_items and isinstance(watched_items, dict):
movie_items += watched_items.get("Items", [])
in_progress_items = self.query( in_progress_items = self.query(
f"/Users/{user_id}/Items" f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", + f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get", "get",
).get("Items", []) )
for movie in watched_items + in_progress_items: if in_progress_items and isinstance(in_progress_items, dict):
movie_items += in_progress_items.get("Items", [])
for movie in movie_items:
# Skip if theres no user data which means the movie has not been watched # Skip if theres no user data which means the movie has not been watched
if "UserData" not in movie: if not movie.get("UserData"):
continue continue
# Skip if theres no media tied to the movie # Skip if theres no media tied to the movie
if "MediaSources" not in movie or movie["MediaSources"] == {}: if not movie.get("MediaSources"):
continue continue
# Skip if not watched or watched less than a minute # Skip if not watched or watched less than a minute
if ( if (
movie["UserData"]["Played"] == True movie["UserData"].get("Played")
or movie["UserData"]["PlaybackPositionTicks"] > 600000000 or movie["UserData"].get("PlaybackPositionTicks", 0) > 600000000
): ):
watched.movies.append(get_mediaitem(self.server_type, movie)) watched.movies.append(get_mediaitem(self.server_type, movie))
# TV Shows # TV Shows
if library_type in ["Series", "Episode"]: if library_type == "tvshows":
# Retrieve a list of watched TV shows # Retrieve a list of watched TV shows
watched_shows = self.query( all_shows = self.query(
f"/Users/{user_id}/Items" f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount", + f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
"get", "get",
).get("Items", []) )
if not all_shows or not isinstance(all_shows, dict):
logger.debug(
f"{self.server_type}: Failed to get shows for {user_name} in {library_title}"
)
return watched
# Filter the list of shows to only include those that have been partially or fully watched # Filter the list of shows to only include those that have been partially or fully watched
watched_shows_filtered = [] watched_shows_filtered = []
for show in watched_shows: for show in all_shows.get("Items", []):
if "UserData" not in show: if not show.get("UserData"):
continue continue
if "PlayedPercentage" in show["UserData"]: if show["UserData"].get("PlayedPercentage", 0) > 0:
if show["UserData"]["PlayedPercentage"] > 0: watched_shows_filtered.append(show)
watched_shows_filtered.append(show)
# Retrieve the watched/partially watched list of episodes of each watched show # Retrieve the watched/partially watched list of episodes of each watched show
for show in watched_shows_filtered: for show in watched_shows_filtered:
show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()} show_name = show.get("Name")
show_guids = {
k.lower(): v for k, v in show.get("ProviderIds", {}).items()
}
show_locations = ( show_locations = (
tuple([show["Path"].split("/")[-1]]) tuple([show["Path"].split("/")[-1]])
if "Path" in show if show.get("Path")
else tuple() else tuple()
) )
show_episodes = self.query( show_episodes = self.query(
f"/Shows/{show['Id']}/Episodes" f"/Shows/{show.get('Id')}/Episodes"
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources", + f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources",
"get", "get",
).get("Items", []) )
if not show_episodes or not isinstance(show_episodes, dict):
logger.debug(
f"{self.server_type}: Failed to get episodes for {user_name} {library_title} {show_name}"
)
continue
# Iterate through the episodes # Iterate through the episodes
# Create a list to store the episodes # Create a list to store the episodes
episode_mediaitem = [] episode_mediaitem = []
for episode in show_episodes: for episode in show_episodes.get("Items", []):
if "UserData" not in episode: if not episode.get("UserData"):
continue continue
if ( if not episode.get("MediaSources"):
"MediaSources" not in episode
or episode["MediaSources"] == {}
):
continue continue
# If watched or watched more than a minute # If watched or watched more than a minute
if ( if (
episode["UserData"]["Played"] == True episode["UserData"].get("Played")
or episode["UserData"]["PlaybackPositionTicks"] > 600000000 or episode["UserData"].get("PlaybackPositionTicks", 0)
> 600000000
): ):
episode_mediaitem.append( episode_mediaitem.append(
get_mediaitem(self.server_type, episode) get_mediaitem(self.server_type, episode)
@@ -329,9 +403,9 @@ class JellyfinEmby:
identifiers=MediaIdentifiers( identifiers=MediaIdentifiers(
title=show.get("Name"), title=show.get("Name"),
locations=show_locations, locations=show_locations,
imdb_id=show_guids.get("imdb", None), imdb_id=show_guids.get("imdb"),
tvdb_id=show_guids.get("tvdb", None), tvdb_id=show_guids.get("tvdb"),
tmdb_id=show_guids.get("tmdb", None), tmdb_id=show_guids.get("tmdb"),
), ),
episodes=episode_mediaitem, episodes=episode_mediaitem,
) )
@@ -348,7 +422,7 @@ class JellyfinEmby:
) )
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {} return LibraryData(title=library_title)
def get_watched( def get_watched(
self, users: dict[str, str], sync_libraries: list[str] self, users: dict[str, str], sync_libraries: list[str]
@@ -357,66 +431,45 @@ class JellyfinEmby:
users_watched: dict[str, UserData] = {} users_watched: dict[str, UserData] = {}
for user_name, user_id in users.items(): for user_name, user_id in users.items():
libraries = []
all_libraries = self.query(f"/Users/{user_id}/Views", "get") all_libraries = self.query(f"/Users/{user_id}/Views", "get")
for library in all_libraries["Items"]: if not all_libraries or not isinstance(all_libraries, dict):
library_id = library["Id"] logger.debug(
library_title = library["Name"] f"{self.server_type}: Failed to get all libraries for {user_name}"
)
continue
if library_title not in sync_libraries: for library in all_libraries.get("Items", []):
if library.get("Name") not in sync_libraries:
continue continue
identifiers: dict[str, str] = { library_id = library.get("Id")
"library_id": library_id, library_title = library.get("Name")
"library_title": library_title, library_type = library.get("CollectionType")
} if not library_id or not library_title or not library_type:
libraries.append( logger.debug(
self.query( f"{self.server_type}: Failed to get library data for {user_name} {library_title}"
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
"get",
identifiers=identifiers,
) )
# Get watched for user
library_data = self.get_user_library_watched(
user_name,
user_id,
library_type,
library_id,
library_title,
) )
for library in libraries: if user_name.lower() not in users_watched:
if len(library["Items"]) == 0: users_watched[user_name.lower()] = UserData()
continue
library_id: str = library["Identifiers"]["library_id"] users_watched[user_name.lower()].libraries[library_title] = (
library_title: str = library["Identifiers"]["library_title"] library_data
# Get all library types excluding "Folder"
types = set(
[
x["Type"]
for x in library["Items"]
if x["Type"] in ["Movie", "Series", "Episode"]
]
) )
for library_type in types:
# Get watched for user
library_data = self.get_user_library_watched(
user_name,
user_id,
library_type,
library_id,
library_title,
)
if user_name.lower() not in users_watched:
users_watched[user_name.lower()] = UserData()
users_watched[user_name.lower()].libraries[library_title] = (
library_data
)
return users_watched return users_watched
except Exception as e: except Exception as e:
logger.error(f"{self.server_type}: Failed to get watched, Error: {e}") logger.error(f"{self.server_type}: Failed to get watched, Error: {e}")
raise Exception(e) return {}
def update_user_watched( def update_user_watched(
self, self,
@@ -425,9 +478,8 @@ class JellyfinEmby:
library_data: LibraryData, library_data: LibraryData,
library_name: str, library_name: str,
library_id: str, library_id: str,
update_partial: bool,
dryrun: bool, dryrun: bool,
): ) -> None:
try: try:
# If there are no movies or shows to update, exit early. # If there are no movies or shows to update, exit early.
if not library_data.series and not library_data.movies: if not library_data.series and not library_data.movies:
@@ -445,7 +497,14 @@ class JellyfinEmby:
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie", + "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie",
"get", "get",
) )
for jellyfin_video in jellyfin_search["Items"]:
if not jellyfin_search or not isinstance(jellyfin_search, dict):
logger.debug(
f"{self.server_type}: Failed to get movies for {user_name} {library_name}"
)
return
for jellyfin_video in jellyfin_search.get("Items", []):
jelly_identifiers = extract_identifiers_from_item( jelly_identifiers = extract_identifiers_from_item(
self.server_type, jellyfin_video self.server_type, jellyfin_video
) )
@@ -454,7 +513,7 @@ class JellyfinEmby:
if check_same_identifiers( if check_same_identifiers(
jelly_identifiers, stored_movie.identifiers jelly_identifiers, stored_movie.identifiers
): ):
jellyfin_video_id = jellyfin_video["Id"] jellyfin_video_id = jellyfin_video.get("Id")
if stored_movie.status.completed: if stored_movie.status.completed:
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library_name}" msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library_name}"
if not dryrun: if not dryrun:
@@ -471,11 +530,11 @@ class JellyfinEmby:
library_name, library_name,
jellyfin_video.get("Name"), jellyfin_video.get("Name"),
) )
elif update_partial: elif self.update_partial:
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user_name} in {library_name}" msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user_name} in {library_name}"
if not dryrun: if not dryrun:
playback_position_payload = { playback_position_payload: dict[str, float] = {
"PlaybackPositionTicks": stored_movie.status.time "PlaybackPositionTicks": stored_movie.status.time
* 10_000, * 10_000,
} }
@@ -507,7 +566,13 @@ class JellyfinEmby:
+ "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series", + "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series",
"get", "get",
) )
jellyfin_shows = [x for x in jellyfin_search["Items"]] if not jellyfin_search or not isinstance(jellyfin_search, dict):
logger.debug(
f"{self.server_type}: Failed to get shows for {user_name} {library_name}"
)
return
jellyfin_shows = [x for x in jellyfin_search.get("Items", [])]
for jellyfin_show in jellyfin_shows: for jellyfin_show in jellyfin_shows:
jellyfin_show_identifiers = extract_identifiers_from_item( jellyfin_show_identifiers = extract_identifiers_from_item(
@@ -518,19 +583,27 @@ class JellyfinEmby:
if check_same_identifiers( if check_same_identifiers(
jellyfin_show_identifiers, stored_series.identifiers jellyfin_show_identifiers, stored_series.identifiers
): ):
logger.info( logger.trace(
f"Found matching show for '{jellyfin_show.get('Name')}'", f"Found matching show for '{jellyfin_show.get('Name')}'",
) )
# Now update episodes. # Now update episodes.
# Get the list of Plex episodes for this show. # Get the list of Plex episodes for this show.
jellyfin_show_id = jellyfin_show["Id"] jellyfin_show_id = jellyfin_show.get("Id")
jellyfin_episodes = self.query( jellyfin_episodes = self.query(
f"/Shows/{jellyfin_show_id}/Episodes" f"/Shows/{jellyfin_show_id}/Episodes"
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", + f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
"get", "get",
) )
for jellyfin_episode in jellyfin_episodes["Items"]: if not jellyfin_episodes or not isinstance(
jellyfin_episodes, dict
):
logger.debug(
f"{self.server_type}: Failed to get episodes for {user_name} {library_name} {jellyfin_show.get('Name')}"
)
return
for jellyfin_episode in jellyfin_episodes.get("Items", []):
jellyfin_episode_identifiers = ( jellyfin_episode_identifiers = (
extract_identifiers_from_item( extract_identifiers_from_item(
self.server_type, jellyfin_episode self.server_type, jellyfin_episode
@@ -541,10 +614,10 @@ class JellyfinEmby:
jellyfin_episode_identifiers, jellyfin_episode_identifiers,
stored_ep.identifiers, stored_ep.identifiers,
): ):
jellyfin_episode_id = jellyfin_episode["Id"] jellyfin_episode_id = jellyfin_episode.get("Id")
if stored_ep.status.completed: if stored_ep.status.completed:
msg = ( msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" f"{self.server_type}: {jellyfin_episode.get('SeriesName')} {jellyfin_episode.get('SeasonName')} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as watched for {user_name} in {library_name}" + f" as watched for {user_name} in {library_name}"
) )
if not dryrun: if not dryrun:
@@ -564,9 +637,9 @@ class JellyfinEmby:
jellyfin_episode.get("SeriesName"), jellyfin_episode.get("SeriesName"),
jellyfin_episode.get("Name"), jellyfin_episode.get("Name"),
) )
elif update_partial: elif self.update_partial:
msg = ( msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" f"{self.server_type}: {jellyfin_episode.get('SeriesName')} {jellyfin_episode.get('SeasonName')} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as partially watched for {floor(stored_ep.status.time / 60_000)} minutes for {user_name} in {library_name}" + f" as partially watched for {floor(stored_ep.status.time / 60_000)} minutes for {user_name} in {library_name}"
) )
@@ -608,105 +681,99 @@ class JellyfinEmby:
logger.error( logger.error(
f"{self.server_type}: Error updating watched for {user_name} in library {library_name}, {e}", f"{self.server_type}: Error updating watched for {user_name} in library {library_name}, {e}",
) )
logger.error(traceback.format_exc())
raise Exception(e)
def update_watched( def update_watched(
self, self,
watched_list: dict[str, UserData], watched_list: dict[str, UserData],
user_mapping=None, user_mapping: dict[str, str] | None = None,
library_mapping=None, library_mapping: dict[str, str] | None = None,
dryrun=False, dryrun: bool = False,
): ) -> None:
try: for user, user_data in watched_list.items():
server_version = self.info(version_only=True) user_other = None
update_partial = self.is_partial_update_supported(server_version) user_name = None
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
if not update_partial: user_id = None
logger.info( for key in self.users:
f"{self.server_type}: Server version {server_version} does not support updating playback position.", if user.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
elif user_other and user_other.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
if not user_id or not user_name:
logger.info(f"{user} {user_other} not found in Jellyfin")
continue
jellyfin_libraries = self.query(
f"/Users/{user_id}/Views",
"get",
)
if not jellyfin_libraries or not isinstance(jellyfin_libraries, dict):
logger.debug(
f"{self.server_type}: Failed to get libraries for {user_name}"
) )
continue
for user, user_data in watched_list.items(): jellyfin_libraries = [x for x in jellyfin_libraries.get("Items", [])]
user_other = None
user_name = None
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
user_id = None for library_name in user_data.libraries:
for key in self.users: library_data = user_data.libraries[library_name]
if user.lower() == key.lower(): library_other = None
user_id = self.users[key] if library_mapping:
user_name = key if library_name in library_mapping.keys():
break library_other = library_mapping[library_name]
elif user_other and user_other.lower() == key.lower(): elif library_name in library_mapping.values():
user_id = self.users[key] library_other = search_mapping(library_mapping, library_name)
user_name = key
break
if not user_id: if library_name.lower() not in [
logger.info(f"{user} {user_other} not found in Jellyfin") x["Name"].lower() for x in jellyfin_libraries
continue ]:
if library_other:
jellyfin_libraries = self.query( if library_other.lower() in [
f"/Users/{user_id}/Views", x["Name"].lower() for x in jellyfin_libraries
"get", ]:
) logger.info(
jellyfin_libraries = [x for x in jellyfin_libraries["Items"]] f"{self.server_type}: Library {library_name} not found, but {library_other} found, using {library_other}",
for library_name in user_data.libraries:
library_data = user_data.libraries[library_name]
library_other = None
if library_mapping:
if library_name in library_mapping.keys():
library_other = library_mapping[library_name]
elif library_name in library_mapping.values():
library_other = search_mapping(
library_mapping, library_name
) )
library_name = library_other
if library_name.lower() not in [
x["Name"].lower() for x in jellyfin_libraries
]:
if library_other:
if library_other.lower() in [
x["Name"].lower() for x in jellyfin_libraries
]:
logger.info(
f"{self.server_type}: Library {library_name} not found, but {library_other} found, using {library_other}",
)
library_name = library_other
else:
logger.info(
f"{self.server_type}: Library {library_name} or {library_other} not found in library list",
)
continue
else: else:
logger.info( logger.info(
f"{self.server_type}: Library {library_name} not found in library list", f"{self.server_type}: Library {library_name} or {library_other} not found in library list",
) )
continue continue
else:
logger.info(
f"{self.server_type}: Library {library_name} not found in library list",
)
continue
library_id = None library_id = None
for jellyfin_library in jellyfin_libraries: for jellyfin_library in jellyfin_libraries:
if jellyfin_library["Name"] == library_name: if jellyfin_library["Name"].lower() == library_name.lower():
library_id = jellyfin_library["Id"] library_id = jellyfin_library["Id"]
continue continue
if library_id: if library_id:
try:
self.update_user_watched( self.update_user_watched(
user_name, user_name,
user_id, user_id,
library_data, library_data,
library_name, library_name,
library_id, library_id,
update_partial,
dryrun, dryrun,
) )
except Exception as e:
except Exception as e: logger.error(
logger.error(f"{self.server_type}: Error updating watched, {e}") f"{self.server_type}: Error updating watched for {user_name} in library {library_name}, {e}",
raise Exception(e) )

View File

@@ -5,6 +5,10 @@ from src.functions import (
search_mapping, search_mapping,
) )
from src.emby import Emby
from src.jellyfin import Jellyfin
from src.plex import Plex
def check_skip_logic( def check_skip_logic(
library_title: str, library_title: str,
@@ -54,7 +58,7 @@ def check_blacklist_logic(
blacklist_library: list[str], blacklist_library: list[str],
blacklist_library_type: list[str], blacklist_library_type: list[str],
library_other: str | None = None, library_other: str | None = None,
): ) -> str | None:
skip_reason = None skip_reason = None
if isinstance(library_type, (list, tuple, set)): if isinstance(library_type, (list, tuple, set)):
for library_type_item in library_type: for library_type_item in library_type:
@@ -90,7 +94,7 @@ def check_whitelist_logic(
whitelist_library: list[str], whitelist_library: list[str],
whitelist_library_type: list[str], whitelist_library_type: list[str],
library_other: str | None = None, library_other: str | None = None,
): ) -> str | None:
skip_reason = None skip_reason = None
if len(whitelist_library_type) > 0: if len(whitelist_library_type) > 0:
if isinstance(library_type, (list, tuple, set)): if isinstance(library_type, (list, tuple, set)):
@@ -161,8 +165,8 @@ def filter_libaries(
def setup_libraries( def setup_libraries(
server_1, server_1: Plex | Jellyfin | Emby,
server_2, server_2: Plex | Jellyfin | Emby,
blacklist_library: list[str], blacklist_library: list[str],
blacklist_library_type: list[str], blacklist_library_type: list[str],
whitelist_library: list[str], whitelist_library: list[str],

View File

@@ -27,7 +27,7 @@ log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
level = os.getenv("DEBUG_LEVEL", "INFO").upper() level = os.getenv("DEBUG_LEVEL", "INFO").upper()
def configure_logger(): def configure_logger() -> None:
# Remove default logger to configure our own # Remove default logger to configure our own
logger.remove() logger.remove()
@@ -111,18 +111,20 @@ def should_sync_server(
return True return True
def main_loop(): def main_loop() -> None:
dryrun = str_to_bool(os.getenv("DRYRUN", "False")) dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
logger.info(f"Dryrun: {dryrun}") logger.info(f"Dryrun: {dryrun}")
user_mapping = os.getenv("USER_MAPPING", None) user_mapping_env = os.getenv("USER_MAPPING", None)
if user_mapping: user_mapping = None
user_mapping = json.loads(user_mapping.lower()) if user_mapping_env:
user_mapping = json.loads(user_mapping_env.lower())
logger.info(f"User Mapping: {user_mapping}") logger.info(f"User Mapping: {user_mapping}")
library_mapping = os.getenv("LIBRARY_MAPPING", None) library_mapping_env = os.getenv("LIBRARY_MAPPING", None)
if library_mapping: library_mapping = None
library_mapping = json.loads(library_mapping) if library_mapping_env:
library_mapping = json.loads(library_mapping_env)
logger.info(f"Library Mapping: {library_mapping}") logger.info(f"Library Mapping: {library_mapping}")
# Create (black/white)lists # Create (black/white)lists
@@ -241,7 +243,7 @@ def main_loop():
@logger.catch @logger.catch
def main(): def main() -> None:
run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False")) run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False"))
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600")) sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
times: list[float] = [] times: list[float] = []

View File

@@ -10,7 +10,8 @@ from requests.adapters import HTTPAdapter as RequestsHTTPAdapter
from plexapi.video import Show, Episode, Movie from plexapi.video import Show, Episode, Movie
from plexapi.server import PlexServer from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount, MyPlexUser
from plexapi.library import MovieSection, ShowSection
from src.functions import ( from src.functions import (
search_mapping, search_mapping,
@@ -35,7 +36,9 @@ generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
# Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186 # Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
class HostNameIgnoringAdapter(RequestsHTTPAdapter): class HostNameIgnoringAdapter(RequestsHTTPAdapter):
def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs): def init_poolmanager(
self, connections: int, maxsize: int | None, block=..., **pool_kwargs
) -> None:
self.poolmanager = PoolManager( self.poolmanager = PoolManager(
num_pools=connections, num_pools=connections,
maxsize=maxsize, maxsize=maxsize,
@@ -53,7 +56,7 @@ def extract_guids_from_item(item: Movie | Show | Episode) -> dict[str, str]:
guids: dict[str, str] = dict( guids: dict[str, str] = dict(
guid.id.split("://") guid.id.split("://")
for guid in item.guids for guid in item.guids
if guid.id is not None and len(guid.id.strip()) > 0 if guid.id and len(guid.id.strip()) > 0
) )
return guids return guids
@@ -69,13 +72,13 @@ def extract_identifiers_from_item(item: Movie | Show | Episode) -> MediaIdentifi
if generate_locations if generate_locations
else tuple() else tuple()
), ),
imdb_id=guids.get("imdb", None), imdb_id=guids.get("imdb"),
tvdb_id=guids.get("tvdb", None), tvdb_id=guids.get("tvdb"),
tmdb_id=guids.get("tmdb", None), tmdb_id=guids.get("tmdb"),
) )
def get_mediaitem(item: Movie | Episode, completed=True) -> MediaItem: def get_mediaitem(item: Movie | Episode, completed: bool) -> MediaItem:
return MediaItem( return MediaItem(
identifiers=extract_identifiers_from_item(item), identifiers=extract_identifiers_from_item(item),
status=WatchedStatus(completed=completed, time=item.viewOffset), status=WatchedStatus(completed=completed, time=item.viewOffset),
@@ -88,174 +91,199 @@ def update_user_watched(
library_data: LibraryData, library_data: LibraryData,
library_name: str, library_name: str,
dryrun: bool, dryrun: bool,
): ) -> None:
try: # If there are no movies or shows to update, exit early.
# If there are no movies or shows to update, exit early. if not library_data.series and not library_data.movies:
if not library_data.series and not library_data.movies: return
return
logger.info( logger.info(f"Plex: Updating watched for {user.title} in library {library_name}")
f"Plex: Updating watched for {user.title} in library {library_name}" library_section = user_plex.library.section(library_name)
if not library_section:
logger.error(
f"Plex: Library {library_name} not found for {user.title}, skipping",
) )
library_section = user_plex.library.section(library_name) return
# Update movies. # Update movies.
if library_data.movies: if library_data.movies:
# Search for Plex movies that are currently marked as unwatched. # Search for Plex movies that are currently marked as unwatched.
for plex_movie in library_section.search(unwatched=True): for plex_movie in library_section.search(unwatched=True):
plex_identifiers = extract_identifiers_from_item(plex_movie) plex_identifiers = extract_identifiers_from_item(plex_movie)
# Check each stored movie for a match. # Check each stored movie for a match.
for stored_movie in library_data.movies: for stored_movie in library_data.movies:
if check_same_identifiers( if check_same_identifiers(plex_identifiers, stored_movie.identifiers):
plex_identifiers, stored_movie.identifiers # If the stored movie is marked as watched (or has enough progress),
): # update the Plex movie accordingly.
# If the stored movie is marked as watched (or has enough progress), if stored_movie.status.completed:
# update the Plex movie accordingly. msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}"
if stored_movie.status.completed: if not dryrun:
msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}" try:
if not dryrun:
plex_movie.markWatched() plex_movie.markWatched()
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}") except Exception as e:
log_marked( logger.error(
"Plex", f"Plex: Failed to mark {plex_movie.title} as watched, Error: {e}"
user_plex.friendlyName, )
user.title, continue
library_name,
plex_movie.title, logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
None, log_marked(
None, "Plex",
) user_plex.friendlyName,
else: user.title,
msg = f"Plex: {plex_movie.title} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user.title} in {library_name}" library_name,
if not dryrun: plex_movie.title,
None,
None,
)
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:
try:
plex_movie.updateTimeline(stored_movie.status.time) plex_movie.updateTimeline(stored_movie.status.time)
except Exception as e:
logger.error(
f"Plex: Failed to update {plex_movie.title} timeline, Error: {e}"
)
continue
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}") logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
log_marked( log_marked(
"Plex", "Plex",
user_plex.friendlyName, user_plex.friendlyName,
user.title, user.title,
library_name, library_name,
plex_movie.title, plex_movie.title,
duration=stored_movie.status.time, duration=stored_movie.status.time,
) )
# Once matched, no need to check further. # Once matched, no need to check further.
break break
# Update TV Shows (series/episodes). # Update TV Shows (series/episodes).
if library_data.series: if library_data.series:
# For each Plex show in the library section: # For each Plex show in the library section:
plex_shows = library_section.search(unwatched=True) plex_shows = library_section.search(unwatched=True)
for plex_show in plex_shows: for plex_show in plex_shows:
# Extract identifiers from the Plex show. # Extract identifiers from the Plex show.
plex_show_identifiers = extract_identifiers_from_item(plex_show) plex_show_identifiers = extract_identifiers_from_item(plex_show)
# Try to find a matching series in your stored library. # Try to find a matching series in your stored library.
for stored_series in library_data.series: for stored_series in library_data.series:
if check_same_identifiers( if check_same_identifiers(
plex_show_identifiers, stored_series.identifiers plex_show_identifiers, stored_series.identifiers
): ):
logger.info(f"Found matching show for '{plex_show.title}'") logger.trace(f"Found matching show for '{plex_show.title}'")
# Now update episodes. # Now update episodes.
# Get the list of Plex episodes for this show. # Get the list of Plex episodes for this show.
plex_episodes = plex_show.episodes() plex_episodes = plex_show.episodes()
for plex_episode in plex_episodes: for plex_episode in plex_episodes:
plex_episode_identifiers = extract_identifiers_from_item( plex_episode_identifiers = extract_identifiers_from_item(
plex_episode plex_episode
) )
for stored_ep in stored_series.episodes: for stored_ep in stored_series.episodes:
if check_same_identifiers( if check_same_identifiers(
plex_episode_identifiers, stored_ep.identifiers plex_episode_identifiers, stored_ep.identifiers
): ):
if stored_ep.status.completed: if stored_ep.status.completed:
msg = f"Plex: {plex_show.title} {plex_episode.title} as watched for {user.title} in {library_name}" msg = f"Plex: {plex_show.title} {plex_episode.title} as watched for {user.title} in {library_name}"
if not dryrun: if not dryrun:
try:
plex_episode.markWatched() plex_episode.markWatched()
except Exception as e:
logger.error(
f"Plex: Failed to mark {plex_show.title} {plex_episode.title} as watched, Error: {e}"
)
continue
logger.success( logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}" f"{'[DRYRUN] ' if dryrun else ''}{msg}"
) )
log_marked( log_marked(
"Plex", "Plex",
user_plex.friendlyName, user_plex.friendlyName,
user.title, user.title,
library_name, library_name,
plex_show.title, plex_show.title,
plex_episode.title, plex_episode.title,
) )
else: 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}" 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: if not dryrun:
try:
plex_episode.updateTimeline( plex_episode.updateTimeline(
stored_ep.status.time stored_ep.status.time
) )
except Exception as e:
logger.error(
f"Plex: Failed to update {plex_show.title} {plex_episode.title} timeline, Error: {e}"
)
continue
logger.success( logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}" f"{'[DRYRUN] ' if dryrun else ''}{msg}"
) )
log_marked( log_marked(
"Plex", "Plex",
user_plex.friendlyName, user_plex.friendlyName,
user.title, user.title,
library_name, library_name,
plex_show.title, plex_show.title,
plex_episode.title, plex_episode.title,
stored_ep.status.time, stored_ep.status.time,
) )
break # Found a matching episode. break # Found a matching episode.
break # Found a matching show. break # Found a matching show.
except Exception as e:
logger.error(
f"Plex: Failed to update watched for {user.title} in library {library_name}, Error: {e}",
2,
)
raise e
# class plex accept base url and token and username and password but default with none # class plex accept base url and token and username and password but default with none
class Plex: class Plex:
def __init__( def __init__(
self, self,
baseurl=None, base_url: str | None = None,
token=None, token: str | None = None,
username=None, user_name: str | None = None,
password=None, password: str | None = None,
servername=None, server_name: str | None = None,
ssl_bypass=False, ssl_bypass: bool = False,
session=None, session: requests.Session | None = None,
): ) -> None:
self.server_type = "Plex" self.server_type: str = "Plex"
self.baseurl = baseurl self.ssl_bypass: bool = ssl_bypass
self.token = token
self.username = username
self.password = password
self.servername = servername
self.ssl_bypass = ssl_bypass
if ssl_bypass: if ssl_bypass:
# Session for ssl bypass # Session for ssl bypass
session = requests.Session() session = requests.Session()
# By pass ssl hostname check https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186 # By pass ssl hostname check https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
session.mount("https://", HostNameIgnoringAdapter()) session.mount("https://", HostNameIgnoringAdapter())
self.session = session self.session = session
self.plex = self.login(self.baseurl, self.token) self.plex: PlexServer = self.login(
self.admin_user = self.plex.myPlexAccount() base_url, token, user_name, password, server_name
self.users = self.get_users() )
def login(self, baseurl, token): self.base_url: str = self.plex._baseurl
self.admin_user: MyPlexAccount = self.plex.myPlexAccount()
self.users: list[MyPlexUser | MyPlexAccount] = self.get_users()
def login(
self,
base_url: str | None,
token: str | None,
user_name: str | None,
password: str | None,
server_name: str | None,
) -> PlexServer:
try: try:
if baseurl and token: if base_url and token:
plex = PlexServer(baseurl, token, session=self.session) plex: PlexServer = PlexServer(base_url, token, session=self.session)
elif self.username and self.password and self.servername: elif user_name and password and server_name:
# Login via plex account # Login via plex account
account = MyPlexAccount(self.username, self.password) account = MyPlexAccount(user_name, password)
plex = account.resource(self.servername).connect() plex = account.resource(server_name).connect()
else: else:
raise Exception("No complete plex credentials provided") raise Exception("No complete plex credentials provided")
return plex return plex
except Exception as e: except Exception as e:
if self.username: if user_name:
msg = f"Failed to login via plex account {self.username}" msg = f"Failed to login via plex account {user_name}"
logger.error(f"Plex: Failed to login, {msg}, Error: {e}") logger.error(f"Plex: Failed to login, {msg}, Error: {e}")
else: else:
logger.error(f"Plex: Failed to login, Error: {e}") logger.error(f"Plex: Failed to login, Error: {e}")
@@ -264,9 +292,9 @@ class Plex:
def info(self) -> str: def info(self) -> str:
return f"Plex {self.plex.friendlyName}: {self.plex.version}" return f"Plex {self.plex.friendlyName}: {self.plex.version}"
def get_users(self): def get_users(self) -> list[MyPlexUser | MyPlexAccount]:
try: try:
users = self.plex.myPlexAccount().users() users: list[MyPlexUser | MyPlexAccount] = self.plex.myPlexAccount().users()
# append self to users # append self to users
users.append(self.plex.myPlexAccount()) users.append(self.plex.myPlexAccount())
@@ -302,7 +330,9 @@ class Plex:
logger.error(f"Plex: Failed to get libraries, Error: {e}") logger.error(f"Plex: Failed to get libraries, Error: {e}")
raise Exception(e) raise Exception(e)
def get_user_library_watched(self, user_name, user_plex, library) -> LibraryData: def get_user_library_watched(
self, user_name: str, user_plex: PlexServer, library: MovieSection | ShowSection
) -> LibraryData:
try: try:
logger.info( logger.info(
f"Plex: Generating watched for {user_name} in library {library.title}", f"Plex: Generating watched for {user_name} in library {library.title}",
@@ -353,9 +383,9 @@ class Plex:
if generate_locations if generate_locations
else tuple() else tuple()
), ),
imdb_id=show_guids.get("imdb", None), imdb_id=show_guids.get("imdb"),
tvdb_id=show_guids.get("tvdb", None), tvdb_id=show_guids.get("tvdb"),
tmdb_id=show_guids.get("tmdb", None), tmdb_id=show_guids.get("tmdb"),
), ),
episodes=episode_mediaitem, episodes=episode_mediaitem,
) )
@@ -369,7 +399,9 @@ class Plex:
) )
return LibraryData(title=library.title) return LibraryData(title=library.title)
def get_watched(self, users, sync_libraries) -> dict[str, UserData]: def get_watched(
self, users: list[MyPlexUser | MyPlexAccount], sync_libraries: list[str]
) -> dict[str, UserData]:
try: try:
users_watched: dict[str, UserData] = {} users_watched: dict[str, UserData] = {}
@@ -379,10 +411,7 @@ class Plex:
else: else:
token = user.get_token(self.plex.machineIdentifier) token = user.get_token(self.plex.machineIdentifier)
if token: if token:
user_plex = self.login( user_plex = self.login(self.base_url, token, None, None, None)
self.plex._baseurl,
token,
)
else: else:
logger.error( logger.error(
f"Plex: Failed to get token for {user.title}, skipping", f"Plex: Failed to get token for {user.title}, skipping",
@@ -411,87 +440,91 @@ class Plex:
return users_watched return users_watched
except Exception as e: except Exception as e:
logger.error(f"Plex: Failed to get watched, Error: {e}") logger.error(f"Plex: Failed to get watched, Error: {e}")
raise Exception(e) return {}
def update_watched( def update_watched(
self, self,
watched_list: dict[str, UserData], watched_list: dict[str, UserData],
user_mapping=None, user_mapping: dict[str, str] | None = None,
library_mapping=None, library_mapping: dict[str, str] | None = None,
dryrun=False, dryrun: bool = False,
): ) -> None:
try: for user, user_data in watched_list.items():
for user, user_data in watched_list.items(): user_other = None
user_other = None # If type of user is dict
# If type of user is dict if user_mapping:
if user_mapping: user_other = search_mapping(user_mapping, user)
user_other = search_mapping(user_mapping, user)
for index, value in enumerate(self.users): for index, value in enumerate(self.users):
username_title = ( username_title = (
value.username.lower() value.username.lower() if value.username else value.title.lower()
if value.username )
else value.title.lower()
if user.lower() == username_title:
user = self.users[index]
break
elif user_other and user_other.lower() == username_title:
user = self.users[index]
break
if self.admin_user == user:
user_plex = self.plex
else:
if isinstance(user, str):
logger.debug(
f"Plex: {user} is not a plex object, attempting to get object for user",
) )
user = self.plex.myPlexAccount().user(user)
if user.lower() == username_title: if not isinstance(user, MyPlexUser):
user = self.users[index] logger.error(f"Plex: {user} failed to get PlexUser")
break continue
elif user_other and user_other.lower() == username_title:
user = self.users[index]
break
if self.admin_user == user: token = user.get_token(self.plex.machineIdentifier)
user_plex = self.plex if token:
user_plex = PlexServer(
self.base_url,
token,
session=self.session,
)
else: else:
if isinstance(user, str): logger.error(
logger.warning( f"Plex: Failed to get token for {user.title}, skipping",
f"Plex: {user} is not a plex object, attempting to get object for user", )
) continue
user = self.plex.myPlexAccount().user(user)
token = user.get_token(self.plex.machineIdentifier) if not user_plex:
if token: logger.error(f"Plex: {user} Failed to get PlexServer")
user_plex = PlexServer( continue
self.plex._baseurl,
token, for library_name in user_data.libraries:
session=self.session, library_data = user_data.libraries[library_name]
) library_other = None
if library_mapping:
library_other = search_mapping(library_mapping, library_name)
# if library in plex library list
library_list = user_plex.library.sections()
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.info(
f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}",
)
library_name = library_other
else:
logger.info(
f"Plex: Library {library_name} or {library_other} not found in library list",
)
continue
else: else:
logger.error( logger.info(
f"Plex: Failed to get token for {user.title}, skipping", f"Plex: Library {library_name} not found in library list",
) )
continue continue
for library_name in user_data.libraries: try:
library_data = user_data.libraries[library_name]
library_other = None
if library_mapping:
library_other = search_mapping(library_mapping, library_name)
# if library in plex library list
library_list = user_plex.library.sections()
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.info(
f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}",
)
library_name = library_other
else:
logger.info(
f"Plex: Library {library_name} or {library_other} not found in library list",
)
continue
else:
logger.info(
f"Plex: Library {library_name} not found in library list",
)
continue
update_user_watched( update_user_watched(
user, user,
user_plex, user_plex,
@@ -499,7 +532,8 @@ class Plex:
library_name, library_name,
dryrun, dryrun,
) )
except Exception as e:
except Exception as e: logger.error(
logger.error(f"Plex: Failed to update watched, Error: {e}") f"Plex: Failed to update watched for {user.title} in {library_name}, Error: {e}",
raise Exception(e) )
continue

View File

@@ -1,4 +1,4 @@
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount, MyPlexUser
from loguru import logger from loguru import logger
from src.emby import Emby from src.emby import Emby
@@ -109,7 +109,10 @@ def setup_users(
blacklist_users: list[str], blacklist_users: list[str],
whitelist_users: list[str], whitelist_users: list[str],
user_mapping: dict[str, str] | None = None, user_mapping: dict[str, str] | None = None,
) -> tuple[list[MyPlexAccount] | dict[str, str], list[MyPlexAccount] | dict[str, str]]: ) -> tuple[
list[MyPlexAccount | MyPlexUser] | dict[str, str],
list[MyPlexAccount | MyPlexUser] | dict[str, str],
]:
server_1_users = generate_user_list(server_1) server_1_users = generate_user_list(server_1)
server_2_users = generate_user_list(server_2) server_2_users = generate_user_list(server_2)
logger.debug(f"Server 1 users: {server_1_users}") logger.debug(f"Server 1 users: {server_1_users}")

View File

@@ -1,6 +1,7 @@
import copy import copy
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from loguru import logger from loguru import logger
from typing import Any
from src.functions import search_mapping from src.functions import search_mapping
@@ -103,8 +104,8 @@ def check_remove_entry(item1: MediaItem, item2: MediaItem) -> bool:
def cleanup_watched( def cleanup_watched(
watched_list_1: dict[str, UserData], watched_list_1: dict[str, UserData],
watched_list_2: dict[str, UserData], watched_list_2: dict[str, UserData],
user_mapping=None, user_mapping: dict[str, str] | None = None,
library_mapping=None, library_mapping: dict[str, str] | None = None,
) -> dict[str, UserData]: ) -> dict[str, UserData]:
modified_watched_list_1 = copy.deepcopy(watched_list_1) modified_watched_list_1 = copy.deepcopy(watched_list_1)
@@ -199,11 +200,17 @@ def cleanup_watched(
return modified_watched_list_1 return modified_watched_list_1
def get_other(watched_list, object_1, object_2): def get_other(
watched_list: dict[str, Any], object_1: str, object_2: str | None
) -> str | None:
if object_1 in watched_list: if object_1 in watched_list:
return object_1 return object_1
elif object_2 in watched_list:
if object_2 and object_2 in watched_list:
return object_2 return object_2
else:
logger.info(f"{object_1} and {object_2} not found in watched list 2") logger.info(
return None f"{object_1}{' and ' + object_2 if object_2 else ''} not found in watched list 2"
)
return None

301
uv.lock generated
View File

@@ -1,5 +1,4 @@
version = 1 version = 1
revision = 1
requires-python = ">=3.12" requires-python = ">=3.12"
[[package]] [[package]]
@@ -13,46 +12,46 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.1.31" version = "2025.4.26"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 },
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.1" version = "3.4.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 },
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 },
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 },
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 },
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 },
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 },
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 },
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 },
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 },
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 },
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 },
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 },
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 },
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 },
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 },
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 },
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 },
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 },
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 },
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 },
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 },
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 },
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 },
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 },
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 },
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 },
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 },
] ]
[[package]] [[package]]
@@ -75,16 +74,16 @@ wheels = [
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.0.0" version = "2.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
] ]
[[package]] [[package]]
name = "jellyplex-watched" name = "jellyplex-watched"
version = "6.1.2" version = "7.0.4"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "loguru" }, { name = "loguru" },
@@ -97,7 +96,9 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "types-requests" },
] ]
lint = [ lint = [
{ name = "ruff" }, { name = "ruff" },
@@ -106,16 +107,20 @@ lint = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "packaging", specifier = "==24.2" }, { name = "packaging", specifier = "==25.0" },
{ name = "plexapi", specifier = "==4.16.1" }, { name = "plexapi", specifier = "==4.17.0" },
{ name = "pydantic", specifier = "==2.10.6" }, { name = "pydantic", specifier = "==2.11.4" },
{ name = "python-dotenv", specifier = "==1.0.0" }, { name = "python-dotenv", specifier = "==1.1.0" },
{ name = "requests", specifier = "==2.32.3" }, { name = "requests", specifier = "==2.32.3" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.3.4" }] dev = [
lint = [{ name = "ruff", specifier = ">=0.9.6" }] { name = "mypy", specifier = ">=1.15.0" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "types-requests", specifier = ">=2.32.0.20250515" },
]
lint = [{ name = "ruff", specifier = ">=0.11.10" }]
[[package]] [[package]]
name = "loguru" name = "loguru"
@@ -131,91 +136,129 @@ wheels = [
] ]
[[package]] [[package]]
name = "packaging" name = "mypy"
version = "24.2" version = "1.15.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } dependencies = [
{ name = "mypy-extensions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 },
{ url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 },
{ url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 },
{ url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 },
{ url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 },
{ url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 },
{ url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 },
{ url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 },
{ url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 },
{ url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 },
{ url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 },
{ url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 },
{ url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
] ]
[[package]] [[package]]
name = "plexapi" name = "plexapi"
version = "4.16.1" version = "4.17.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "requests" }, { name = "requests" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/66/1c/beeaf8dd489dad13ca4310a7bd9c601da6c6831e7c8ac61a45aadccb742d/plexapi-4.16.1.tar.gz", hash = "sha256:8e62d727e67b69994770196cd83a57783e9194d735aa347f682b4534ce6f0565", size = 153460 } sdist = { url = "https://files.pythonhosted.org/packages/94/79/129a01479ae08d934782a4ae2ece5bb1eee7e9576c14cf41b467a403dcb6/plexapi-4.17.0.tar.gz", hash = "sha256:065ff984a9500e049a9cc30927ab3245e518e39edc2f4058e31528be1a0a2aef", size = 154599 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/07/20b8a50b8b78374a49685dfe12739c27a9cd440b13913c2cbeeb50470d1e/PlexAPI-4.16.1-py3-none-any.whl", hash = "sha256:87432226c4cd682b5780b01e8def313285c52bdd57c8e72f66a5cef73ce64530", size = 165325 }, { url = "https://files.pythonhosted.org/packages/24/42/400828990b1884bb3d18d6cdbd1c26f91f1ca256619d057bd5f5d8a9ec7b/plexapi-4.17.0-py3-none-any.whl", hash = "sha256:cf42a990205c0327a2ab1d2871087a91b50596e6e960b99a185bf657525e6938", size = 166667 },
] ]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.5.0" version = "1.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
] ]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.10.6" version = "2.11.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-types" }, { name = "annotated-types" },
{ name = "pydantic-core" }, { name = "pydantic-core" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 },
] ]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.27.2" version = "2.33.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 },
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 },
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 },
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 },
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 },
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 },
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 },
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 },
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 },
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 },
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 },
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 },
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 },
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 },
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 },
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 },
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 },
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 },
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 },
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 },
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 },
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 },
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 },
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 },
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 },
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 },
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 },
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
] ]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.4" version = "8.3.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -223,18 +266,18 @@ dependencies = [
{ name = "packaging" }, { name = "packaging" },
{ name = "pluggy" }, { name = "pluggy" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
] ]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.0.0" version = "1.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/06/1ef763af20d0572c032fa22882cfbfb005fba6e7300715a37840858c919e/python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", size = 37399 } sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 }, { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
] ]
[[package]] [[package]]
@@ -254,45 +297,69 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.9.6" version = "0.11.10"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/e1/e265aba384343dd8ddd3083f5e33536cd17e1566c41453a5517b5dd443be/ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9", size = 3639454 } sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/4a3c5a97faaae6b428b336dcca81d03ad04779f8072c267ad2bd860126bf/ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6", size = 4165632 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/e3/3d2c022e687e18cf5d93d6bfa2722d46afc64eaa438c7fbbdd603b3597be/ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba", size = 11714128 }, { url = "https://files.pythonhosted.org/packages/2f/9f/596c628f8824a2ce4cd12b0f0b4c0629a62dfffc5d0f742c19a1d71be108/ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58", size = 10316243 },
{ url = "https://files.pythonhosted.org/packages/e1/22/aff073b70f95c052e5c58153cba735748c9e70107a77d03420d7850710a0/ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504", size = 11682539 }, { url = "https://files.pythonhosted.org/packages/3c/38/c1e0b77ab58b426f8c332c1d1d3432d9fc9a9ea622806e208220cb133c9e/ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed", size = 11083636 },
{ url = "https://files.pythonhosted.org/packages/75/a7/f5b7390afd98a7918582a3d256cd3e78ba0a26165a467c1820084587cbf9/ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83", size = 11132512 }, { url = "https://files.pythonhosted.org/packages/23/41/b75e15961d6047d7fe1b13886e56e8413be8467a4e1be0a07f3b303cd65a/ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca", size = 10441624 },
{ url = "https://files.pythonhosted.org/packages/a6/e3/45de13ef65047fea2e33f7e573d848206e15c715e5cd56095589a7733d04/ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc", size = 11929275 }, { url = "https://files.pythonhosted.org/packages/b6/2c/e396b6703f131406db1811ea3d746f29d91b41bbd43ad572fea30da1435d/ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2", size = 10624358 },
{ url = "https://files.pythonhosted.org/packages/7d/f2/23d04cd6c43b2e641ab961ade8d0b5edb212ecebd112506188c91f2a6e6c/ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b", size = 11466502 }, { url = "https://files.pythonhosted.org/packages/bd/8c/ee6cca8bdaf0f9a3704796022851a33cd37d1340bceaf4f6e991eb164e2e/ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5", size = 10176850 },
{ url = "https://files.pythonhosted.org/packages/b5/6f/3a8cf166f2d7f1627dd2201e6cbc4cb81f8b7d58099348f0c1ff7b733792/ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e", size = 12676364 }, { url = "https://files.pythonhosted.org/packages/e9/ce/4e27e131a434321b3b7c66512c3ee7505b446eb1c8a80777c023f7e876e6/ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641", size = 11759787 },
{ url = "https://files.pythonhosted.org/packages/f5/c4/db52e2189983c70114ff2b7e3997e48c8318af44fe83e1ce9517570a50c6/ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666", size = 13335518 }, { url = "https://files.pythonhosted.org/packages/58/de/1e2e77fc72adc7cf5b5123fd04a59ed329651d3eab9825674a9e640b100b/ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947", size = 12430479 },
{ url = "https://files.pythonhosted.org/packages/66/44/545f8a4d136830f08f4d24324e7db957c5374bf3a3f7a6c0bc7be4623a37/ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5", size = 12823287 }, { url = "https://files.pythonhosted.org/packages/07/ed/af0f2340f33b70d50121628ef175523cc4c37619e98d98748c85764c8d88/ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4", size = 11919760 },
{ url = "https://files.pythonhosted.org/packages/c5/26/8208ef9ee7431032c143649a9967c3ae1aae4257d95e6f8519f07309aa66/ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5", size = 14592374 }, { url = "https://files.pythonhosted.org/packages/24/09/d7b3d3226d535cb89234390f418d10e00a157b6c4a06dfbe723e9322cb7d/ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f", size = 14041747 },
{ url = "https://files.pythonhosted.org/packages/31/70/e917781e55ff39c5b5208bda384fd397ffd76605e68544d71a7e40944945/ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217", size = 12500173 }, { url = "https://files.pythonhosted.org/packages/62/b3/a63b4e91850e3f47f78795e6630ee9266cb6963de8f0191600289c2bb8f4/ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b", size = 11550657 },
{ url = "https://files.pythonhosted.org/packages/84/f5/e4ddee07660f5a9622a9c2b639afd8f3104988dc4f6ba0b73ffacffa9a8c/ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6", size = 11906555 }, { url = "https://files.pythonhosted.org/packages/46/63/a4f95c241d79402ccdbdb1d823d156c89fbb36ebfc4289dce092e6c0aa8f/ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2", size = 10489671 },
{ url = "https://files.pythonhosted.org/packages/f1/2b/6ff2fe383667075eef8656b9892e73dd9b119b5e3add51298628b87f6429/ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897", size = 11538958 }, { url = "https://files.pythonhosted.org/packages/6a/9b/c2238bfebf1e473495659c523d50b1685258b6345d5ab0b418ca3f010cd7/ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523", size = 10160135 },
{ url = "https://files.pythonhosted.org/packages/3c/db/98e59e90de45d1eb46649151c10a062d5707b5b7f76f64eb1e29edf6ebb1/ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08", size = 12117247 }, { url = "https://files.pythonhosted.org/packages/ba/ef/ba7251dd15206688dbfba7d413c0312e94df3b31b08f5d695580b755a899/ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125", size = 11170179 },
{ url = "https://files.pythonhosted.org/packages/ec/bc/54e38f6d219013a9204a5a2015c09e7a8c36cedcd50a4b01ac69a550b9d9/ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656", size = 12554647 }, { url = "https://files.pythonhosted.org/packages/73/9f/5c336717293203ba275dbfa2ea16e49b29a9fd9a0ea8b6febfc17e133577/ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad", size = 11626021 },
{ url = "https://files.pythonhosted.org/packages/a5/7d/7b461ab0e2404293c0627125bb70ac642c2e8d55bf590f6fce85f508f1b2/ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d", size = 9949214 }, { url = "https://files.pythonhosted.org/packages/d9/2b/162fa86d2639076667c9aa59196c020dc6d7023ac8f342416c2f5ec4bda0/ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19", size = 10494958 },
{ url = "https://files.pythonhosted.org/packages/ee/30/c3cee10f915ed75a5c29c1e57311282d1a15855551a64795c1b2bbe5cf37/ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa", size = 10999914 }, { url = "https://files.pythonhosted.org/packages/24/f3/66643d8f32f50a4b0d09a4832b7d919145ee2b944d43e604fbd7c144d175/ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224", size = 11650285 },
{ url = "https://files.pythonhosted.org/packages/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 }, { url = "https://files.pythonhosted.org/packages/95/3a/2e8704d19f376c799748ff9cb041225c1d59f3e7711bc5596c8cfdc24925/ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1", size = 10765278 },
]
[[package]]
name = "types-requests"
version = "2.32.0.20250515"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c1/cdc4f9b8cfd9130fbe6276db574f114541f4231fcc6fb29648289e6e3390/types_requests-2.32.0.20250515.tar.gz", hash = "sha256:09c8b63c11318cb2460813871aaa48b671002e59fda67ca909e9883777787581", size = 23012 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/0f/68a997c73a129287785f418c1ebb6004f81e46b53b3caba88c0e03fcd04a/types_requests-2.32.0.20250515-py3-none-any.whl", hash = "sha256:f8eba93b3a892beee32643ff836993f15a785816acca21ea0ffa006f05ef0fb2", size = 20635 },
] ]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.13.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
]
[[package]]
name = "typing-inspection"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 },
] ]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.3.0" version = "2.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 },
] ]
[[package]] [[package]]