Add support for emby
parent
5b1933cb08
commit
1f7da2f609
36
.env.sample
36
.env.sample
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
[](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)
|
[](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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
874
src/jellyfin.py
874
src/jellyfin.py
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
213
src/main.py
213
src/main.py
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue