pull/1/head
Luigi311 2022-05-22 21:49:10 -06:00
parent df1a6c06fe
commit 34579a52c8
10 changed files with 697 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
.env

13
.env.sample Normal file
View File

@ -0,0 +1,13 @@
PLEX_BASEURL = "http://localhost:32400"
PLEX_TOKEN = "SuperSecretToken"
#PLEX_USERNAME = ""
#PLEX_PASSWORD = ""
JELLYFIN_BASEURL = "http://localhost:8096"
JELLYFIN_TOKEN = "SuperSecretToken"
#BLACKLIST_LIBRARY = ""
#WHITELIST_LIBRARY = ""
#BLACKLIST_LIBRARY_TYPE = ""
#WHITELIST_LIBRARY_TYPE = ""
#BLACKLIST_USERS = ""
WHITELIST_USERS = "testuser1,testuser2"

75
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,75 @@
name: CI
on:
push:
paths-ignore:
- .gitignore
- "*.md"
pull_request:
paths-ignore:
- .gitignore
- "*.md"
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Docker meta
id: docker_meta
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
if: "${{ env.DOCKER_USERNAME != '' }}"
uses: docker/metadata-action@v3
with:
images: ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched # list of Docker images to use as base name for tags
flavor: |
latest=true
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
if: "${{ steps.docker_meta.outcome == 'success' }}"
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build
id: build
if: "${{ steps.docker_meta.outcome == 'skipped' }}"
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: false
tags: jellyplex-watched:action
- name: Build Push
id: build_push
if: "${{ steps.docker_meta.outcome == 'success' }}"
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
# Echo digest so users can validate their image
- name: Image digest
if: "${{ steps.docker_meta.outcome == 'success' }}"
run: echo "${{ steps.build_push.outputs.digest }}"

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.env
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM python:3-slim
WORKDIR /app
COPY ./requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-u", "main.py"]

View File

@ -1,2 +1,67 @@
# JellyPlex-Watched
Sync watched between jellyfin and plex
## Description
Keep in sync all your users watched history between jellyfin and plex locally. This uses the imdb ids and any other matching id to find the correct episode/movie between the two. This is not perfect but it works for most cases.
## Installation
### Baremeta
- Setup virtualenv of your choice
- Install dependencies
```bash
pip install -r requirements.txt
```
- Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens
- Run
```bash
python main.py
```
### Docker
- Build docker image
```bash
docker build -t jellyplex-watched .
```
- or use pre-built image
```bash
docker pull luigi311/jellyplex-watched:latest
```
#### With variables
- Run
```bash
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
```
#### With .env
- Create a .env file similar to .env.sample and set the MNEMONIC variable to your seed phrase
- Run
```bash
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
```
## Contributing
I am open to recieving pull requests. If you are submitting a pull request, please make sure run it locally for a day or two to make sure it is working as expected and stable. Make all pull requests against the dev branch and nothing will be merged into the main without going through the lower branches.
## License
This is currently under the GNU General Public License v3.0.

172
main.py Normal file
View File

