parent
38e65f5a17
commit
588c23ce41
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
94
src/main.py
94
src/main.py
|
|
@ -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)
|
||||||
|
|
|
||||||
73
src/plex.py
73
src/plex.py
|
|
@ -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)
|
||||||
|
|
|
||||||
25
src/users.py
25
src/users.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
24
uv.lock
|
|
@ -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 },
|
||||||
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue