Add support for emby

pull/172/head
Luis Garcia 2024-05-06 21:28:48 -06:00
parent 5b1933cb08
commit 1f7da2f609
12 changed files with 1216 additions and 1036 deletions

View File

@ -55,7 +55,6 @@ MAX_THREADS = 32
WHITELIST_USERS = "testuser1,testuser2" WHITELIST_USERS = "testuser1,testuser2"
# Plex # Plex
## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers
@ -77,13 +76,6 @@ PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2"
## Set to True if running into ssl certificate errors ## Set to True if running into ssl certificate errors
SSL_BYPASS = "False" SSL_BYPASS = "False"
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
# Jellyfin # Jellyfin
@ -94,3 +86,31 @@ JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma separated list for multiple servers ## Comma separated list for multiple servers
JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2" JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

View File

@ -40,43 +40,42 @@ jobs:
- name: "Start containers" - name: "Start containers"
run: | run: |
export PGID=$(id -g) JellyPlex-Watched-CI/start_containers.sh
export PUID=$(id -u)
sudo chown -R $PUID:$PGID JellyPlex-Watched-CI
docker pull lscr.io/linuxserver/plex &
docker pull lscr.io/linuxserver/jellyfin &
wait
docker-compose -f JellyPlex-Watched-CI/plex/docker-compose.yml up -d
docker-compose -f JellyPlex-Watched-CI/jellyfin/docker-compose.yml up -d
# Wait for containers to start # Wait for containers to start
sleep 10 sleep 10
docker-compose -f JellyPlex-Watched-CI/plex/docker-compose.yml logs for FOLDER in $(find "JellyPlex-Watched-CI" -type f -name "docker-compose.yml" -exec dirname {} \;); do
docker-compose -f JellyPlex-Watched-CI/jellyfin/docker-compose.yml logs docker-compose -f "${FOLDER}/docker-compose.yml" logs
done
- name: "Run tests" - name: "Run tests"
run: | run: |
# Test ci1 # Test guids
mv test/ci1.env .env mv test/ci_guids.env .env
python main.py python main.py
# Test ci2 cat mark.log
mv test/ci2.env .env python test/validate_ci_marklog.py --dry
rm mark.log
# Test locations
mv test/ci_locations.env .env
python main.py python main.py
# Test ci3 cat mark.log
mv test/ci3.env .env python test/validate_ci_marklog.py --dry
rm mark.log
# Test writing to the servers
mv test/ci_write.env .env
python main.py python main.py
# Test again to test if it can handle existing data # Test again to test if it can handle existing data
python main.py python main.py
cat mark.log cat mark.log
python test/validate_ci_marklog.py python test/validate_ci_marklog.py --write
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -2,11 +2,11 @@
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com\&utm_medium=referral\&utm_content=luigi311/JellyPlex-Watched\&utm_campaign=Badge_Grade) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com\&utm_medium=referral\&utm_content=luigi311/JellyPlex-Watched\&utm_campaign=Badge_Grade)
Sync watched between jellyfin and plex locally Sync watched between jellyfin, plex and emby locally
## Description ## Description
Keep in sync all your users watched history between jellyfin and plex servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the .env plex/jellyfin section separated by commas. Keep in sync all your users watched history between jellyfin, plex and emby servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the .env plex/jellyfin section separated by commas.
## Features ## Features
@ -32,12 +32,12 @@ Keep in sync all your users watched history between jellyfin and plex servers lo
### Emby ### Emby
* \[ ] Match via filenames * \[x] Match via filenames
* \[ ] Match via provider ids * \[x] Match via provider ids
* \[ ] Map usernames * \[x] Map usernames
* \[ ] Use single login * \[x] Use single login
* \[ ] One way/multi way sync * \[x] One way/multi way sync
* \[ ] Sync watched * \[x] Sync watched
* \[ ] Sync in progress * \[ ] Sync in progress
## Configuration ## Configuration

21
src/emby.py Normal file
View File

@ -0,0 +1,21 @@
from src.jellyfin_emby import JellyfinEmby
class Emby(JellyfinEmby):
def __init__(self, baseurl, token):
authorization = (
"Emby , "
'Client="JellyPlex-Watched", '
'Device="script", '
'DeviceId="script", '
'Version="0.0.0"'
)
headers = {
"Accept": "application/json",
"X-Emby-Token": token,
"X-Emby-Authorization": authorization,
}
super().__init__(
server_type="Emby", baseurl=baseurl, token=token, headers=headers
)

View File

