@@ -18,5 +18,7 @@ lint = [
|
|||||||
"ruff>=0.9.6",
|
"ruff>=0.9.6",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
|
"mypy>=1.15.0",
|
||||||
"pytest>=8.3.4",
|
"pytest>=8.3.4",
|
||||||
|
"types-requests>=2.32.0.20250306",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
13
src/emby.py
13
src/emby.py
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,12 +196,10 @@ 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
|
||||||
@@ -201,17 +209,26 @@ 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 library_type not in ["movies", "tvshows"]:
|
if library_type not in ["movies", "tvshows"]:
|
||||||
@@ -228,7 +245,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["Movie", "Series", "Episode"],
|
||||||
|
library_id: str,
|
||||||
|
library_title: str,
|
||||||
) -> LibraryData:
|
) -> LibraryData:
|
||||||
user_name = user_name.lower()
|
user_name = user_name.lower()
|
||||||
try:
|
try:
|
||||||
@@ -239,85 +261,104 @@ class JellyfinEmby:
|
|||||||
|
|
||||||
# Movies
|
# Movies
|
||||||
if library_type == "Movie":
|
if library_type == "Movie":
|
||||||
|
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 in ["Series", "Episode"]:
|
||||||
# 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 +370,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 +389,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]
|
||||||
@@ -425,9 +466,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 +485,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 +501,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 +518,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 +554,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 +571,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 +602,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 +625,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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -614,19 +675,11 @@ class JellyfinEmby:
|
|||||||
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:
|
try:
|
||||||
server_version = self.info(version_only=True)
|
|
||||||
update_partial = self.is_partial_update_supported(server_version)
|
|
||||||
|
|
||||||
if not update_partial:
|
|
||||||
logger.info(
|
|
||||||
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
|
|
||||||
)
|
|
||||||
|
|
||||||
for user, user_data in watched_list.items():
|
for user, user_data in watched_list.items():
|
||||||
user_other = None
|
user_other = None
|
||||||
user_name = None
|
user_name = None
|
||||||
@@ -647,7 +700,7 @@ class JellyfinEmby:
|
|||||||
user_name = key
|
user_name = key
|
||||||
break
|
break
|
||||||
|
|
||||||
if not user_id:
|
if not user_id or not user_name:
|
||||||
logger.info(f"{user} {user_other} not found in Jellyfin")
|
logger.info(f"{user} {user_other} not found in Jellyfin")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -655,7 +708,14 @@ class JellyfinEmby:
|
|||||||
f"/Users/{user_id}/Views",
|
f"/Users/{user_id}/Views",
|
||||||
"get",
|
"get",
|
||||||
)
|
)
|
||||||
jellyfin_libraries = [x for x in jellyfin_libraries["Items"]]
|
|
||||||
|
if not jellyfin_libraries or not isinstance(jellyfin_libraries, dict):
|
||||||
|
logger.debug(
|
||||||
|
f"{self.server_type}: Failed to get libraries for {user_name}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
jellyfin_libraries = [x for x in jellyfin_libraries.get("Items", [])]
|
||||||
|
|
||||||
for library_name in user_data.libraries:
|
for library_name in user_data.libraries:
|
||||||
library_data = user_data.libraries[library_name]
|
library_data = user_data.libraries[library_name]
|
||||||
@@ -703,7 +763,6 @@ class JellyfinEmby:
|
|||||||
library_data,
|
library_data,
|
||||||
library_name,
|
library_name,
|
||||||
library_id,
|
library_id,
|
||||||
update_partial,
|
|
||||||
dryrun,
|
dryrun,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
20
src/main.py
20
src/main.py
@@ -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] = []
|
||||||
|
|||||||
124
src/plex.py
124
src/plex.py
@@ -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,7 +91,7 @@ def update_user_watched(
|
|||||||
library_data: LibraryData,
|
library_data: LibraryData,
|
||||||
library_name: str,
|
library_name: str,
|
||||||
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:
|
||||||
@@ -115,6 +118,7 @@ def update_user_watched(
|
|||||||
msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}"
|
msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}"
|
||||||
if not dryrun:
|
if not dryrun:
|
||||||
plex_movie.markWatched()
|
plex_movie.markWatched()
|
||||||
|
|
||||||
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
|
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
|
||||||
log_marked(
|
log_marked(
|
||||||
"Plex",
|
"Plex",
|
||||||
@@ -154,7 +158,7 @@ def update_user_watched(
|
|||||||
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()
|
||||||
@@ -216,46 +220,53 @@ def update_user_watched(
|
|||||||
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 +275,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 +313,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 +366,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 +382,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 +394,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",
|
||||||
@@ -416,10 +428,10 @@ class Plex:
|
|||||||
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:
|
try:
|
||||||
for user, user_data in watched_list.items():
|
for user, user_data in watched_list.items():
|
||||||
user_other = None
|
user_other = None
|
||||||
@@ -445,15 +457,19 @@ class Plex:
|
|||||||
user_plex = self.plex
|
user_plex = self.plex
|
||||||
else:
|
else:
|
||||||
if isinstance(user, str):
|
if isinstance(user, str):
|
||||||
logger.warning(
|
logger.debug(
|
||||||
f"Plex: {user} is not a plex object, attempting to get object for user",
|
f"Plex: {user} is not a plex object, attempting to get object for user",
|
||||||
)
|
)
|
||||||
user = self.plex.myPlexAccount().user(user)
|
user = self.plex.myPlexAccount().user(user)
|
||||||
|
|
||||||
|
if not isinstance(user, MyPlexUser):
|
||||||
|
logger.error(f"Plex: {user} failed to get PlexUser")
|
||||||
|
continue
|
||||||
|
|
||||||
token = user.get_token(self.plex.machineIdentifier)
|
token = user.get_token(self.plex.machineIdentifier)
|
||||||
if token:
|
if token:
|
||||||
user_plex = PlexServer(
|
user_plex = PlexServer(
|
||||||
self.plex._baseurl,
|
self.base_url,
|
||||||
token,
|
token,
|
||||||
session=self.session,
|
session=self.session,
|
||||||
)
|
)
|
||||||
@@ -463,6 +479,10 @@ class Plex:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not user_plex:
|
||||||
|
logger.error(f"Plex: {user} Failed to get PlexServer")
|
||||||
|
continue
|
||||||
|
|
||||||
for library_name in user_data.libraries:
|
for library_name in user_data.libraries:
|
||||||
library_data = user_data.libraries[library_name]
|
library_data = user_data.libraries[library_name]
|
||||||
library_other = None
|
library_other = None
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
f"{object_1}{' and ' + object_2 if object_2 else ''} not found in watched list 2"
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
55
uv.lock
generated
55
uv.lock
generated
@@ -1,5 +1,4 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 1
|
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -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" },
|
||||||
@@ -114,7 +115,11 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pytest", specifier = ">=8.3.4" }]
|
dev = [
|
||||||
|
{ name = "mypy", specifier = ">=1.15.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.3.4" },
|
||||||
|
{ name = "types-requests", specifier = ">=2.32.0.20250306" },
|
||||||
|
]
|
||||||
lint = [{ name = "ruff", specifier = ">=0.9.6" }]
|
lint = [{ name = "ruff", specifier = ">=0.9.6" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -130,6 +135,40 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
|
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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 = [
|
||||||
|
{ 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.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "24.2"
|
version = "24.2"
|
||||||
@@ -277,6 +316,18 @@ wheels = [
|
|||||||
{ 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/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-requests"
|
||||||
|
version = "2.32.0.20250306"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/09/1a/beaeff79ef9efd186566ba5f0d95b44ae21f6d31e9413bcfbef3489b6ae3/types_requests-2.32.0.20250306.tar.gz", hash = "sha256:0962352694ec5b2f95fda877ee60a159abdf84a0fc6fdace599f20acb41a03d1", size = 23012 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/26/645d89f56004aa0ba3b96fec27793e3c7e62b40982ee069e52568922b6db/types_requests-2.32.0.20250306-py3-none-any.whl", hash = "sha256:25f2cbb5c8710b2022f8bbee7b2b66f319ef14aeea2f35d80f18c9dbf3b60a0b", size = 20673 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.12.2"
|
version = "4.12.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user