Switch logging to loguru

Signed-off-by: Luis Garcia <git@luigi311.com>
pull/229/head
Luis Garcia 2025-02-21 16:03:29 -07:00
parent 38e65f5a17
commit 588c23ce41
12 changed files with 182 additions and 200 deletions

View File

@ -5,6 +5,7 @@ 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",
"packaging==24.2", "packaging==24.2",
"plexapi==4.16.1", "plexapi==4.16.1",
"pydantic==2.10.6", "pydantic==2.10.6",

View File

@ -1,4 +1,6 @@
from src.functions import logger, search_mapping from loguru import logger
from src.functions import search_mapping
def setup_black_white_lists( def setup_black_white_lists(
@ -58,13 +60,13 @@ def setup_x_lists(
temp_library.append(library_other) temp_library.append(library_other)
out_library = out_library + temp_library out_library = out_library + temp_library
logger(f"{xlist_type}list Library: {xlist_library}", 1) logger.info(f"{xlist_type}list Library: {xlist_library}")
out_library_type: list[str] = [] out_library_type: list[str] = []
if xlist_library_type: if xlist_library_type:
out_library_type = [x.lower().strip() for x in xlist_library_type] out_library_type = [x.lower().strip() for x in xlist_library_type]
logger(f"{xlist_type}list Library Type: {out_library_type}", 1) logger.info(f"{xlist_type}list Library Type: {out_library_type}")
out_users: list[str] = [] out_users: list[str] = []
if xlist_users: if xlist_users:
@ -78,6 +80,6 @@ def setup_x_lists(
out_users = out_users + temp_users out_users = out_users + temp_users
logger(f"{xlist_type}list Users: {out_users}", 1) logger.info(f"{xlist_type}list Users: {out_users}")
return out_library, out_library_type, out_users return out_library, out_library_type, out_users

View File

@ -1,8 +1,9 @@
import os import os
from typing import Literal from typing import Literal
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger
from src.functions import logger, str_to_bool from src.functions import str_to_bool
from src.plex import Plex from src.plex import Plex
from src.jellyfin import Jellyfin from src.jellyfin import Jellyfin
from src.emby import Emby from src.emby import Emby
@ -39,7 +40,7 @@ def jellyfin_emby_server_connection(
else: else:
raise Exception("Unknown server type") raise Exception("Unknown server type")
logger(f"{server_type} Server {i} info: {server.info()}", 3) logger.debug(f"{server_type} Server {i} info: {server.info()}")
return servers return servers
@ -73,7 +74,7 @@ def generate_server_connections() -> list[Plex | Jellyfin | Emby]:
ssl_bypass=ssl_bypass, ssl_bypass=ssl_bypass,
) )
logger(f"Plex Server {i} info: {server.info()}", 3) logger.debug(f"Plex Server {i} info: {server.info()}")
servers.append(server) servers.append(server)
@ -99,7 +100,7 @@ def generate_server_connections() -> list[Plex | Jellyfin | Emby]:
ssl_bypass=ssl_bypass, ssl_bypass=ssl_bypass,
) )
logger(f"Plex Server {i} info: {server.info()}", 3) logger.debug(f"Plex Server {i} info: {server.info()}")
servers.append(server) servers.append(server)
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None) jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None)

View File