@ -0,0 +1,172 @@
import copy, os
from time import sleep
from dotenv import load_dotenv
from src.plex import Plex
from src.jellyfin import Jellyfin
load_dotenv(override=True)
def cleanup_watched(watched_list_1, watched_list_2):
modified_watched_list_1 = copy.deepcopy(watched_list_1)
# remove entries from plex_watched that are in jellyfin_watched
for user in watched_list_1:
if user in modified_watched_list_1:
for library in watched_list_1[user]:
if library in modified_watched_list_1[user]:
for item in watched_list_1[user][library]:
if item in modified_watched_list_1[user][library]:
if user in watched_list_2 and library in watched_list_2[user]:
# Movies
if isinstance(watched_list_1[user][library], list):
for watch_list_1_key, watch_list_1_value in item.items():
for watch_list_2_item in watched_list_2[user][library]:
for watch_list_2_item_key, watch_list_2_item_value in watch_list_2_item.items():
if watch_list_1_key == watch_list_2_item_key and watch_list_1_value == watch_list_2_item_value:
if item in modified_watched_list_1[user][library]:
modified_watched_list_1[user][library].remove(item)
# TV Shows
elif isinstance(watched_list_1[user][library], dict):
if item in watched_list_2[user][library]:
for season in watched_list_1[user][library][item]:
if season in watched_list_2[user][library][item]:
for episode in watched_list_1[user][library][item][season]:
for watch_list_1_episode_key, watch_list_1_episode_value in episode.items():
for watch_list_2_episode in watched_list_2[user][library][item][season]:
for watch_list_2_episode_key, watch_list_2_episode_value in watch_list_2_episode.items():
if watch_list_1_episode_key == watch_list_2_episode_key and watch_list_1_episode_value == watch_list_2_episode_value:
if episode in modified_watched_list_1[user][library][item][season]:
modified_watched_list_1[user][library][item][season].remove(episode)
# If season is empty, remove season
if len(modified_watched_list_1[user][library][item][season]) == 0:
if season in modified_watched_list_1[user][library][item]:
del modified_watched_list_1[user][library][item][season]
# If the show is empty, remove the show
if len(modified_watched_list_1[user][library][item]) == 0:
if item in modified_watched_list_1[user][library]:
del modified_watched_list_1[user][library][item]
# If library is empty then remove it
if len(modified_watched_list_1[user][library]) == 0:
if library in modified_watched_list_1[user]:
del modified_watched_list_1[user][library]
# If user is empty delete user
if len(modified_watched_list_1[user]) == 0:
del modified_watched_list_1[user]
return modified_watched_list_1
if __name__ == "__main__":
plex = Plex()
jellyfin = Jellyfin()
blacklist_library = os.getenv("BLACKLIST_LIBRARY")
if blacklist_library:
if len(blacklist_library) > 0:
blacklist_library = blacklist_library.split(",")
blacklist_library = [x.lower().trim() for x in blacklist_library]
else:
blacklist_library = []
whitelist_library = os.getenv("WHITELIST_LIBRARY")
if whitelist_library:
if len(whitelist_library) > 0:
whitelist_library = whitelist_library.split(",")
whitelist_library = [x.lower().strip() for x in whitelist_library]
else:
whitelist_library = []
blacklist_library_type = os.getenv("BLACKLIST_LIBRARY_TYPE")
if blacklist_library_type:
if len(blacklist_library_type) > 0:
blacklist_library_type = blacklist_library_type.split(",")
blacklist_library_type = [x.lower().strip() for x in blacklist_library_type]
else:
blacklist_library_type = []
whitelist_library_type = os.getenv("WHITELIST_LIBRARY_TYPE")
if whitelist_library_type:
if len(whitelist_library_type) > 0:
whitelist_library_type = whitelist_library_type.split(",")
whitelist_library_type = [x.lower().strip() for x in whitelist_library_type]
else:
whitelist_library_type = []
blacklist_users = os.getenv("BLACKLIST_USERS")
if blacklist_users:
if len(blacklist_users) > 0:
blacklist_users = blacklist_users.split(",")
blacklist_users = [x.lower().strip() for x in blacklist_users]
else:
blacklist_users = []
whitelist_users = os.getenv("WHITELIST_USERS")
# print whitelist_users object type
if whitelist_users:
if len(whitelist_users) > 0:
whitelist_users = whitelist_users.split(",")
whitelist_users = [x.lower().strip() for x in whitelist_users]
else:
whitelist_users = []
else:
whitelist_users = []
users_filtered = []
# generate list of users from plex.users
plex_users = [ x.title.lower() for x in plex.users ]
jellyfin_users = [ key.lower() for key in jellyfin.users.keys() ]
# combined list of overlapping users from plex and jellyfin
users = [x for x in plex_users if x in jellyfin_users]
for user in users:
# whitelist_user is not empty and user lowercase is not in whitelist lowercase
if len(whitelist_users) > 0 and user.lower() not in whitelist_users:
print(f"{user} is not in whitelist")
else:
if user.lower() not in blacklist_users:
users_filtered.append(user)
plex_users = []
for plex_user in plex.users:
if plex_user.title.lower() in users_filtered:
plex_users.append(plex_user)
jellyfin_users = {}
for jellyfin_user, jellyfin_id in jellyfin.users.items():
if jellyfin_user.lower() in users_filtered:
jellyfin_users[jellyfin_user] = jellyfin_id
if len(plex_users) == 0:
raise Exception("No users found")
if len(jellyfin_users) == 0:
raise Exception("No users found")
plex_watched = plex.get_plex_watched(plex_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type)
jellyfin_watched = jellyfin.get_jellyfin_watched(jellyfin_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type)
# clone watched so it isnt modified in the cleanup function so all duplicates are actually removed
plex_watched_filtered = copy.deepcopy(plex_watched)
jellyfin_watched_filtered = copy.deepcopy(jellyfin_watched)
plex_watched = cleanup_watched(plex_watched_filtered, jellyfin_watched_filtered)
print(f"Plex Watched: {plex_watched}")
jellyfin_watched = cleanup_watched(jellyfin_watched_filtered, plex_watched_filtered)
print(f"Jellyfin Watched: {jellyfin_watched}")
# Update watched status
plex.update_watched(jellyfin_watched)
print("Plex watched updated")
jellyfin.update_watched(plex_watched)
print("Jellyfin watched updated")

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
plexapi
requests
python-dotenv