@ -1,859 +1,21 @@
import traceback, os from src.jellyfin_emby import JellyfinEmby
from math import floor
from dotenv import load_dotenv
import requests
from src.functions import (
logger,
search_mapping,
contains_nested,
log_marked,
str_to_bool,
)
from src.library import (
check_skip_logic,
generate_library_guids_dict,
)
from src.watched import (
combine_watched_dicts,
)
load_dotenv(override=True)
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
def get_guids(item): class Jellyfin(JellyfinEmby):
if item.get("Name"):
guids = {"title": item.get("Name")}
else:
logger(f"Jellyfin: Name not found in {item.get('Id')}", 1)
guids = {"title": None}
if "ProviderIds" in item:
guids.update({k.lower(): v for k, v in item["ProviderIds"].items()})
else:
logger(f"Jellyfin: ProviderIds not found in {item.get('Name')}", 1)
if "MediaSources" in item:
guids["locations"] = tuple(
[x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x]
)
else:
logger(f"Jellyfin: MediaSources not found in {item.get('Name')}", 1)
guids["locations"] = tuple()
if "UserData" in item:
guids["status"] = {
"completed": item["UserData"]["Played"],
# Convert ticks to milliseconds to match Plex
"time": floor(item["UserData"]["PlaybackPositionTicks"] / 10000),
}
else:
logger(f"Jellyfin: UserData not found in {item.get('Name')}", 1)
guids["status"] = {}
return guids
def get_video_status(jellyfin_video, videos_ids, videos):
video_status = None
if generate_locations:
if "MediaSources" in jellyfin_video:
for video_location in jellyfin_video["MediaSources"]:
if "Path" in video_location:
if (
contains_nested(
video_location["Path"].split("/")[-1],
videos_ids["locations"],
)
is not None
):
for video in videos:
if (
contains_nested(
video_location["Path"].split("/")[-1],
video["locations"],
)
is not None
):
video_status = video["status"]
break
break
if generate_guids:
if not video_status:
for (
video_provider_source,
video_provider_id,
) in jellyfin_video["ProviderIds"].items():
if video_provider_source.lower() in videos_ids:
if (
video_provider_id.lower()
in videos_ids[video_provider_source.lower()]
):
for video in videos:
if video_provider_id.lower() in video.get(
video_provider_source.lower(), []
):
video_status = video["status"]
break
break
return video_status
class Jellyfin:
def __init__(self, baseurl, token): def __init__(self, baseurl, token):
self.baseurl = baseurl authorization = (
self.token = token "MediaBrowser , "
self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300)) 'Client="JellyPlex-Watched", '
'Device="script", '
if not self.baseurl: 'DeviceId="script", '
raise Exception("Jellyfin baseurl not set") 'Version="5.2.0", '
f'Token="{token}"'
if not self.token: )
raise Exception("Jellyfin token not set") headers = {
"Accept": "application/json",
self.session = requests.Session() "Authorization": authorization,
self.users = self.get_users() }
def query(self, query, query_type, session=None, identifiers=None): super().__init__(
try: server_type="Jellyfin", baseurl=baseurl, token=token, headers=headers
results = None )
authorization = (
"MediaBrowser , "
'Client="other", '
'Device="script", '
'DeviceId="script", '
'Version="0.0.0"'
)
headers = {
"Accept": "application/json",
"X-Emby-Token": self.token,
"X-Emby-Authorization": authorization,
}
if query_type == "get":
response = self.session.get(
self.baseurl + query, headers=headers, timeout=self.timeout
)
if response.status_code != 200:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
results = response.json()
elif query_type == "post":
response = self.session.post(
self.baseurl + query, headers=headers, timeout=self.timeout
)
if response.status_code != 200:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
results = response.json()
if not isinstance(results, list) and not isinstance(results, dict):
raise Exception("Query result is not of type list or dict")
# append identifiers to results
if identifiers:
results["Identifiers"] = identifiers
return results
except Exception as e:
logger(f"Jellyfin: Query {query_type} {query}\nResults {results}\n{e}", 2)
raise Exception(e)
def info(self) -> str:
try:
query_string = "/System/Info/Public"
response = self.query(query_string, "get")
if response:
return f"{response['ServerName']}: {response['Version']}"
else:
return None
except Exception as e:
logger(f"Jellyfin: Get server name failed {e}", 2)
raise Exception(e)
def get_users(self):
try:
users = {}
query_string = "/Users"
response = self.query(query_string, "get")
# If response is not empty
if response:
for user in response:
users[user["Name"]] = user["Id"]
return users
except Exception as e:
logger(f"Jellyfin: Get users failed {e}", 2)
raise Exception(e)
def get_user_library_watched(
self, user_name, user_id, library_type, library_id, library_title
):
try:
user_name = user_name.lower()
user_watched = {}
user_watched[user_name] = {}
logger(
f"Jellyfin: Generating watched for {user_name} in library {library_title}",
0,
)
# Movies
if library_type == "Movie":
user_watched[user_name][library_title] = []
watched = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
in_progress = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
for movie in watched["Items"] + in_progress["Items"]:
if "MediaSources" in movie and movie["MediaSources"] != {}:
if "UserData" not in movie:
continue
# Skip if not watched or watched less than a minute
if (
movie["UserData"]["Played"] == True
or movie["UserData"]["PlaybackPositionTicks"] > 600000000
):
logger(
f"Jellyfin: Adding {movie.get('Name')} to {user_name} watched list",
3,
)
# Get the movie's GUIDs
movie_guids = get_guids(movie)
# Append the movie dictionary to the list for the given user and library
user_watched[user_name][library_title].append(movie_guids)
logger(
f"Jellyfin: Added {movie_guids} to {user_name} watched list",
3,
)
# TV Shows
if library_type in ["Series", "Episode"]:
# Initialize an empty dictionary for the given user and library
user_watched[user_name][library_title] = {}
# Retrieve a list of watched TV shows
watched_shows = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
"get",
)
# Filter the list of shows to only include those that have been partially or fully watched
watched_shows_filtered = []
for show in watched_shows["Items"]:
if not "UserData" in show:
continue
if "PlayedPercentage" in show["UserData"]:
if show["UserData"]["PlayedPercentage"] > 0:
watched_shows_filtered.append(show)
# Retrieve the seasons of each watched show
seasons_watched = []
for show in watched_shows_filtered:
logger(
f"Jellyfin: Adding {show.get('Name')} to {user_name} watched list",
3,
)
show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()}
show_guids["title"] = show["Name"]
show_guids["locations"] = (
tuple([show["Path"].split("/")[-1]])
if "Path" in show
else tuple()
)
show_guids = frozenset(show_guids.items())
show_identifiers = {
"show_guids": show_guids,
"show_id": show["Id"],
}
seasons_watched.append(
self.query(
f"/Shows/{show['Id']}/Seasons"
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount",
"get",
identifiers=frozenset(show_identifiers.items()),
)
)
# Filter the list of seasons to only include those that have been partially or fully watched
seasons_watched_filtered = []
for seasons in seasons_watched:
seasons_watched_filtered_dict = {}
seasons_watched_filtered_dict["Identifiers"] = seasons[
"Identifiers"
]
seasons_watched_filtered_dict["Items"] = []
for season in seasons["Items"]:
if "PlayedPercentage" in season["UserData"]:
if season["UserData"]["PlayedPercentage"] > 0:
seasons_watched_filtered_dict["Items"].append(season)
if seasons_watched_filtered_dict["Items"]:
seasons_watched_filtered.append(seasons_watched_filtered_dict)
# Create a list of tasks to retrieve the episodes of each watched season
watched_episodes = []
for seasons in seasons_watched_filtered:
if len(seasons["Items"]) > 0:
for season in seasons["Items"]:
if "IndexNumber" not in season:
logger(
f"Jellyfin: Skipping show {season.get('SeriesName')} season {season.get('Name')} as it has no index number",
3,
)
continue
season_identifiers = dict(seasons["Identifiers"])
season_identifiers["season_index"] = season["IndexNumber"]
watched_task = self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsPlayed&Fields=ProviderIds,MediaSources",
"get",
identifiers=frozenset(season_identifiers.items()),
)
in_progress_task = self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsResumable&Fields=ProviderIds,MediaSources",
"get",
identifiers=frozenset(season_identifiers.items()),
)
watched_episodes.append(watched_task)
watched_episodes.append(in_progress_task)
# Iterate through the watched episodes
for episodes in watched_episodes:
# If the season has any watched episodes
if len(episodes["Items"]) > 0:
# Create a dictionary for the season with its identifier and episodes
season_dict = {}
season_dict["Identifiers"] = dict(episodes["Identifiers"])
season_dict["Episodes"] = []
for episode in episodes["Items"]:
if (
"MediaSources" in episode
and episode["MediaSources"] != {}
):
# If watched or watched more than a minute
if (
episode["UserData"]["Played"] == True
or episode["UserData"]["PlaybackPositionTicks"]
> 600000000
):
episode_dict = get_guids(episode)
# Add the episode dictionary to the season's list of episodes
season_dict["Episodes"].append(episode_dict)
# Add the season dictionary to the show's list of seasons
if (
season_dict["Identifiers"]["show_guids"]
not in user_watched[user_name][library_title]
):
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
] = {}
if (
season_dict["Identifiers"]["season_index"]
not in user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
]
):
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
][season_dict["Identifiers"]["season_index"]] = []
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
][season_dict["Identifiers"]["season_index"]] = season_dict[
"Episodes"
]
logger(
f"Jellyfin: Added {season_dict['Episodes']} to {user_name} {season_dict['Identifiers']['show_guids']} watched list",
1,
)
logger(
f"Jellyfin: Got watched for {user_name} in library {library_title}", 1
)
if library_title in user_watched[user_name]:
logger(f"Jellyfin: {user_watched[user_name][library_title]}", 3)
return user_watched
except Exception as e:
logger(
f"Jellyfin: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
2,
)
logger(traceback.format_exc(), 2)
return {}
def get_users_watched(
self,
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
):
try:
# Get all libraries
user_name = user_name.lower()
watched = []
libraries = []
all_libraries = self.query(f"/Users/{user_id}/Views", "get")
for library in all_libraries["Items"]:
library_id = library["Id"]
library_title = library["Name"]
identifiers = {
"library_id": library_id,
"library_title": library_title,
}
libraries.append(
self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
"get",
identifiers=identifiers,
)
)
for library in libraries:
if len(library["Items"]) == 0:
continue
library_id = library["Identifiers"]["library_id"]
library_title = library["Identifiers"]["library_title"]
# Get all library types excluding "Folder"
types = set(
[
x["Type"]
for x in library["Items"]
if x["Type"] in ["Movie", "Series", "Episode"]
]
)
skip_reason = check_skip_logic(
library_title,
types,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
if skip_reason:
logger(
f"Jellyfin: Skipping library {library_title}: {skip_reason}",
1,
)
continue
# If there are multiple types in library raise error
if types is None or len(types) < 1:
all_types = set([x["Type"] for x in library["Items"]])
logger(
f"Jellyfin: Skipping Library {library_title} found types: {types}, all types: {all_types}",
1,
)
continue
for library_type in types:
# Get watched for user
watched.append(
self.get_user_library_watched(
user_name,
user_id,
library_type,
library_id,
library_title,
)
)
return watched
except Exception as e:
logger(f"Jellyfin: Failed to get users watched, Error: {e}", 2)
raise Exception(e)
def get_watched(
self,
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping=None,
):
try:
users_watched = {}
watched = []
for user_name, user_id in users.items():
watched.append(
self.get_users_watched(
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
)
for user_watched in watched:
user_watched_combine = combine_watched_dicts(user_watched)
for user, user_watched_temp in user_watched_combine.items():
if user not in users_watched:
users_watched[user] = {}
users_watched[user].update(user_watched_temp)
return users_watched
except Exception as e:
logger(f"Jellyfin: Failed to get watched, Error: {e}", 2)
raise Exception(e)
def update_user_watched(
self, user_name, user_id, library, library_id, videos, dryrun
):
try:
logger(
f"Jellyfin: Updating watched for {user_name} in library {library}", 1
)
(
videos_shows_ids,
videos_episodes_ids,
videos_movies_ids,
) = generate_library_guids_dict(videos)
if (
not videos_movies_ids
and not videos_shows_ids
and not videos_episodes_ids
):
logger(
f"Jellyfin: No videos to mark as watched for {user_name} in library {library}",
1,
)
return
logger(
f"Jellyfin: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
1,
)
if videos_movies_ids:
jellyfin_search = self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie",
"get",
)
for jellyfin_video in jellyfin_search["Items"]:
movie_status = get_video_status(
jellyfin_video, videos_movies_ids, videos
)
if movie_status:
jellyfin_video_id = jellyfin_video["Id"]
if movie_status["completed"]:
msg = f"Jellyfin: {jellyfin_video.get('Name')} as watched for {user_name} in {library}"
if not dryrun:
logger(msg, 5)
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
"post",
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_video.get("Name"),
)
else:
# TODO add support for partially watched movies
msg = f"Jellyfin: {jellyfin_video.get('Name')} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library}"
"""
if not dryrun:
pass
# logger(msg, 5)
else:
pass
# logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_video.get("Name"),
duration=floor(movie_status["time"] / 60_000),
)"""
else:
logger(
f"Jellyfin: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}",
3,
)
# TV Shows
if videos_shows_ids and videos_episodes_ids:
jellyfin_search = self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series",
"get",
)
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for jellyfin_show in jellyfin_shows:
show_found = False
episode_videos = []
if generate_locations:
if "Path" in jellyfin_show:
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
videos_shows_ids["locations"],
)
is not None
):
show_found = True
for shows, seasons in videos.items():
show = {k: v for k, v in shows}
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
show["locations"],
)
is not None
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
break
if generate_guids:
if not show_found:
for show_provider_source, show_provider_id in jellyfin_show[
"ProviderIds"
].items():
if show_provider_source.lower() in videos_shows_ids:
if (
show_provider_id.lower()
in videos_shows_ids[
show_provider_source.lower()
]
):
show_found = True
for show, seasons in videos.items():
show = {k: v for k, v in show}
if show_provider_id.lower() in show.get(
show_provider_source.lower(), []
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
break
if show_found:
logger(
f"Jellyfin: Updating watched for {user_name} in library {library} for show {jellyfin_show.get('Name')}",
1,
)
jellyfin_show_id = jellyfin_show["Id"]
jellyfin_episodes = self.query(
f"/Shows/{jellyfin_show_id}/Episodes"
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
for jellyfin_episode in jellyfin_episodes["Items"]:
episode_status = get_video_status(
jellyfin_episode, videos_episodes_ids, episode_videos
)
if episode_status:
jellyfin_episode_id = jellyfin_episode["Id"]
if episode_status["completed"]:
msg = (
f"Jellyfin: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as watched for {user_name} in {library}"
)
if not dryrun:
logger(msg, 5)
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
"post",
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_episode.get("SeriesName"),
jellyfin_episode.get("Name"),
)
else:
# TODO add support for partially watched episodes
msg = (
f"Jellyfin: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library}"
)
"""
if not dryrun:
pass
# logger(f"Marked {msg}", 0)
else:
pass
# logger(f"Dryrun {msg}", 0)
log_marked(
user_name,
library,
jellyfin_episode.get("SeriesName"),
jellyfin_episode.get('Name'),
duration=floor(episode_status["time"] / 60_000),
)"""
else:
logger(
f"Jellyfin: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}",
3,
)
else:
logger(
f"Jellyfin: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}",
3,
)
except Exception as e:
logger(
f"Jellyfin: Error updating watched for {user_name} in library {library}, {e}",
2,
)
logger(traceback.format_exc(), 2)
raise Exception(e)
def update_watched(
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
):
try:
for user, libraries in watched_list.items():
logger(f"Jellyfin: Updating for entry {user}, {libraries}", 1)
user_other = None
user_name = None
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
user_id = None
for key in self.users:
if user.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
elif user_other and user_other.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
if not user_id:
logger(f"{user} {user_other} not found in Jellyfin", 2)
continue
jellyfin_libraries = self.query(
f"/Users/{user_id}/Views",
"get",
)
jellyfin_libraries = [x for x in jellyfin_libraries["Items"]]
for library, videos in libraries.items():
library_other = None
if library_mapping:
if library in library_mapping.keys():
library_other = library_mapping[library]
elif library in library_mapping.values():
library_other = search_mapping(library_mapping, library)
if library.lower() not in [
x["Name"].lower() for x in jellyfin_libraries
]:
if library_other:
if library_other.lower() in [
x["Name"].lower() for x in jellyfin_libraries
]:
logger(
f"Jellyfin: Library {library} not found, but {library_other} found, using {library_other}",
1,
)
library = library_other
else:
logger(
f"Jellyfin: Library {library} or {library_other} not found in library list",
1,
)
continue
else:
logger(
f"Jellyfin: Library {library} not found in library list",
1,
)
continue
library_id = None
for jellyfin_library in jellyfin_libraries:
if jellyfin_library["Name"] == library:
library_id = jellyfin_library["Id"]
continue
if library_id:
self.update_user_watched(
user_name, user_id, library, library_id, videos, dryrun
)
except Exception as e:
logger(f"Jellyfin: Error updating watched, {e}", 2)
raise Exception(e)

860
src/jellyfin_emby.py Normal file
View File

@ -0,0 +1,860 @@
# Functions for Jellyfin and Emby
import traceback, os
from math import floor
from dotenv import load_dotenv
import requests
from src.functions import (
logger,
search_mapping,
contains_nested,
log_marked,
str_to_bool,
)
from src.library import (
check_skip_logic,
generate_library_guids_dict,
)
from src.watched import (
combine_watched_dicts,
)
load_dotenv(override=True)
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
def get_guids(server_type, item):
if item.get("Name"):
guids = {"title": item.get("Name")}
else:
logger(f"{server_type}: Name not found in {item.get('Id')}", 1)
guids = {"title": None}
if "ProviderIds" in item:
guids.update({k.lower(): v for k, v in item["ProviderIds"].items()})
else:
logger(f"{server_type}: ProviderIds not found in {item.get('Name')}", 1)
if "MediaSources" in item:
guids["locations"] = tuple(
[x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x]
)
else:
logger(f"{server_type}: MediaSources not found in {item.get('Name')}", 1)
guids["locations"] = tuple()
if "UserData" in item:
guids["status"] = {
"completed": item["UserData"]["Played"],
# Convert ticks to milliseconds to match Plex
"time": floor(item["UserData"]["PlaybackPositionTicks"] / 10000),
}
else:
logger(f"{server_type}: UserData not found in {item.get('Name')}", 1)
guids["status"] = {}
return guids
def get_video_status(server_video, videos_ids, videos):
video_status = None
if generate_locations:
if "MediaSources" in server_video:
for video_location in server_video["MediaSources"]:
if "Path" in video_location:
if (
contains_nested(
video_location["Path"].split("/")[-1],
videos_ids["locations"],
)
is not None
):
for video in videos:
if (
contains_nested(
video_location["Path"].split("/")[-1],
video["locations"],
)
is not None
):
video_status = video["status"]
break
break
if generate_guids:
if not video_status:
for (
video_provider_source,
video_provider_id,
) in server_video["ProviderIds"].items():
if video_provider_source.lower() in videos_ids:
if (
video_provider_id.lower()
in videos_ids[video_provider_source.lower()]
):
for video in videos:
if video_provider_id.lower() in video.get(
video_provider_source.lower(), []
):
video_status = video["status"]
break
break
return video_status
class JellyfinEmby:
def __init__(self, server_type, baseurl, token, headers):
if server_type not in ["Jellyfin", "Emby"]:
raise Exception(f"Server type {server_type} not supported")
self.server_type = server_type
self.baseurl = baseurl
self.token = token
self.headers = headers
self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300))
if not self.baseurl:
raise Exception(f"{self.server_type} baseurl not set")
if not self.token:
raise Exception(f"{self.server_type} token not set")
self.session = requests.Session()
self.users = self.get_users()
def query(self, query, query_type, identifiers=None):
try:
results = None
if query_type == "get":
response = self.session.get(
self.baseurl + query, headers=self.headers, timeout=self.timeout
)
if response.status_code != 200:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
results = response.json()
elif query_type == "post":
response = self.session.post(
self.baseurl + query, headers=self.headers, timeout=self.timeout
)
if response.status_code != 200:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
results = response.json()
if not isinstance(results, list) and not isinstance(results, dict):
raise Exception("Query result is not of type list or dict")
# append identifiers to results
if identifiers:
results["Identifiers"] = identifiers
return results
except Exception as e:
logger(
f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}",
2,
)
raise Exception(e)
def info(self) -> str:
try:
query_string = "/System/Info/Public"
response = self.query(query_string, "get")
if response:
return f"{response['ServerName']}: {response['Version']}"
else:
return None
except Exception as e:
logger(f"{self.server_type}: Get server name failed {e}", 2)
raise Exception(e)
def get_users(self):
try:
users = {}
query_string = "/Users"
response = self.query(query_string, "get")
# If response is not empty
if response:
for user in response:
users[user["Name"]] = user["Id"]
return users
except Exception as e:
logger(f"{self.server_type}: Get users failed {e}", 2)
raise Exception(e)
def get_user_library_watched(
self, user_name, user_id, library_type, library_id, library_title
):
try:
user_name = user_name.lower()
user_watched = {}
user_watched[user_name] = {}
logger(
f"{self.server_type}: Generating watched for {user_name} in library {library_title}",
0,
)
# Movies
if library_type == "Movie":
user_watched[user_name][library_title] = []
watched = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
in_progress = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
for movie in watched["Items"] + in_progress["Items"]:
if "MediaSources" in movie and movie["MediaSources"] != {}:
if "UserData" not in movie:
continue
# Skip if not watched or watched less than a minute
if (
movie["UserData"]["Played"] == True
or movie["UserData"]["PlaybackPositionTicks"] > 600000000
):
logger(
f"{self.server_type}: Adding {movie.get('Name')} to {user_name} watched list",
3,
)
# Get the movie's GUIDs
movie_guids = get_guids(self.server_type, movie)
# Append the movie dictionary to the list for the given user and library
user_watched[user_name][library_title].append(movie_guids)
logger(
f"{self.server_type}: Added {movie_guids} to {user_name} watched list",
3,
)
# TV Shows
if library_type in ["Series", "Episode"]:
# Initialize an empty dictionary for the given user and library
user_watched[user_name][library_title] = {}
# Retrieve a list of watched TV shows
watched_shows = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
"get",
)
# Filter the list of shows to only include those that have been partially or fully watched
watched_shows_filtered = []
for show in watched_shows["Items"]:
if not "UserData" in show:
continue
if "PlayedPercentage" in show["UserData"]:
if show["UserData"]["PlayedPercentage"] > 0:
watched_shows_filtered.append(show)
# Retrieve the seasons of each watched show
seasons_watched = []
for show in watched_shows_filtered:
logger(
f"{self.server_type}: Adding {show.get('Name')} to {user_name} watched list",
3,
)
show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()}
show_guids["title"] = show["Name"]
show_guids["locations"] = (
tuple([show["Path"].split("/")[-1]])
if "Path" in show
else tuple()
)
show_guids = frozenset(show_guids.items())
show_identifiers = {
"show_guids": show_guids,
"show_id": show["Id"],
}
seasons_watched.append(
self.query(
f"/Shows/{show['Id']}/Seasons"
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount",
"get",
identifiers=frozenset(show_identifiers.items()),
)
)
# Filter the list of seasons to only include those that have been partially or fully watched
seasons_watched_filtered = []
for seasons in seasons_watched:
seasons_watched_filtered_dict = {}
seasons_watched_filtered_dict["Identifiers"] = seasons[
"Identifiers"
]
seasons_watched_filtered_dict["Items"] = []
for season in seasons["Items"]:
if "PlayedPercentage" in season["UserData"]:
if season["UserData"]["PlayedPercentage"] > 0:
seasons_watched_filtered_dict["Items"].append(season)
if seasons_watched_filtered_dict["Items"]:
seasons_watched_filtered.append(seasons_watched_filtered_dict)
# Create a list of tasks to retrieve the episodes of each watched season
watched_episodes = []
for seasons in seasons_watched_filtered:
if len(seasons["Items"]) > 0:
for season in seasons["Items"]:
if "IndexNumber" not in season:
logger(
f"Jellyfin: Skipping show {season.get('SeriesName')} season {season.get('Name')} as it has no index number",
3,
)
continue
season_identifiers = dict(seasons["Identifiers"])
season_identifiers["season_index"] = season["IndexNumber"]
watched_task = self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsPlayed&Fields=ProviderIds,MediaSources",
"get",
identifiers=frozenset(season_identifiers.items()),
)
in_progress_task = self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsResumable&Fields=ProviderIds,MediaSources",
"get",
identifiers=frozenset(season_identifiers.items()),
)
watched_episodes.append(watched_task)
watched_episodes.append(in_progress_task)
# Iterate through the watched episodes
for episodes in watched_episodes:
# If the season has any watched episodes
if len(episodes["Items"]) > 0:
# Create a dictionary for the season with its identifier and episodes
season_dict = {}
season_dict["Identifiers"] = dict(episodes["Identifiers"])
season_dict["Episodes"] = []
for episode in episodes["Items"]:
if (
"MediaSources" in episode
and episode["MediaSources"] != {}
):
# If watched or watched more than a minute
if (
episode["UserData"]["Played"] == True
or episode["UserData"]["PlaybackPositionTicks"]
> 600000000
):
episode_dict = get_guids(self.server_type, episode)
# Add the episode dictionary to the season's list of episodes
season_dict["Episodes"].append(episode_dict)
# Add the season dictionary to the show's list of seasons
if (
season_dict["Identifiers"]["show_guids"]
not in user_watched[user_name][library_title]
):
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
] = {}
if (
season_dict["Identifiers"]["season_index"]
not in user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
]
):
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
][season_dict["Identifiers"]["season_index"]] = []
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
][season_dict["Identifiers"]["season_index"]] = season_dict[
"Episodes"
]
logger(
f"{self.server_type}: Added {season_dict['Episodes']} to {user_name} {season_dict['Identifiers']['show_guids']} watched list",
1,
)
logger(
f"{self.server_type}: Got watched for {user_name} in library {library_title}",
1,
)
if library_title in user_watched[user_name]:
logger(
f"{self.server_type}: {user_watched[user_name][library_title]}", 3
)
return user_watched
except Exception as e:
logger(
f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
2,
)
logger(traceback.format_exc(), 2)
return {}
def get_users_watched(
self,
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
):
try:
# Get all libraries
user_name = user_name.lower()
watched = []
libraries = []
all_libraries = self.query(f"/Users/{user_id}/Views", "get")
for library in all_libraries["Items"]:
library_id = library["Id"]
library_title = library["Name"]
identifiers = {
"library_id": library_id,
"library_title": library_title,
}
libraries.append(
self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
"get",
identifiers=identifiers,
)
)
for library in libraries:
if len(library["Items"]) == 0:
continue
library_id = library["Identifiers"]["library_id"]
library_title = library["Identifiers"]["library_title"]
# Get all library types excluding "Folder"
types = set(
[
x["Type"]
for x in library["Items"]
if x["Type"] in ["Movie", "Series", "Episode"]
]
)
skip_reason = check_skip_logic(
library_title,
types,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
if skip_reason:
logger(
f"{self.server_type}: Skipping library {library_title}: {skip_reason}",
1,
)
continue
# If there are multiple types in library raise error
if types is None or len(types) < 1:
all_types = set([x["Type"] for x in library["Items"]])
logger(
f"{self.server_type}: Skipping Library {library_title} found types: {types}, all types: {all_types}",
1,
)
continue
for library_type in types:
# Get watched for user
watched.append(
self.get_user_library_watched(
user_name,
user_id,
library_type,
library_id,
library_title,
)
)
return watched
except Exception as e:
logger(f"{self.server_type}: Failed to get users watched, Error: {e}", 2)
raise Exception(e)
def get_watched(
self,
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping=None,
):
try:
users_watched = {}
watched = []
for user_name, user_id in users.items():
watched.append(
self.get_users_watched(
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
)
for user_watched in watched:
user_watched_combine = combine_watched_dicts(user_watched)
for user, user_watched_temp in user_watched_combine.items():
if user not in users_watched:
users_watched[user] = {}
users_watched[user].update(user_watched_temp)
return users_watched
except Exception as e:
logger(f"{self.server_type}: Failed to get watched, Error: {e}", 2)
raise Exception(e)
def update_user_watched(
self, user_name, user_id, library, library_id, videos, dryrun
):
try:
logger(
f"{self.server_type}: Updating watched for {user_name} in library {library}",
1,
)
(
videos_shows_ids,
videos_episodes_ids,
videos_movies_ids,
) = generate_library_guids_dict(videos)
if (
not videos_movies_ids
and not videos_shows_ids
and not videos_episodes_ids
):
logger(
f"{self.server_type}: No videos to mark as watched for {user_name} in library {library}",
1,
)
return
logger(
f"{self.server_type}: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
1,
)
if videos_movies_ids:
jellyfin_search = self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie",
"get",
)
for jellyfin_video in jellyfin_search["Items"]:
movie_status = get_video_status(
jellyfin_video, videos_movies_ids, videos
)
if movie_status:
jellyfin_video_id = jellyfin_video["Id"]
if movie_status["completed"]:
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library}"
if not dryrun:
logger(msg, 5)
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
"post",
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_video.get("Name"),
)
else:
# TODO add support for partially watched movies
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library}"
"""
if not dryrun:
pass
# logger(msg, 5)
else:
pass
# logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_video.get("Name"),
duration=floor(movie_status["time"] / 60_000),
)"""
else:
logger(
f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}",
3,
)
# TV Shows
if videos_shows_ids and videos_episodes_ids:
jellyfin_search = self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series",
"get",
)
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for jellyfin_show in jellyfin_shows:
show_found = False
episode_videos = []
if generate_locations:
if "Path" in jellyfin_show:
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
videos_shows_ids["locations"],
)
is not None
):
show_found = True
for shows, seasons in videos.items():
show = {k: v for k, v in shows}
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
show["locations"],
)
is not None
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
break
if generate_guids:
if not show_found:
for show_provider_source, show_provider_id in jellyfin_show[
"ProviderIds"
].items():
if show_provider_source.lower() in videos_shows_ids:
if (
show_provider_id.lower()
in videos_shows_ids[
show_provider_source.lower()
]
):
show_found = True
for show, seasons in videos.items():
show = {k: v for k, v in show}
if show_provider_id.lower() in show.get(
show_provider_source.lower(), []
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
break
if show_found:
logger(
f"{self.server_type}: Updating watched for {user_name} in library {library} for show {jellyfin_show.get('Name')}",
1,
)
jellyfin_show_id = jellyfin_show["Id"]
jellyfin_episodes = self.query(
f"/Shows/{jellyfin_show_id}/Episodes"
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
for jellyfin_episode in jellyfin_episodes["Items"]:
episode_status = get_video_status(
jellyfin_episode, videos_episodes_ids, episode_videos
)
if episode_status:
jellyfin_episode_id = jellyfin_episode["Id"]
if episode_status["completed"]:
msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as watched for {user_name} in {library}"
)
if not dryrun:
logger(msg, 5)
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
"post",
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_episode.get("SeriesName"),
jellyfin_episode.get("Name"),
)
else:
# TODO add support for partially watched episodes
msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library}"
)
"""
if not dryrun:
pass
# logger(f"Marked {msg}", 0)
else:
pass
# logger(f"Dryrun {msg}", 0)
log_marked(
user_name,
library,
jellyfin_episode.get("SeriesName"),
jellyfin_episode.get('Name'),
duration=floor(episode_status["time"] / 60_000),
)"""
else:
logger(
f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}",
3,
)
else:
logger(
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:
logger(
f"{self.server_type}: Error updating watched for {user_name} in library {library}, {e}",
2,
)
logger(traceback.format_exc(), 2)
raise Exception(e)
def update_watched(
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
):
try:
for user, libraries in watched_list.items():
logger(f"{self.server_type}: Updating for entry {user}, {libraries}", 1)
user_other = None
user_name = None
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
user_id = None
for key in self.users:
if user.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
elif user_other and user_other.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
if not user_id:
logger(f"{user} {user_other} not found in Jellyfin", 2)
continue
jellyfin_libraries = self.query(
f"/Users/{user_id}/Views",
"get",
)
jellyfin_libraries = [x for x in jellyfin_libraries["Items"]]
for library, videos in libraries.items():
library_other = None
if library_mapping:
if library in library_mapping.keys():
library_other = library_mapping[library]
elif library in library_mapping.values():
library_other = search_mapping(library_mapping, library)
if library.lower() not in [
x["Name"].lower() for x in jellyfin_libraries
]:
if library_other:
if library_other.lower() in [
x["Name"].lower() for x in jellyfin_libraries
]:
logger(
f"{self.server_type}: Library {library} not found, but {library_other} found, using {library_other}",
1,
)
library = library_other
else:
logger(
f"{self.server_type}: Library {library} or {library_other} not found in library list",
1,
)
continue
else:
logger(
f"{self.server_type}: Library {library} not found in library list",
1,
)
continue
library_id = None
for jellyfin_library in jellyfin_libraries:
if jellyfin_library["Name"] == library:
library_id = jellyfin_library["Id"]
continue
if library_id:
self.update_user_watched(
user_name, user_id, library, library_id, videos, dryrun
)
except Exception as e:
logger(f"{self.server_type}: Error updating watched, {e}", 2)
raise Exception(e)

View File

@ -19,6 +19,7 @@ from src.black_white import setup_black_white_lists
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
load_dotenv(override=True) load_dotenv(override=True)
@ -65,6 +66,47 @@ def setup_users(
return output_server_1_users, output_server_2_users return output_server_1_users, output_server_2_users
def jellyfin_emby_server_connection(server_baseurl, server_token, server_type):
servers = []
server_baseurl = server_baseurl.split(",")
server_token = server_token.split(",")
if len(server_baseurl) != len(server_token):
raise Exception(
f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries"
)
for i, baseurl in enumerate(server_baseurl):
baseurl = baseurl.strip()
if baseurl[-1] == "/":
baseurl = baseurl[:-1]
if server_type == "jellyfin":
server = Jellyfin(baseurl=baseurl, token=server_token[i].strip())
servers.append(
(
"jellyfin",
server,
)
)
elif server_type == "emby":
server = Emby(baseurl=baseurl, token=server_token[i].strip())
servers.append(
(
"emby",
server,
)
)
else:
raise Exception("Unknown server type")
logger(f"{server_type} Server {i} info: {server.info()}", 3)
return servers
def generate_server_connections(): def generate_server_connections():
servers = [] servers = []
@ -137,121 +179,84 @@ def generate_server_connections():
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None) jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
if jellyfin_baseurl and jellyfin_token: if jellyfin_baseurl and jellyfin_token:
jellyfin_baseurl = jellyfin_baseurl.split(",") servers.extend(
jellyfin_token = jellyfin_token.split(",") jellyfin_emby_server_connection(
jellyfin_baseurl, jellyfin_token, "jellyfin"
if len(jellyfin_baseurl) != len(jellyfin_token):
raise Exception(
"JELLYFIN_BASEURL and JELLYFIN_TOKEN must have the same number of entries"
) )
)
for i, baseurl in enumerate(jellyfin_baseurl): emby_baseurl = os.getenv("EMBY_BASEURL", None)
baseurl = baseurl.strip() emby_token = os.getenv("EMBY_TOKEN", None)
if baseurl[-1] == "/":
baseurl = baseurl[:-1]
server = Jellyfin(baseurl=baseurl, token=jellyfin_token[i].strip()) if emby_baseurl and emby_token:
servers.extend(
logger(f"Jellyfin Server {i} info: {server.info()}", 3) jellyfin_emby_server_connection(emby_baseurl, emby_token, "emby")
servers.append( )
(
"jellyfin",
server,
)
)
return servers return servers
def get_server_watched(
server_connection: list,
users: dict,
blacklist_library: list,
whitelist_library: list,
blacklist_library_type: list,
whitelist_library_type: list,
library_mapping: dict,
):
if server_connection[0] == "plex":
return server_connection[1].get_watched(
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
elif server_connection[0] == "jellyfin":
return server_connection[1].get_watched(
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
def update_server_watched(
server_connection: list,
server_watched_filtered: dict,
user_mapping: dict,
library_mapping: dict,
dryrun: bool,
):
if server_connection[0] == "plex":
server_connection[1].update_watched(
server_watched_filtered, user_mapping, library_mapping, dryrun
)
elif server_connection[0] == "jellyfin":
server_connection[1].update_watched(
server_watched_filtered, user_mapping, library_mapping, dryrun
)
def should_sync_server(server_1_type, server_2_type): def should_sync_server(server_1_type, server_2_type):
sync_from_plex_to_jellyfin = str_to_bool( sync_from_plex_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True") os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True")
) )
sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True"))
sync_from_plex_to_emby = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_EMBY", "True"))
sync_from_jelly_to_plex = str_to_bool( sync_from_jelly_to_plex = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True") os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True")
) )
sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True"))
sync_from_jelly_to_jellyfin = str_to_bool( sync_from_jelly_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True") os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True")
) )
sync_from_jelly_to_emby = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_EMBY", "True")
)
if ( sync_from_emby_to_plex = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_PLEX", "True"))
server_1_type == "plex" sync_from_emby_to_jellyfin = str_to_bool(
and server_2_type == "plex" os.getenv("SYNC_FROM_EMBY_TO_JELLYFIN", "True")
and not sync_from_plex_to_plex )
): sync_from_emby_to_emby = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_EMBY", "True"))
logger("Sync between plex and plex is disabled", 1)
return False
if ( if server_1_type == "plex":
server_1_type == "plex" if server_2_type == "jellyfin" and not sync_from_plex_to_jellyfin:
and server_2_type == "jellyfin" logger("Sync from plex to jellyfin is disabled", 1)
and not sync_from_jelly_to_plex return False
):
logger("Sync from jellyfin to plex disabled", 1)
return False
if ( if server_2_type == "emby" and not sync_from_plex_to_emby:
server_1_type == "jellyfin" logger("Sync from plex to emby is disabled", 1)
and server_2_type == "jellyfin" return False
and not sync_from_jelly_to_jellyfin
):
logger("Sync between jellyfin and jellyfin is disabled", 1)
return False
if ( if server_2_type == "plex" and not sync_from_plex_to_plex:
server_1_type == "jellyfin" logger("Sync from plex to plex is disabled", 1)
and server_2_type == "plex" return False
and not sync_from_plex_to_jellyfin
): if server_1_type == "jellyfin":
logger("Sync from plex to jellyfin is disabled", 1) if server_2_type == "plex" and not sync_from_jelly_to_plex:
return False logger("Sync from jellyfin to plex is disabled", 1)
return False
if server_2_type == "jellyfin" and not sync_from_jelly_to_jellyfin:
logger("Sync from jellyfin to jellyfin is disabled", 1)
return False
if server_2_type == "emby" and not sync_from_jelly_to_emby:
logger("Sync from jellyfin to emby is disabled", 1)
return False
if server_1_type == "emby":
if server_2_type == "plex" and not sync_from_emby_to_plex:
logger("Sync from emby to plex is disabled", 1)
return False
if server_2_type == "jellyfin" and not sync_from_emby_to_jellyfin:
logger("Sync from emby to jellyfin is disabled", 1)
return False
if server_2_type == "emby" and not sync_from_emby_to_emby:
logger("Sync from emby to emby is disabled", 1)
return False
return True return True
@ -323,8 +328,7 @@ def main_loop():
) )
logger("Creating watched lists", 1) logger("Creating watched lists", 1)
server_1_watched = get_server_watched( server_1_watched = server_1[1].get_watched(
server_1,
server_1_users, server_1_users,
blacklist_library, blacklist_library,
whitelist_library, whitelist_library,
@ -333,8 +337,8 @@ def main_loop():
library_mapping, library_mapping,
) )
logger("Finished creating watched list server 1", 1) logger("Finished creating watched list server 1", 1)
server_2_watched = get_server_watched(
server_2, server_2_watched = server_2[1].get_watched(
server_2_users, server_2_users,
blacklist_library, blacklist_library,
whitelist_library, whitelist_library,
@ -343,6 +347,7 @@ def main_loop():
library_mapping, library_mapping,
) )
logger("Finished creating watched list server 2", 1) logger("Finished creating watched list server 2", 1)
logger(f"Server 1 watched: {server_1_watched}", 3) logger(f"Server 1 watched: {server_1_watched}", 3)
logger(f"Server 2 watched: {server_2_watched}", 3) logger(f"Server 2 watched: {server_2_watched}", 3)
@ -365,18 +370,18 @@ def main_loop():
1, 1,
) )
if should_sync_server(server_1[0], server_2[0]): if should_sync_server(server_2[0], server_1[0]):
update_server_watched( logger(f"Syncing {server_2[1].info()} -> {server_1[1].info()}", 0)
server_1, server_1[1].update_watched(
server_2_watched_filtered, server_2_watched_filtered,
user_mapping, user_mapping,
library_mapping, library_mapping,
dryrun, dryrun,
) )
if should_sync_server(server_2[0], server_1[0]): if should_sync_server(server_1[0], server_2[0]):
update_server_watched( logger(f"Syncing {server_1[1].info()} -> {server_2[1].info()}", 0)
server_2, server_2[1].update_watched(
server_1_watched_filtered, server_1_watched_filtered,
user_mapping, user_mapping,
library_mapping, library_mapping,

View File

@ -16,7 +16,7 @@ def generate_user_list(server):
user.username.lower() if user.username else user.title.lower() user.username.lower() if user.username else user.title.lower()
) )
elif server_type == "jellyfin": elif server_type in ["jellyfin", "emby"]:
server_users = [key.lower() for key in server_connection.users.keys()] server_users = [key.lower() for key in server_connection.users.keys()]
return server_users return server_users
@ -79,7 +79,7 @@ def generate_server_users(server, users):
or username_title.lower() in users.values() or username_title.lower() in users.values()
): ):
server_users.append(plex_user) server_users.append(plex_user)
elif server[0] == "jellyfin": elif server[0] in ["jellyfin", "emby"]:
server_users = {} server_users = {}
for jellyfin_user, jellyfin_id in server[1].users.items(): for jellyfin_user, jellyfin_id in server[1].users.items():
if ( if (

View File

@ -78,13 +78,6 @@ PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y"
## Set to True if running into ssl certificate errors ## Set to True if running into ssl certificate errors
SSL_BYPASS = "True" SSL_BYPASS = "True"
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
# Jellyfin # Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
@ -94,3 +87,30 @@ JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers ## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

View File

@ -78,13 +78,6 @@ PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y"
## Set to True if running into ssl certificate errors ## Set to True if running into ssl certificate errors
SSL_BYPASS = "True" SSL_BYPASS = "True"
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
# Jellyfin # Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
@ -94,3 +87,30 @@ JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers ## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

View File

@ -78,13 +78,6 @@ PLEX_TOKEN = "mVaCzSyd78uoWkCBzZ_Y"
## Set to True if running into ssl certificate errors ## Set to True if running into ssl certificate errors
SSL_BYPASS = "True" SSL_BYPASS = "True"
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
# Jellyfin # Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
@ -94,3 +87,30 @@ JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers ## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

View File

@ -1,6 +1,20 @@
# 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 os import os, argparse
def parse_args():
parser = argparse.ArgumentParser(
description="Check the mark.log file that is generated by the CI to make sure it contains the expected values"
)
parser.add_argument(
"--dry", action="store_true", help="Check the mark.log file for dry-run"
)
parser.add_argument(
"--write", action="store_true", help="Check the mark.log file for write-run"
)
return parser.parse_args()
def read_marklog(): def read_marklog():
@ -48,20 +62,59 @@ def check_marklog(lines, expected_values):
def main(): def main():
expected_values = [ args = parse_args()
"jellyplex_watched/Movies/Five Nights at Freddy's",
"jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
"JellyUser/Movies/Big Buck Bunny",
"JellyUser/Shows/Doctor Who/The Unquiet Dead",
"JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
]
# Triple the expected values because the CI runs three times # Expected values for the mark.log file, dry-run is slightly different than write-run
expected_values = expected_values * 3 # due to some of the items being copied over from one server to another and now being there
# for the next server run.
if args.dry:
expected_values = [
# Jellyfin -> Plex
"jellyplex_watched/Movies/Five Nights at Freddy's",
"jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
# Plex -> Jellyfin
"JellyUser/Movies/Big Buck Bunny",
"JellyUser/Shows/Doctor Who/The Unquiet Dead",
"JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
# Emby -> Plex
"jellyplex_watched/Movies/Tears of Steel",
"jellyplex_watched/TV shows/Doctor Who (2005)/World War Three (2)",
"jellyplex_watched/TV shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
# Plex -> Emby
"jellyplex_watched/Movies/Big Buck Bunny",
"jellyplex_watched/Movies/The Family Plan",
# Emby -> Jellyfin
"JellyUser/Movies/Tears of Steel",
# Jellyfin -> Emby
"jellyplex_watched/Movies/The Family Plan",
"jellyplex_watched/Movies/Five Nights at Freddy's",
]
elif args.write:
expected_values = [
"jellyplex_watched/Movies/Five Nights at Freddy's",
"jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
"JellyUser/Movies/Big Buck Bunny",
"JellyUser/Shows/Doctor Who/The Unquiet Dead",
"JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"jellyplex_watched/Movies/Tears of Steel",
"jellyplex_watched/TV shows/Doctor Who (2005)/World War Three (2)",
"jellyplex_watched/TV shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
"jellyplex_watched/Movies/Big Buck Bunny",
"jellyplex_watched/Movies/The Family Plan",
"jellyplex_watched/Movies/Five Nights at Freddy's",
"JellyUser/Movies/Tears of Steel",
"jellyplex_watched/TV shows/Doctor Who (2005)/World War Three (2)",
"jellyplex_watched/TV shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
]
lines = read_marklog() lines = read_marklog()
if not check_marklog(lines, expected_values): if not check_marklog(lines, expected_values):