@ -9,34 +9,6 @@ log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
mark_file = os.getenv("MARK_FILE", os.getenv("MARKFILE", "mark.log")) mark_file = os.getenv("MARK_FILE", os.getenv("MARKFILE", "mark.log"))
def logger(message: str, log_type: int = 0):
debug = str_to_bool(os.getenv("DEBUG", "False"))
debug_level = os.getenv("DEBUG_LEVEL", "info").lower()
output: str | None = str(message)
if log_type == 0:
pass
elif log_type == 1 and (debug and debug_level in ("info", "debug")):
output = f"[INFO]: {output}"
elif log_type == 2:
output = f"[ERROR]: {output}"
elif log_type == 3 and (debug and debug_level == "debug"):
output = f"[DEBUG]: {output}"
elif log_type == 4:
output = f"[WARNING]: {output}"
elif log_type == 5:
output = f"[MARK]: {output}"
elif log_type == 6:
output = f"[DRYRUN]: {output}"
else:
output = None
if output is not None:
print(output)
with open(f"{log_file}", "a", encoding="utf-8") as file:
file.write(output + "\n")
def log_marked( def log_marked(
server_type: str, server_type: str,
server_name: str, server_name: str,

View File

@ -1,15 +1,15 @@
# Functions for Jellyfin and Emby # Functions for Jellyfin and Emby
import requests
import traceback import traceback
import os import os
from math import floor from math import floor
from typing import Any, Literal from typing import Any, Literal
from dotenv import load_dotenv from dotenv import load_dotenv
import requests
from packaging.version import parse, Version from packaging.version import parse, Version
from loguru import logger
from src.functions import ( from src.functions import (
logger,
search_mapping, search_mapping,
log_marked, log_marked,
str_to_bool, str_to_bool,
@ -35,15 +35,14 @@ def extract_identifiers_from_item(server_type, item: dict) -> MediaIdentifiers:
id = None id = None
if not title: if not title:
id = item.get("Id") id = item.get("Id")
logger(f"{server_type}: Name not found in {id}", 1) logger.info(f"{server_type}: Name not found in {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["ProviderIds"].items()}
if not guids: if not guids:
logger( logger.info(
f"{server_type}: {title if title else id} has no guids", f"{server_type}: {title if title else id} has no guids",
1,
) )
locations = tuple() locations = tuple()
@ -56,7 +55,7 @@ def extract_identifiers_from_item(server_type, item: dict) -> MediaIdentifiers:
) )
if not locations: if not locations:
logger(f"{server_type}: {title if title else id} has no locations", 1) logger.info(f"{server_type}: {title if title else id} has no locations")
return MediaIdentifiers( return MediaIdentifiers(
title=title, title=title,
@ -155,9 +154,8 @@ class JellyfinEmby:
return results return results
except Exception as e: except Exception as e:
logger( logger.error(
f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}", f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}",
2,
) )
raise Exception(e) raise Exception(e)
@ -180,7 +178,7 @@ class JellyfinEmby:
return None return None
except Exception as e: except Exception as e:
logger(f"{self.server_type}: Get server name failed {e}", 2) logger.error(f"{self.server_type}: Get server name failed {e}")
raise Exception(e) raise Exception(e)
def get_users(self) -> dict[str, str]: def get_users(self) -> dict[str, str]:
@ -198,7 +196,7 @@ class JellyfinEmby:
return users return users
except Exception as e: except Exception as e:
logger(f"{self.server_type}: Get users failed {e}", 2) logger.error(f"{self.server_type}: Get users failed {e}")
raise Exception(e) raise Exception(e)
def get_libraries(self) -> dict[str, str]: def get_libraries(self) -> dict[str, str]:
@ -215,9 +213,8 @@ class JellyfinEmby:
library_type = library.get("CollectionType") library_type = library.get("CollectionType")
if library_type not in ["movies", "tvshows"]: if library_type not in ["movies", "tvshows"]:
logger( 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}",
1,
) )
continue continue
@ -225,7 +222,7 @@ class JellyfinEmby:
return libraries return libraries
except Exception as e: except Exception as e:
logger(f"{self.server_type}: Get libraries failed {e}", 2) logger.error(f"{self.server_type}: Get libraries failed {e}")
raise Exception(e) raise Exception(e)
def get_user_library_watched( def get_user_library_watched(
@ -233,9 +230,8 @@ class JellyfinEmby:
) -> LibraryData: ) -> LibraryData:
user_name = user_name.lower() user_name = user_name.lower()
try: try:
logger( logger.info(
f"{self.server_type}: Generating watched for {user_name} in library {library_title}", f"{self.server_type}: Generating watched for {user_name} in library {library_title}",
0,
) )
watched = LibraryData(title=library_title) watched = LibraryData(title=library_title)
@ -339,19 +335,17 @@ class JellyfinEmby:
) )
) )
logger( logger.info(
f"{self.server_type}: Finished getting watched for {user_name} in library {library_title}", f"{self.server_type}: Finished getting watched for {user_name} in library {library_title}",
1,
) )
return watched return watched
except Exception as e: except Exception as e:
logger( logger.error(
f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}", f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
2,
) )
logger(traceback.format_exc(), 2) logger.error(traceback.format_exc())
return {} return {}
def get_watched( def get_watched(
@ -419,7 +413,7 @@ class JellyfinEmby:
return users_watched return users_watched
except Exception as e: except Exception as e:
logger(f"{self.server_type}: Failed to get watched, Error: {e}", 2) logger.error(f"{self.server_type}: Failed to get watched, Error: {e}")
raise Exception(e) raise Exception(e)
def update_user_watched( def update_user_watched(
@ -437,9 +431,8 @@ class JellyfinEmby:
if not library_data.series and not library_data.movies: if not library_data.series and not library_data.movies:
return return
logger( logger.info(
f"{self.server_type}: Updating watched for {user_name} in library {library_name}", f"{self.server_type}: Updating watched for {user_name} in library {library_name}",
1,
) )
# Update movies. # Update movies.
@ -463,14 +456,12 @@ class JellyfinEmby:
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:
logger(msg, 5)
self.query( self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
"post", "post",
) )
else:
logger(msg, 6)
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
log_marked( log_marked(
self.server_type, self.server_type,
self.server_name, self.server_name,
@ -482,7 +473,6 @@ class JellyfinEmby:
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:
logger(msg, 5)
playback_position_payload = { playback_position_payload = {
"PlaybackPositionTicks": stored_movie.status.time "PlaybackPositionTicks": stored_movie.status.time
* 10_000, * 10_000,
@ -492,9 +482,8 @@ class JellyfinEmby:
"post", "post",
json=playback_position_payload, json=playback_position_payload,
) )
else:
logger(msg, 6)
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
log_marked( log_marked(
self.server_type, self.server_type,
self.server_name, self.server_name,
@ -504,9 +493,8 @@ class JellyfinEmby:
duration=floor(stored_movie.status.time / 60_000), duration=floor(stored_movie.status.time / 60_000),
) )
else: else:
logger( logger.trace(
f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}", f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}",
3,
) )
# Update TV Shows (series/episodes). # Update TV Shows (series/episodes).
@ -528,9 +516,8 @@ class JellyfinEmby:
if check_same_identifiers( if check_same_identifiers(
jellyfin_show_identifiers, stored_series.identifiers jellyfin_show_identifiers, stored_series.identifiers
): ):
logger( logger.info(
f"Found matching show for '{jellyfin_show.get('Name')}'", f"Found matching show for '{jellyfin_show.get('Name')}'",
1,
) )
# Now update episodes. # Now update episodes.
# Get the list of Plex episodes for this show. # Get the list of Plex episodes for this show.
@ -559,14 +546,14 @@ class JellyfinEmby:
+ f" as watched for {user_name} in {library_name}" + f" as watched for {user_name} in {library_name}"
) )
if not dryrun: if not dryrun:
logger(msg, 5)
self.query( self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
"post", "post",
) )
else:
logger(msg, 6)
logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
)
log_marked( log_marked(
self.server_type, self.server_type,
self.server_name, self.server_name,
@ -582,7 +569,6 @@ class JellyfinEmby:
) )
if not dryrun: if not dryrun:
logger(msg, 5)
playback_position_payload = { playback_position_payload = {
"PlaybackPositionTicks": stored_ep.status.time "PlaybackPositionTicks": stored_ep.status.time
* 10_000, * 10_000,
@ -592,9 +578,10 @@ class JellyfinEmby:
"post", "post",
json=playback_position_payload, json=playback_position_payload,
) )
else:
logger(msg, 6)
logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
)
log_marked( log_marked(
self.server_type, self.server_type,
self.server_name, self.server_name,
@ -607,22 +594,19 @@ class JellyfinEmby:
), ),
) )
else: else:
logger( logger.trace(
f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}", f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}",
3,
) )
else: else:
logger( logger.trace(
f"{self.server_type}: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}", f"{self.server_type}: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}",
3,
) )
except Exception as e: except Exception as e:
logger( 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}",
2,
) )
logger(traceback.format_exc(), 2) logger.error(traceback.format_exc())
raise Exception(e) raise Exception(e)
def update_watched( def update_watched(
@ -637,9 +621,8 @@ class JellyfinEmby:
update_partial = self.is_partial_update_supported(server_version) update_partial = self.is_partial_update_supported(server_version)
if not update_partial: if not update_partial:
logger( logger.info(
f"{self.server_type}: Server version {server_version} does not support updating playback position.", f"{self.server_type}: Server version {server_version} does not support updating playback position.",
2,
) )
for user, user_data in watched_list.items(): for user, user_data in watched_list.items():
@ -663,7 +646,7 @@ class JellyfinEmby:
break break
if not user_id: if not user_id:
logger(f"{user} {user_other} not found in Jellyfin", 2) logger.info(f"{user} {user_other} not found in Jellyfin")
continue continue
jellyfin_libraries = self.query( jellyfin_libraries = self.query(
@ -692,21 +675,18 @@ class JellyfinEmby:
if library_other.lower() in [ if library_other.lower() in [
x["Name"].lower() for x in jellyfin_libraries x["Name"].lower() for x in jellyfin_libraries
]: ]:
logger( logger.info(
f"{self.server_type}: Library {library_name} not found, but {library_other} found, using {library_other}", f"{self.server_type}: Library {library_name} not found, but {library_other} found, using {library_other}",
1,
) )
library_name = library_other library_name = library_other
else: else:
logger( logger.info(
f"{self.server_type}: Library {library_name} or {library_other} not found in library list", f"{self.server_type}: Library {library_name} or {library_other} not found in library list",
1,
) )
continue continue
else: else:
logger( logger.info(
f"{self.server_type}: Library {library_name} not found in library list", f"{self.server_type}: Library {library_name} not found in library list",
1,
) )
continue continue
@ -728,5 +708,5 @@ class JellyfinEmby:
) )
except Exception as e: except Exception as e:
logger(f"{self.server_type}: Error updating watched, {e}", 2) logger.error(f"{self.server_type}: Error updating watched, {e}")
raise Exception(e) raise Exception(e)

View File

@ -1,5 +1,6 @@
from loguru import logger
from src.functions import ( from src.functions import (
logger,
match_list, match_list,
search_mapping, search_mapping,
) )
@ -151,7 +152,7 @@ def filter_libaries(
) )
if skip_reason: if skip_reason:
logger(f"Skipping library {library}: {skip_reason}", 1) logger.info(f"Skipping library {library}: {skip_reason}")
continue continue
filtered_libaries.append(library) filtered_libaries.append(library)
@ -170,8 +171,6 @@ def setup_libraries(
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
server_1_libraries = server_1.get_libraries() server_1_libraries = server_1.get_libraries()
server_2_libraries = server_2.get_libraries() server_2_libraries = server_2.get_libraries()
logger(f"Server 1 libraries: {server_1_libraries}", 1)
logger(f"Server 2 libraries: {server_2_libraries}", 1)
# Filter out all blacklist, whitelist libaries # Filter out all blacklist, whitelist libaries
filtered_server_1_libraries = filter_libaries( filtered_server_1_libraries = filter_libaries(

View File

@ -1,15 +1,16 @@
import os import os
import traceback import traceback
import json import json
import sys
from dotenv import load_dotenv from dotenv import load_dotenv
from time import sleep, perf_counter from time import sleep, perf_counter
from loguru import logger
from src.emby import Emby from src.emby import Emby
from src.jellyfin import Jellyfin from src.jellyfin import Jellyfin
from src.plex import Plex from src.plex import Plex
from src.library import setup_libraries from src.library import setup_libraries
from src.functions import ( from src.functions import (
logger,
parse_string_to_list, parse_string_to_list,
str_to_bool, str_to_bool,
) )
@ -51,41 +52,41 @@ def should_sync_server(
if isinstance(server_1, Plex): if isinstance(server_1, Plex):
if isinstance(server_2, Jellyfin) and not sync_from_plex_to_jellyfin: if isinstance(server_2, Jellyfin) and not sync_from_plex_to_jellyfin:
logger("Sync from plex -> jellyfin is disabled", 1) logger.info("Sync from plex -> jellyfin is disabled")
return False return False
if isinstance(server_2, Emby) and not sync_from_plex_to_emby: if isinstance(server_2, Emby) and not sync_from_plex_to_emby:
logger("Sync from plex -> emby is disabled", 1) logger.info("Sync from plex -> emby is disabled")
return False return False
if isinstance(server_2, Plex) and not sync_from_plex_to_plex: if isinstance(server_2, Plex) and not sync_from_plex_to_plex:
logger("Sync from plex -> plex is disabled", 1) logger.info("Sync from plex -> plex is disabled")
return False return False
if isinstance(server_1, Jellyfin): if isinstance(server_1, Jellyfin):
if isinstance(server_2, Plex) and not sync_from_jelly_to_plex: if isinstance(server_2, Plex) and not sync_from_jelly_to_plex:
logger("Sync from jellyfin -> plex is disabled", 1) logger.info("Sync from jellyfin -> plex is disabled")
return False return False
if isinstance(server_2, Jellyfin) and not sync_from_jelly_to_jellyfin: if isinstance(server_2, Jellyfin) and not sync_from_jelly_to_jellyfin:
logger("Sync from jellyfin -> jellyfin is disabled", 1) logger.info("Sync from jellyfin -> jellyfin is disabled")
return False return False
if isinstance(server_2, Emby) and not sync_from_jelly_to_emby: if isinstance(server_2, Emby) and not sync_from_jelly_to_emby:
logger("Sync from jellyfin -> emby is disabled", 1) logger.info("Sync from jellyfin -> emby is disabled")
return False return False
if isinstance(server_1, Emby): if isinstance(server_1, Emby):
if isinstance(server_2, Plex) and not sync_from_emby_to_plex: if isinstance(server_2, Plex) and not sync_from_emby_to_plex:
logger("Sync from emby -> plex is disabled", 1) logger.info("Sync from emby -> plex is disabled")
return False return False
if isinstance(server_2, Jellyfin) and not sync_from_emby_to_jellyfin: if isinstance(server_2, Jellyfin) and not sync_from_emby_to_jellyfin:
logger("Sync from emby -> jellyfin is disabled", 1) logger.info("Sync from emby -> jellyfin is disabled")
return False return False
if isinstance(server_2, Emby) and not sync_from_emby_to_emby: if isinstance(server_2, Emby) and not sync_from_emby_to_emby:
logger("Sync from emby -> emby is disabled", 1) logger.info("Sync from emby -> emby is disabled")
return False return False
return True return True
@ -98,18 +99,18 @@ def main_loop():
os.remove(log_file) os.remove(log_file)
dryrun = str_to_bool(os.getenv("DRYRUN", "False")) dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
logger(f"Dryrun: {dryrun}", 1) logger.info(f"Dryrun: {dryrun}")
user_mapping = os.getenv("USER_MAPPING", "") user_mapping = os.getenv("USER_MAPPING", "")
user_mapping = json.loads(user_mapping.lower()) user_mapping = json.loads(user_mapping.lower())
logger(f"User Mapping: {user_mapping}", 1) logger.info(f"User Mapping: {user_mapping}")
library_mapping = os.getenv("LIBRARY_MAPPING", "") library_mapping = os.getenv("LIBRARY_MAPPING", "")
library_mapping = json.loads(library_mapping) library_mapping = json.loads(library_mapping)
logger(f"Library Mapping: {library_mapping}", 1) logger.info(f"Library Mapping: {library_mapping}")
# Create (black/white)lists # Create (black/white)lists
logger("Creating (black/white)lists", 1) logger.info("Creating (black/white)lists")
blacklist_library = parse_string_to_list(os.getenv("BLACKLIST_LIBRARY", None)) blacklist_library = parse_string_to_list(os.getenv("BLACKLIST_LIBRARY", None))
whitelist_library = parse_string_to_list(os.getenv("WHITELIST_LIBRARY", None)) whitelist_library = parse_string_to_list(os.getenv("WHITELIST_LIBRARY", None))
blacklist_library_type = parse_string_to_list( blacklist_library_type = parse_string_to_list(
@ -140,7 +141,7 @@ def main_loop():
) )
# Create server connections # Create server connections
logger("Creating server connections", 1) logger.info("Creating server connections")
servers = generate_server_connections() servers = generate_server_connections()
for server_1 in servers: for server_1 in servers:
@ -156,11 +157,11 @@ def main_loop():
): ):
continue continue
logger(f"Server 1: {type(server_1)}: {server_1.info()}", 0) logger.info(f"Server 1: {type(server_1)}: {server_1.info()}")
logger(f"Server 2: {type(server_2)}: {server_2.info()}", 0) logger.info(f"Server 2: {type(server_2)}: {server_2.info()}")
# Create users list # Create users list
logger("Creating users list", 1) logger.info("Creating users list")
server_1_users, server_2_users = setup_users( server_1_users, server_2_users = setup_users(
server_1, server_2, blacklist_users, whitelist_users, user_mapping server_1, server_2, blacklist_users, whitelist_users, user_mapping
) )
@ -174,38 +175,38 @@ def main_loop():
whitelist_library_type, whitelist_library_type,
library_mapping, library_mapping,
) )
logger.info(f"Server 1 syncing libraries: {server_1_libraries}")
logger.info(f"Server 2 syncing libraries: {server_2_libraries}")
logger("Creating watched lists", 1) logger.info("Creating watched lists", 1)
server_1_watched = server_1.get_watched(server_1_users, server_1_libraries) server_1_watched = server_1.get_watched(server_1_users, server_1_libraries)
logger("Finished creating watched list server 1", 1) logger.info("Finished creating watched list server 1")
server_2_watched = server_2.get_watched(server_2_users, server_2_libraries) server_2_watched = server_2.get_watched(server_2_users, server_2_libraries)
logger("Finished creating watched list server 2", 1) logger.info("Finished creating watched list server 2")
logger(f"Server 1 watched: {server_1_watched}", 3) logger.debug(f"Server 1 watched: {server_1_watched}")
logger(f"Server 2 watched: {server_2_watched}", 3) logger.debug(f"Server 2 watched: {server_2_watched}")
logger("Cleaning Server 1 Watched", 1) logger.info("Cleaning Server 1 Watched", 1)
server_1_watched_filtered = cleanup_watched( server_1_watched_filtered = cleanup_watched(
server_1_watched, server_2_watched, user_mapping, library_mapping server_1_watched, server_2_watched, user_mapping, library_mapping
) )
logger("Cleaning Server 2 Watched", 1) logger.info("Cleaning Server 2 Watched", 1)
server_2_watched_filtered = cleanup_watched( server_2_watched_filtered = cleanup_watched(
server_2_watched, server_1_watched, user_mapping, library_mapping server_2_watched, server_1_watched, user_mapping, library_mapping
) )
logger( logger.debug(
f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}", f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}",
1,
) )
logger( logger.debug(
f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}", f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}",
1,
) )
if should_sync_server(server_2, server_1): if should_sync_server(server_2, server_1):
logger(f"Syncing {server_2.info()} -> {server_1.info()}", 0) logger.info(f"Syncing {server_2.info()} -> {server_1.info()}")
server_1.update_watched( server_1.update_watched(
server_2_watched_filtered, server_2_watched_filtered,
user_mapping, user_mapping,
@ -214,7 +215,7 @@ def main_loop():
) )
if should_sync_server(server_1, server_2): if should_sync_server(server_1, server_2):
logger(f"Syncing {server_1.info()} -> {server_2.info()}", 0) logger.info(f"Syncing {server_1.info()} -> {server_2.info()}")
server_2.update_watched( server_2.update_watched(
server_1_watched_filtered, server_1_watched_filtered,
user_mapping, user_mapping,
@ -223,7 +224,22 @@ def main_loop():
) )
@logger.catch
def main(): def main():
# Remove default logger to configure our own
logger.remove()
# Choose log level based on environment
# If in debug mode with a "debug" level, use DEBUG; otherwise, default to INFO.
level = os.getenv("DEBUG_LEVEL", "INFO").upper()
if level not in ["INFO", "DEBUG", "TRACE"]:
raise Exception("Invalid DEBUG_LEVEL, please choose between INFO, DEBUG, TRACE")
# Add a sink for file logging (with optional rotation) and the console.
logger.add("log.log", level=level, rotation="10 MB")
logger.add(sys.stdout, level=level)
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] = []
@ -235,31 +251,31 @@ def main():
times.append(end - start) times.append(end - start)
if len(times) > 0: if len(times) > 0:
logger(f"Average time: {sum(times) / len(times)}", 0) logger.info(f"Average time: {sum(times) / len(times)}")
if run_only_once: if run_only_once:
break break
logger(f"Looping in {sleep_duration}") logger.info(f"Looping in {sleep_duration}")
sleep(sleep_duration) sleep(sleep_duration)
except Exception as error: except Exception as error:
if isinstance(error, list): if isinstance(error, list):
for message in error: for message in error:
logger(message, log_type=2) logger.error(message)
else: else:
logger(error, log_type=2) logger.error(error)
logger(traceback.format_exc(), 2) logger.error(traceback.format_exc())
if run_only_once: if run_only_once:
break break
logger(f"Retrying in {sleep_duration}", log_type=0) logger.info(f"Retrying in {sleep_duration}")
sleep(sleep_duration) sleep(sleep_duration)
except KeyboardInterrupt: except KeyboardInterrupt:
if len(times) > 0: if len(times) > 0:
logger(f"Average time: {sum(times) / len(times)}", 0) logger.info(f"Average time: {sum(times) / len(times)}")
logger("Exiting", log_type=0) logger.info("Exiting")
os._exit(0) os._exit(0)