189
src/jellyfin.py Normal file
View File

@ -0,0 +1,189 @@
import requests, os
from dotenv import load_dotenv
load_dotenv(override=True)
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL")
jellyfin_token = os.getenv("JELLYFIN_TOKEN")
class Jellyfin():
def __init__(self):
self.baseurl = jellyfin_baseurl
self.token = jellyfin_token
if not self.baseurl:
raise Exception("Jellyfin baseurl not set")
if not self.token:
raise Exception("Jellyfin token not set")
self.users = self.get_users()
def query(self, query, query_type):
try:
response = None
if query_type == "get":
response = requests.get(self.baseurl + query, headers={"accept":"application/json", "X-Emby-Token": self.token})
elif query_type == "post":
authorization = (
'MediaBrowser , '
'Client="other", '
'Device="script", '
'DeviceId="script", '
'Version="0.0.0"'
)
response = requests.post(self.baseurl + query, headers={"accept":"application/json", "X-Emby-Authorization": authorization, "X-Emby-Token": self.token})
return response.json()
except Exception as e:
print(e)
print(response)
def get_users(self):
users = {}
query = "/Users"
response = self.query(query, "get")
# If reponse is not empty
if response:
for user in response:
users[user["Name"]] = user["Id"]
return users
def get_jellyfin_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type):
users_watched = {}
for user_name, user_id in users.items():
# Get all libraries
user_name = user_name.lower()
libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"]
for library in libraries:
library_title = library["Name"]
print(f"Jellyfin: Generating watched for {user_name} in library {library_title}")
library_id = library["Id"]
# if whitelist is not empty and library is not in whitelist
if len(whitelist_library) > 0 and library_title.lower() not in [x.lower() for x in whitelist_library]:
pass
else:
if library_title.lower() not in [x.lower() for x in blacklist_library]:
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&limit=1", "get")
if len(watched["Items"]) == 0:
pass
else:
library_type = watched["Items"][0]["Type"]
# if Type in blacklist_library_type then break
if library_type in blacklist_library_type or (len(whitelist_library_type) > 0 and library_type.lower() not in whitelist_library_type):
break
# Movies
if library_type == "Movie":
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&Fields=ItemCounts,ProviderIds", "get")
for movie in watched["Items"]:
if movie["UserData"]["Played"] == True:
if movie["ProviderIds"]:
if user_name not in users_watched:
users_watched[user_name] = {}
if library_title not in users_watched[user_name]:
users_watched[user_name][library_title] = []
# Lowercase movie["ProviderIds"] keys
movie["ProviderIds"] = {k.lower(): v for k, v in movie["ProviderIds"].items()}
users_watched[user_name][library_title].append(movie["ProviderIds"])
# TV Shows
if library_type == "Episode":
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}", "get")
watched_shows = [x for x in watched["Items"] if x["Type"] == "Series"]
for show in watched_shows:
seasons = self.query(f"/Shows/{show['Id']}/Seasons?userId={user_id}&Fields=ItemCounts", "get")
if len(seasons["Items"]) > 0:
for season in seasons["Items"]:
episodes = self.query(f"/Shows/{show['Id']}/Episodes?seasonId={season['Id']}&userId={user_id}&Fields=ItemCounts,ProviderIds", "get")
if len(episodes["Items"]) > 0:
for episode in episodes["Items"]:
if episode["UserData"]["Played"] == True:
if episode["ProviderIds"]:
if user_name not in users_watched:
users_watched[user_name] = {}
if library_title not in users_watched[user_name]:
users_watched[user_name][library_title] = {}
if show["Name"] not in users_watched[user_name][library_title]:
users_watched[user_name][library_title][show["Name"]] = {}
if season["Name"] not in users_watched[user_name][library_title][show["Name"]]:
users_watched[user_name][library_title][show["Name"]][season["Name"]] = []
# Lowercase episode["ProviderIds"] keys
episode["ProviderIds"] = {k.lower(): v for k, v in episode["ProviderIds"].items()}
users_watched[user_name][library_title][show["Name"]][season["Name"]].append(episode["ProviderIds"])
return users_watched
def update_watched(self, watched_list):
for user, libraries in watched_list.items():
user_id = None
for key, value in self.users.items():
if user.lower() == key.lower():
user_id = self.users[key]
break
if not user_id:
print(f"{user} not found in Jellyfin")
break
jellyfin_libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"]
for library, videos in libraries.items():
library_id = None
for jellyfin_library in jellyfin_libraries:
if jellyfin_library["Name"] == library:
library_id = jellyfin_library["Id"]
break
if library_id:
library_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&limit=1", "get")
library_type = library_search["Items"][0]["Type"]
# Movies
if library_type == "Movie":
jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds", "get")
for jellyfin_video in jellyfin_search["Items"]:
if jellyfin_video["UserData"]["Played"] == False:
jellyfin_video_id = jellyfin_video["Id"]
for video in videos:
for key, value in jellyfin_video["ProviderIds"].items():
if key.lower() in video.keys() and value.lower() == video[key.lower()].lower():
print(f"Marking {jellyfin_video['Name']} as watched for {user}")
self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", "post")
break
# TV Shows
if library_type == "Episode":
jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&isPlayed=false", "get")
jellyfin_shows = [x for x in jellyfin_search["Items"] if x["Type"] == "Series"]
for jellyfin_show in jellyfin_shows:
if jellyfin_show["Name"] in videos.keys():
jellyfin_show_id = jellyfin_show["Id"]
jellyfin_episodes = self.query(f"/Shows/{jellyfin_show_id}/Episodes?userId={user_id}&Fields=ItemCounts,ProviderIds", "get")
for jellyfin_episode in jellyfin_episodes["Items"]:
if jellyfin_episode["UserData"]["Played"] == False:
jellyfin_episode_id = jellyfin_episode["Id"]
for show in videos:
for season in videos[show]:
for episode in videos[show][season]:
for key, value in jellyfin_episode["ProviderIds"].items():
if key.lower() in episode.keys() and value.lower() == episode[key.lower()].lower():
print(f"Marked {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} {jellyfin_episode['Name']} as watched for {user} in Jellyfin")
self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", "post")
break