View File

@ -1,6 +1,7 @@
import os import os
import requests import requests
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger
from urllib3.poolmanager import PoolManager from urllib3.poolmanager import PoolManager
from math import floor from math import floor
@ -12,7 +13,6 @@ from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount
from src.functions import ( from src.functions import (
logger,
search_mapping, search_mapping,
log_marked, log_marked,
str_to_bool, str_to_bool,
@ -94,7 +94,9 @@ def update_user_watched(
if not library_data.series and not library_data.movies: if not library_data.series and not library_data.movies:
return return
logger(f"Plex: Updating watched for {user.title} in library {library_name}", 1) logger.info(
f"Plex: Updating watched for {user.title} in library {library_name}"
)
library_section = user_plex.library.section(library_name) library_section = user_plex.library.section(library_name)
# Update movies. # Update movies.
@ -112,11 +114,8 @@ def update_user_watched(
if stored_movie.status.completed: if stored_movie.status.completed:
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:
logger(msg, 5)
plex_movie.markWatched() plex_movie.markWatched()
else: logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
logger(msg, 6)
log_marked( log_marked(
"Plex", "Plex",
user_plex.friendlyName, user_plex.friendlyName,
@ -129,11 +128,9 @@ def update_user_watched(
else: 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}" 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: if not dryrun:
logger(msg, 5)
plex_movie.updateTimeline(stored_movie.status.time) plex_movie.updateTimeline(stored_movie.status.time)
else:
logger(msg, 6)
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
log_marked( log_marked(
"Plex", "Plex",
user_plex.friendlyName, user_plex.friendlyName,
@ -157,7 +154,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(f"Found matching show for '{plex_show.title}'", 1) logger.info(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()
@ -172,11 +169,11 @@ def update_user_watched(
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:
logger(msg, 5)
plex_episode.markWatched() plex_episode.markWatched()
else:
logger(msg, 6)
logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
)
log_marked( log_marked(
"Plex", "Plex",
user_plex.friendlyName, user_plex.friendlyName,
@ -188,13 +185,13 @@ def update_user_watched(
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:
logger(msg, 5)
plex_episode.updateTimeline( plex_episode.updateTimeline(
stored_ep.status.time stored_ep.status.time
) )
else:
logger(msg, 6)
logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
)
log_marked( log_marked(
"Plex", "Plex",
user_plex.friendlyName, user_plex.friendlyName,
@ -206,8 +203,9 @@ def update_user_watched(
) )
break # Found a matching episode. break # Found a matching episode.
break # Found a matching show. break # Found a matching show.
except Exception as e: except Exception as e:
logger( logger.error(
f"Plex: Failed to update watched for {user.title} in library {library_name}, Error: {e}", f"Plex: Failed to update watched for {user.title} in library {library_name}, Error: {e}",
2, 2,
) )
@ -255,11 +253,11 @@ class Plex:
return plex return plex
except Exception as e: except Exception as e:
if self.username or self.password: if self.username:
msg = f"Failed to login via plex account {self.username}" msg = f"Failed to login via plex account {self.username}"
logger(f"Plex: Failed to login, {msg}, Error: {e}", 2) logger.error(f"Plex: Failed to login, {msg}, Error: {e}")
else: else:
logger(f"Plex: Failed to login, Error: {e}", 2) logger.error(f"Plex: Failed to login, Error: {e}")
raise Exception(e) raise Exception(e)
def info(self) -> str: def info(self) -> str:
@ -274,7 +272,7 @@ class Plex:
return users return users
except Exception as e: except Exception as e:
logger(f"Plex: Failed to get users, Error: {e}", 2) logger.error(f"Plex: Failed to get users, Error: {e}")
raise Exception(e) raise Exception(e)
def get_libraries(self) -> dict[str, str]: def get_libraries(self) -> dict[str, str]:
@ -288,9 +286,8 @@ class Plex:
library_type = library.type library_type = library.type
if library_type not in ["movie", "show"]: if library_type not in ["movie", "show"]:
logger( logger.debug(
f"Plex: Skipping Library {library_title} found type {library_type}", f"Plex: Skipping Library {library_title} found type {library_type}",
1,
) )
continue continue
@ -298,15 +295,14 @@ class Plex:
return output return output
except Exception as e: except Exception as e:
logger(f"Plex: Failed to get libraries, Error: {e}", 2) logger.error(f"Plex: Failed to get libraries, Error: {e}")
raise Exception(e) raise Exception(e)
def get_user_library_watched(self, user, user_plex, library) -> LibraryData: def get_user_library_watched(self, user, user_plex, library) -> LibraryData:
user_name: str = user.username.lower() if user.username else user.title.lower() user_name: str = user.username.lower() if user.username else user.title.lower()
try: try:
logger( logger.info(
f"Plex: Generating watched for {user_name} in library {library.title}", f"Plex: Generating watched for {user_name} in library {library.title}",
0,
) )
watched = LibraryData(title=library.title) watched = LibraryData(title=library.title)
@ -365,9 +361,8 @@ class Plex:
return watched return watched
except Exception as e: except Exception as e:
logger( logger.error(
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}", f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
2,
) )
return LibraryData(title=library.title) return LibraryData(title=library.title)
@ -386,9 +381,8 @@ class Plex:
token, token,
) )
else: else:
logger( logger.error(
f"Plex: Failed to get token for {user.title}, skipping", f"Plex: Failed to get token for {user.title}, skipping",
2,
) )
continue continue
@ -411,7 +405,7 @@ class Plex:
return users_watched return users_watched
except Exception as e: except Exception as e:
logger(f"Plex: Failed to get watched, Error: {e}", 2) logger.error(f"Plex: Failed to get watched, Error: {e}")
raise Exception(e) raise Exception(e)
def update_watched( def update_watched(
@ -446,9 +440,8 @@ class Plex:
user_plex = self.plex user_plex = self.plex
else: else:
if isinstance(user, str): if isinstance(user, str):
logger( logger.warning(
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",
4,
) )
user = self.plex.myPlexAccount().user(user) user = self.plex.myPlexAccount().user(user)
@ -460,9 +453,8 @@ class Plex:
session=self.session, session=self.session,
) )
else: else:
logger( logger.error(
f"Plex: Failed to get token for {user.title}, skipping", f"Plex: Failed to get token for {user.title}, skipping",
2,
) )
continue continue
@ -480,21 +472,18 @@ class Plex:
if library_other.lower() in [ if library_other.lower() in [
x.title.lower() for x in library_list x.title.lower() for x in library_list
]: ]:
logger( logger.info(
f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}", f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}",
1,
) )
library_name = library_other library_name = library_other
else: else:
logger( logger.info(
f"Plex: Library {library_name} or {library_other} not found in library list", f"Plex: Library {library_name} or {library_other} not found in library list",
1,
) )
continue continue
else: else:
logger( logger.info(
f"Plex: Library {library_name} not found in library list", f"Plex: Library {library_name} not found in library list",
1,
) )
continue continue
@ -507,5 +496,5 @@ class Plex:
) )
except Exception as e: except Exception as e:
logger(f"Plex: Failed to update watched, Error: {e}", 2) logger.error(f"Plex: Failed to update watched, Error: {e}")
raise Exception(e) raise Exception(e)

View File

@ -1,11 +1,10 @@
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount
from loguru import logger
from src.emby import Emby from src.emby import Emby
from src.jellyfin import Jellyfin from src.jellyfin import Jellyfin
from src.plex import Plex from src.plex import Plex
from src.functions import ( from src.functions import search_mapping
logger,
search_mapping,
)
def generate_user_list(server: Plex | Jellyfin | Emby) -> list[str]: def generate_user_list(server: Plex | Jellyfin | Emby) -> list[str]:
@ -63,7 +62,7 @@ def filter_user_lists(
# whitelist_user is not empty and user lowercase is not in whitelist lowercase # whitelist_user is not empty and user lowercase is not in whitelist lowercase
if len(whitelist_users) > 0: if len(whitelist_users) > 0:
if user not in whitelist_users and users[user] not in whitelist_users: if user not in whitelist_users and users[user] not in whitelist_users:
logger(f"{user} or {users[user]} is not in whitelist", 1) logger.info(f"{user} or {users[user]} is not in whitelist")
continue continue
if user not in blacklist_users and users[user] not in blacklist_users: if user not in blacklist_users and users[user] not in blacklist_users:
@ -113,26 +112,26 @@ def setup_users(
) -> tuple[list[MyPlexAccount] | dict[str, str], list[MyPlexAccount] | dict[str, str]]: ) -> tuple[list[MyPlexAccount] | dict[str, str], list[MyPlexAccount] | 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(f"Server 1 users: {server_1_users}", 1) logger.debug(f"Server 1 users: {server_1_users}")
logger(f"Server 2 users: {server_2_users}", 1) logger.debug(f"Server 2 users: {server_2_users}")
users = combine_user_lists(server_1_users, server_2_users, user_mapping) users = combine_user_lists(server_1_users, server_2_users, user_mapping)
logger(f"User list that exist on both servers {users}", 1) logger.debug(f"User list that exist on both servers {users}")
users_filtered = filter_user_lists(users, blacklist_users, whitelist_users) users_filtered = filter_user_lists(users, blacklist_users, whitelist_users)
logger(f"Filtered user list {users_filtered}", 1) logger.debug(f"Filtered user list {users_filtered}")
output_server_1_users = generate_server_users(server_1, users_filtered) output_server_1_users = generate_server_users(server_1, users_filtered)
output_server_2_users = generate_server_users(server_2, users_filtered) output_server_2_users = generate_server_users(server_2, users_filtered)
# Check if users is none or empty # Check if users is none or empty
if output_server_1_users is None or len(output_server_1_users) == 0: if output_server_1_users is None or len(output_server_1_users) == 0:
logger( logger.warning(
f"No users found for server 1 {type(server_1)}, users: {server_1_users}, overlapping users {users}, filtered users {users_filtered}, server 1 users {server_1.users}" f"No users found for server 1 {type(server_1)}, users: {server_1_users}, overlapping users {users}, filtered users {users_filtered}, server 1 users {server_1.users}"
) )
if output_server_2_users is None or len(output_server_2_users) == 0: if output_server_2_users is None or len(output_server_2_users) == 0:
logger( logger.warning(
f"No users found for server 2 {type(server_2)}, users: {server_2_users}, overlapping users {users} filtered users {users_filtered}, server 2 users {server_2.users}" f"No users found for server 2 {type(server_2)}, users: {server_2_users}, overlapping users {users} filtered users {users_filtered}, server 2 users {server_2.users}"
) )
@ -144,7 +143,7 @@ def setup_users(
): ):
raise Exception("No users found for one or both servers") raise Exception("No users found for one or both servers")
logger(f"Server 1 users: {output_server_1_users}", 1) logger.info(f"Server 1 users: {output_server_1_users}")
logger(f"Server 2 users: {output_server_2_users}", 1) logger.info(f"Server 2 users: {output_server_2_users}")
return output_server_1_users, output_server_2_users return output_server_1_users, output_server_2_users

View File

@ -1,7 +1,8 @@
import copy import copy
from pydantic import BaseModel from pydantic import BaseModel
from loguru import logger
from src.functions import logger, search_mapping from src.functions import search_mapping
class MediaIdentifiers(BaseModel): class MediaIdentifiers(BaseModel):
@ -134,7 +135,7 @@ def cleanup_watched(
remove_flag = False remove_flag = False
for movie2 in library_2.movies: for movie2 in library_2.movies:
if check_remove_entry(movie, movie2): if check_remove_entry(movie, movie2):
logger(f"Removing movie: {movie.identifiers.title}", 3) logger.trace(f"Removing movie: {movie.identifiers.title}")
remove_flag = True remove_flag = True
break break
@ -164,9 +165,8 @@ def cleanup_watched(
remove_flag = False remove_flag = False
for ep2 in matching_series.episodes: for ep2 in matching_series.episodes:
if check_remove_entry(ep1, ep2): if check_remove_entry(ep1, ep2):
logger( logger.trace(
f"Removing episode '{ep1.identifiers.title}' from show '{series1.identifiers.title}'", f"Removing episode '{ep1.identifiers.title}' from show '{series1.identifiers.title}'",
3,
) )
remove_flag = True remove_flag = True
break break
@ -179,9 +179,8 @@ def cleanup_watched(
modified_series1.episodes = filtered_episodes modified_series1.episodes = filtered_episodes
filtered_series_list.append(modified_series1) filtered_series_list.append(modified_series1)
else: else:
logger( logger.trace(
f"Removing entire show '{series1.identifiers.title}' as no episodes remain after cleanup.", f"Removing entire show '{series1.identifiers.title}' as no episodes remain after cleanup.",
3,
) )
modified_watched_list_1[user_1].libraries[ modified_watched_list_1[user_1].libraries[
library_1_key library_1_key
@ -194,7 +193,7 @@ def cleanup_watched(
if library.movies or library.series: if library.movies or library.series:
new_libraries[lib_key] = library new_libraries[lib_key] = library
else: else:
logger(f"Removing empty library '{lib_key}' for user '{user}'", 3) logger.trace(f"Removing empty library '{lib_key}' for user '{user}'")
user_data.libraries = new_libraries user_data.libraries = new_libraries
return modified_watched_list_1 return modified_watched_list_1
@ -206,5 +205,5 @@ def get_other(watched_list, object_1, object_2):
elif object_2 in watched_list: elif object_2 in watched_list:
return object_2 return object_2
else: else:
logger(f"{object_1} and {object_2} not found in watched list 2", 1) logger.info(f"{object_1} and {object_2} not found in watched list 2")
return None return None

View File

@ -1,6 +1,6 @@
# Check the mark.log file that is generated by the CI to make sure it contains the expected values # Check the mark.log file that is generated by the CI to make sure it contains the expected values
import argparse
import os, argparse import os
def parse_args(): def parse_args():

24
uv.lock
View File

@ -87,6 +87,7 @@ name = "jellyplex-watched"
version = "6.1.2" version = "6.1.2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "loguru" },
{ name = "packaging" }, { name = "packaging" },
{ name = "plexapi" }, { name = "plexapi" },
{ name = "pydantic" }, { name = "pydantic" },
@ -104,6 +105,7 @@ lint = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "packaging", specifier = "==24.2" }, { name = "packaging", specifier = "==24.2" },
{ name = "plexapi", specifier = "==4.16.1" }, { name = "plexapi", specifier = "==4.16.1" },
{ name = "pydantic", specifier = "==2.10.6" }, { name = "pydantic", specifier = "==2.10.6" },
@ -115,6 +117,19 @@ requires-dist = [
dev = [{ name = "pytest", specifier = ">=8.3.4" }] dev = [{ name = "pytest", specifier = ">=8.3.4" }]
lint = [{ name = "ruff", specifier = ">=0.9.6" }] lint = [{ name = "ruff", specifier = ">=0.9.6" }]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "24.2" version = "24.2"
@ -279,3 +294,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf
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/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
] ]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
]