167
src/plex.py Normal file
View File

@ -0,0 +1,167 @@
import re, os
from dotenv import load_dotenv
from time import sleep
from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount
load_dotenv(override=True)
plex_baseurl = os.getenv("PLEX_BASEURL")
plex_token = os.getenv("PLEX_TOKEN")
username = os.getenv("PLEX_USERNAME")
password = os.getenv("PLEX_PASSWORD")
# class plex accept base url and token and username and password but default with none
class Plex:
def __init__(self):
self.baseurl = plex_baseurl
self.token = plex_token
self.username = username
self.password = password
self.plex = self.plex_login()
self.admin_user = self.plex.myPlexAccount()
self.users = self.get_plex_users()
def plex_login(self):
if self.baseurl:
# if self.username and self.password are not None or empty strings
if self.username and self.password:
# Login via plex account
account = MyPlexAccount(self.username, self.password)
plex = account.resource(self.baseurl).connect()
elif self.token:
# Login via token
plex = PlexServer(self.baseurl, self.token)
else:
raise Exception("No plex credentials provided")
else:
raise Exception("No plex baseurl provided")
return plex
def get_plex_users(self):
users = self.plex.myPlexAccount().users()
# append self to users
users.append(self.plex.myPlexAccount())
return users
def get_plex_user_watched(self, user, library):
if self.admin_user == user:
user_plex = self.plex
else:
user_plex = PlexServer(self.baseurl, user.get_token(self.plex.machineIdentifier))
watched = None
if library.type == "movie":
watched = []
library_videos = user_plex.library.section(library.title)
for video in library_videos.search(unmatched=False, unwatched=False):
guids = {}
for guid in video.guids:
guid_source = re.search(r'(.*)://', guid.id).group(1).lower()
guid_id = re.search(r'://(.*)', guid.id).group(1)
guids[guid_source] = guid_id
watched.append(guids)
elif library.type == "show":
watched = {}
library_videos = user_plex.library.section(library.title)
for show in library_videos.search(unmatched=False, unwatched=False):
for season in show.seasons():
guids = []
for episode in season.episodes():
if episode.viewCount > 0:
guids_temp = {}
for guid in episode.guids:
# Extract after :// from guid.id
guid_source = re.search(r'(.*)://', guid.id).group(1).lower()
guid_id = re.search(r'://(.*)', guid.id).group(1)
guids_temp[guid_source] = guid_id
guids.append(guids_temp)
if guids:
# append show, season, episode
if show.title not in watched:
watched[show.title] = {}
if season.title not in watched[show.title]:
watched[show.title][season.title] = {}
watched[show.title][season.title] = guids
return watched
def get_plex_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type):
# Get all libraries
libraries = self.plex.library.sections()
users_watched = {}
# for not in blacklist
for library in libraries:
library_title = library.title
# if whitelist is not empty and library is not in whitelist
if (len(whitelist_library) > 0 and library_title.lower() not in [x.lower() for x in whitelist_library]) or (len(whitelist_library_type) > 0 and library_title.type() not in [x.lower() for x in whitelist_library_type]):
pass
else:
if library_title.lower() not in [x.lower() for x in blacklist_library] and library.type not in [x.lower() for x in blacklist_library_type]:
for user in users:
print(f"Plex: Generating watched for {user.title} in library {library_title}")
user_name = user.title.lower()
watched = self.get_plex_user_watched(user, library)
if watched:
if user_name not in users_watched:
users_watched[user_name] = {}
if library_title not in users_watched[user_name]:
users_watched[user_name][library_title] = []
users_watched[user_name][library_title] = watched
return users_watched
def update_watched(self, watched_list):
for user, libraries in watched_list.items():
for index, value in enumerate(self.users):
if user.lower() == value.title.lower():
user = self.users[index]
break
if self.admin_user == user:
user_plex = self.plex
else:
user_plex = PlexServer(self.baseurl, user.get_token(self.plex.machineIdentifier))
print(f"Updating watched for {user.title}")
for library, videos in libraries.items():
library_videos = user_plex.library.section(library)
if library_videos.type == "movie":
for movies_search in library_videos.search(unmatched=False, unwatched=True):
for guid in movies_search.guids:
guid_source = re.search(r'(.*)://', guid.id).group(1).lower()
guid_id = re.search(r'://(.*)', guid.id).group(1)
for video in videos:
for video_keys, video_id in video.items():
if video_keys == guid_source and video_id == guid_id:
if movies_search.viewCount == 0:
movies_search.markWatched()
print(f"Marked {movies_search.title} watched")
break
elif library_videos.type == "show":
for show_search in library_videos.search(unmatched=False, unwatched=True):
if show_search.title in videos:
for season_search in show_search.seasons():
for episode_search in season_search.episodes():
for guid in episode_search.guids:
guid_source = re.search(r'(.*)://', guid.id).group(1).lower()
guid_id = re.search(r'://(.*)', guid.id).group(1)
for show, seasons in videos.items():
for season, episodes in seasons.items():
for episode in episodes:
for episode_keys, episode_id in episode.items():
if episode_keys == guid_source and episode_id == guid_id:
if episode_search.viewCount == 0:
episode_search.markWatched()
print(f"Marked {show_search.title} {season_search.title} {episode_search.title} as watched for {user.title} in Plex")
break