27 Commits

Author SHA1 Message Date
Luigi311
16879cc728 Merge pull request #26 from luigi311/dev
Use async for jellyfin
2022-08-17 21:49:34 -06:00
Luigi311
942ec3533f Cleanup log file on runs 2022-08-17 21:43:51 -06:00
Luigi311
9f6edfc91a Merge branch 'main' into dev 2022-08-17 21:40:25 -06:00
Luigi311
827ace2e97 cleanup 2022-08-17 21:20:28 -06:00
Luigi311
f6b57a1b4d Update README.md 2022-07-10 01:38:42 -06:00
Luigi311
88a7526721 Use async for jellyfin (#23)
* Use async

* Massive jellyfin watched speedup

Co-authored-by: Luigi311 <luigi311.lg@gmail.com>
2022-07-10 01:30:12 -06:00
luigi311
1efb4d8543 Fix debug 2022-07-06 17:22:35 -06:00
Luigi311
7571e9a343 Merge pull request #22 from luigi311/dev
Fix errors on certain edge cases
2022-07-05 21:23:14 -06:00
Luigi311
7640e9ee03 fix typo 2022-07-05 19:26:58 -06:00
Luigi311
50ed3d6400 Fix user_name in plex 2022-07-05 19:26:22 -06:00
Luigi311
c9a373851f Remove indexnumber from logging 2022-07-05 19:16:25 -06:00
Luigi311
a3f3db8f4e Use generate_library_guids_dict instead of library type 2022-07-05 18:09:08 -06:00
Luigi311
de619de923 Add more logging, fix username in jellyfin mark. 2022-07-05 16:35:22 -06:00
Luigi311
852d8dc3c3 Merge pull request #18 from luigi311/dev
Dev
2022-06-21 02:53:32 -06:00
Luigi311
c104973f95 Add location based matching 2022-06-20 21:12:02 -06:00
Luigi311
8b7fc5e323 Merge pull request #17 from luigi311/pytest
Add Pytest
2022-06-20 16:10:29 -06:00
Luigi311
afb71d8e00 Handle locations in generate_library_guids_dict 2022-06-20 16:07:52 -06:00
Luigi311
34d97f8dde Add pytest action 2022-06-20 16:05:05 -06:00
Luigi311
2ad6b3afdf Add pytest 2022-06-20 15:48:07 -06:00
Luigi311
7cd492dc98 Remove worker=1 2022-06-19 03:03:17 -06:00
Luigi311
74b5ea7b5e Fix username differences in watch list. Add python version check. More error handling. 2022-06-19 02:56:50 -06:00
Luigi311
21fe4875eb Add ENVs to dockerfile 2022-06-15 13:38:28 -06:00
Luigi311
aeb86f6b85 Fix user when using plex login. Fix sleep duration 2022-06-15 13:21:03 -06:00
Luigi311
70ef31ff47 Fix threading 2022-06-15 12:51:09 -06:00
Luigi311
0584a85f90 Add parallel threading 2022-06-14 22:36:44 -06:00
Luigi311
beb4e667ae Cleanup 2022-06-13 22:43:56 -06:00
Luigi311
7695994ec2 Support x many servers of any combination 2022-06-13 22:30:41 -06:00
16 changed files with 2625 additions and 1915 deletions

View File

@@ -1,40 +1,42 @@
## Do not mark any shows/movies as played and instead just output to log if they would of been marked. ## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True" DRYRUN = "True"
## Additional logging information ## Additional logging information
DEBUG = "True" DEBUG = "True"
## Debugging level, INFO is default, DEBUG is more verbose ## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "INFO" DEBUG_LEVEL = "info"
## How often to run the script in seconds ## How often to run the script in seconds
SLEEP_DURATION = "3600" SLEEP_DURATION = "3600"
## Log file where all output will be written to ## Log file where all output will be written to
LOGFILE = "log.log" LOGFILE = "log.log"
## Map usernames between plex and jellyfin in the event that they are different, order does not matter ## Map usernames between plex and jellyfin in the event that they are different, order does not matter
#USER_MAPPING = { "testuser2": "testuser3" } #USER_MAPPING = { "testuser2": "testuser3" }
## Map libraries between plex and jellyfin in the even that they are different, order does not matter ## Map libraries between plex and jellyfin in the even that they are different, order does not matter
#LIBRARY_MAPPING = { "Shows": "TV Shows" } #LIBRARY_MAPPING = { "Shows": "TV Shows" }
## 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
## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly
PLEX_BASEURL = "http://localhost:32400" ## Comma seperated list for multiple servers
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ PLEX_BASEURL = "http://localhost:32400"
PLEX_TOKEN = "SuperSecretToken" ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
## If not using plex token then use username and password of the server admin along with the servername PLEX_TOKEN = "SuperSecretToken"
#PLEX_USERNAME = "" ## If not using plex token then use username and password of the server admin along with the servername
#PLEX_PASSWORD = "" #PLEX_USERNAME = ""
#PLEX_SERVERNAME = "Plex Server" #PLEX_PASSWORD = ""
#PLEX_SERVERNAME = "Plex Server"
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
JELLYFIN_BASEURL = "http://localhost:8096" ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## 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
JELLYFIN_TOKEN = "SuperSecretToken" 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_TOKEN = "SuperSecretToken"
## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded.
#BLACKLIST_LIBRARY = ""
#WHITELIST_LIBRARY = "" ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded.
#BLACKLIST_LIBRARY_TYPE = "" #BLACKLIST_LIBRARY = ""
#WHITELIST_LIBRARY_TYPE = "" #WHITELIST_LIBRARY = ""
#BLACKLIST_USERS = "" #BLACKLIST_LIBRARY_TYPE = ""
WHITELIST_USERS = "testuser1,testuser2" #WHITELIST_LIBRARY_TYPE = ""
#BLACKLIST_USERS = ""
WHITELIST_USERS = "testuser1,testuser2"

View File

@@ -1,74 +1,86 @@
name: CI name: CI
on: on:
push: push:
paths-ignore: paths-ignore:
- .gitignore - .gitignore
- "*.md" - "*.md"
pull_request: pull_request:
paths-ignore: paths-ignore:
- .gitignore - .gitignore
- "*.md" - "*.md"
jobs: jobs:
docker: pytest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - uses: actions/checkout@v2
uses: actions/checkout@v2
- name: "Install dependencies"
- name: Docker meta run: pip install -r requirements.txt && pip install -r test/requirements.txt
id: docker_meta
env: - name: "Run tests"
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} run: pytest -vvv
if: "${{ env.DOCKER_USERNAME != '' }}"
uses: docker/metadata-action@v4 docker:
with: runs-on: ubuntu-latest
images: ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched # list of Docker images to use as base name for tags needs: pytest
tags: | steps:
type=raw,value=latest,enable={{is_default_branch}} - name: Checkout
type=ref,event=branch uses: actions/checkout@v2
type=ref,event=pr
type=semver,pattern={{version}} - name: Docker meta
type=semver,pattern={{major}}.{{minor}} id: docker_meta
type=sha env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Set up QEMU if: "${{ env.DOCKER_USERNAME != '' }}"
uses: docker/setup-qemu-action@v1 uses: docker/metadata-action@v4
with:
- name: Set up Docker Buildx images: ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched # list of Docker images to use as base name for tags
uses: docker/setup-buildx-action@v1 tags: |
type=raw,value=latest,enable={{is_default_branch}}
- name: Login to DockerHub type=ref,event=branch
if: "${{ steps.docker_meta.outcome == 'success' }}" type=ref,event=pr
uses: docker/login-action@v1 type=semver,pattern={{version}}
with: type=semver,pattern={{major}}.{{minor}}
username: ${{ secrets.DOCKER_USERNAME }} type=sha
password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up QEMU
- name: Build uses: docker/setup-qemu-action@v1
id: build
if: "${{ steps.docker_meta.outcome == 'skipped' }}" - name: Set up Docker Buildx
uses: docker/build-push-action@v2 uses: docker/setup-buildx-action@v1
with:
context: . - name: Login to DockerHub
file: ./Dockerfile if: "${{ steps.docker_meta.outcome == 'success' }}"
platforms: linux/amd64,linux/arm64 uses: docker/login-action@v1
push: false with:
tags: jellyplex-watched:action username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build Push
id: build_push - name: Build
if: "${{ steps.docker_meta.outcome == 'success' }}" id: build
uses: docker/build-push-action@v2 if: "${{ steps.docker_meta.outcome == 'skipped' }}"
with: uses: docker/build-push-action@v2
context: . with:
file: ./Dockerfile context: .
platforms: linux/amd64,linux/arm64 file: ./Dockerfile
push: true platforms: linux/amd64,linux/arm64
tags: ${{ steps.docker_meta.outputs.tags }} push: false
labels: ${{ steps.docker_meta.outputs.labels }} tags: jellyplex-watched:action
# Echo digest so users can validate their image - name: Build Push
- name: Image digest id: build_push
if: "${{ steps.docker_meta.outcome == 'success' }}" if: "${{ steps.docker_meta.outcome == 'success' }}"
run: echo "${{ steps.build_push.outputs.digest }}" 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 }}"

263
.gitignore vendored
View File

@@ -1,131 +1,132 @@
.env .env
*.prof
# Byte-compiled / optimized / DLL files
__pycache__/ # Byte-compiled / optimized / DLL files
*.py[cod] __pycache__/
*$py.class *.py[cod]
*$py.class
# C extensions
*.so # C extensions
*.so
# Distribution / packaging
.Python # Distribution / packaging
build/ .Python
develop-eggs/ build/
dist/ develop-eggs/
downloads/ dist/
eggs/ downloads/
.eggs/ eggs/
lib/ .eggs/
lib64/ lib/
parts/ lib64/
sdist/ parts/
var/ sdist/
wheels/ var/
pip-wheel-metadata/ wheels/
share/python-wheels/ pip-wheel-metadata/
*.egg-info/ share/python-wheels/
.installed.cfg *.egg-info/
*.egg .installed.cfg
MANIFEST *.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template # PyInstaller
# before PyInstaller builds the exe, so as to inject date/other infos into it. # Usually these files are written by a python script from a template
*.manifest # before PyInstaller builds the exe, so as to inject date/other infos into it.
*.spec *.manifest
*.spec
# Installer logs
pip-log.txt # Installer logs
pip-delete-this-directory.txt pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/ # Unit test / coverage reports
.tox/ htmlcov/
.nox/ .tox/
.coverage .nox/
.coverage.* .coverage
.cache .coverage.*
nosetests.xml .cache
coverage.xml nosetests.xml
*.cover coverage.xml
*.py,cover *.cover
.hypothesis/ *.py,cover
.pytest_cache/ .hypothesis/
.pytest_cache/
# Translations
*.mo # Translations
*.pot *.mo
*.pot
# Django stuff:
*.log # Django stuff:
local_settings.py *.log
db.sqlite3 local_settings.py
db.sqlite3-journal db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/ # Flask stuff:
.webassets-cache instance/
.webassets-cache
# Scrapy stuff:
.scrapy # Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/ # Sphinx documentation
docs/_build/
# PyBuilder
target/ # PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints # Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/ # IPython
ipython_config.py profile_default/
ipython_config.py
# pyenv
.python-version # pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # pipenv
# However, in case of collaboration, if having platform-specific dependencies or dependencies # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# having no cross-platform support, pipenv may install dependencies that don't work, or not # However, in case of collaboration, if having platform-specific dependencies or dependencies
# install all needed dependencies. # having no cross-platform support, pipenv may install dependencies that don't work, or not
#Pipfile.lock # install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule # Celery stuff
celerybeat.pid celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py # SageMath parsed files
*.sage.py
# Environments
.env # Environments
.venv .env
env/ .venv
venv/ env/
ENV/ venv/
env.bak/ ENV/
venv.bak/ env.bak/
venv.bak/
# Spyder project settings
.spyderproject # Spyder project settings
.spyproject .spyderproject
.spyproject
# Rope project settings
.ropeproject # Rope project settings
.ropeproject
# mkdocs documentation
/site # mkdocs documentation
/site
# mypy
.mypy_cache/ # mypy
.dmypy.json .mypy_cache/
dmypy.json .dmypy.json
dmypy.json
# Pyre type checker
.pyre/ # Pyre type checker
.pyre/

32
.vscode/launch.json vendored
View File

@@ -1,16 +1,16 @@
{ {
// Use IntelliSense to learn about possible attributes. // Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes. // Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python", "name": "Python: Main",
"type": "python", "type": "python",
"request": "launch", "request": "launch",
"program": "main.py", "program": "main.py",
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": true "justMyCode": true
} }
] ]
} }

View File

@@ -1,10 +1,35 @@
FROM python:3-slim FROM python:3-slim
WORKDIR /app ENV DRYRUN 'True'
ENV DEBUG 'True'
COPY ./requirements.txt ./ ENV DEBUG_LEVEL 'INFO'
RUN pip install --no-cache-dir -r requirements.txt ENV SLEEP_DURATION '3600'
ENV LOGFILE 'log.log'
COPY . .
ENV USER_MAPPING '{ "User Test": "User Test2" }'
CMD ["python", "-u", "main.py"] ENV LIBRARY_MAPPING '{ "Shows Test": "TV Shows Test" }'
ENV PLEX_BASEURL 'http://localhost:32400'
ENV PLEX_TOKEN ''
ENV PLEX_USERNAME ''
ENV PLEX_PASSWORD ''
ENV PLEX_SERVERNAME ''
ENV JELLYFIN_BASEURL 'http://localhost:8096'
ENV JELLYFIN_TOKEN ''
ENV BLACKLIST_LIBRARY ''
ENV WHITELIST_LIBRARY ''
ENV BLACKLIST_LIBRARY_TYPE ''
ENV WHITELIST_LIBRARY_TYPE ''
ENV BLACKLIST_USERS ''
ENV WHITELIST_USERS ''
WORKDIR /app
COPY ./requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-u", "main.py"]

1348
LICENSE

File diff suppressed because it is too large Load Diff

142
README.md
View File

@@ -1,69 +1,73 @@
# JellyPlex-Watched # JellyPlex-Watched
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=luigi311/JellyPlex-Watched&amp;utm_campaign=Badge_Grade) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=luigi311/JellyPlex-Watched&amp;utm_campaign=Badge_Grade)
Sync watched between jellyfin and plex Sync watched between jellyfin and plex
## Description ## 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. Keep in sync all your users watched history between jellyfin and plex servers 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. You can use this for as many servers as you want by enterying multiple options in the .env plex/jellyfin section seperated by commas.
## Installation ## Configuration
### Baremetal
- Setup virtualenv of your choice ## Installation
- Install dependencies ### Baremetal
```bash - Setup virtualenv of your choice
pip install -r requirements.txt
``` - Install dependencies
- Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens ```bash
pip install -r requirements.txt
- Run ```
```bash - Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens
python main.py
``` - Run
### Docker ```bash
python main.py
- Build docker image ```
```bash ### Docker
docker build -t jellyplex-watched .
``` - Build docker image
- or use pre-built image ```bash
docker build -t jellyplex-watched .
```bash ```
docker pull luigi311/jellyplex-watched:latest
``` - or use pre-built image
#### With variables ```bash
docker pull luigi311/jellyplex-watched:latest
- Run ```
```bash #### With variables
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
``` - Run
#### With .env ```bash
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
- Create a .env file similar to .env.sample and set the MNEMONIC variable to your seed phrase ```
- Run #### With .env
```bash - Create a .env file similar to .env.sample and set the variables to match your setup
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
``` - Run
## Contributing ```bash
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
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 ## Contributing
This is currently under the GNU General Public License v3.0. 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.

317
main.py
View File

@@ -1,307 +1,10 @@
import copy, os, traceback, json import sys
from dotenv import load_dotenv
from time import sleep if __name__ == '__main__':
# Check python version 3.6 or higher
from src.functions import logger, str_to_bool, search_mapping, generate_library_guids_dict if not (3, 6) <= tuple(map(int, sys.version_info[:2])):
from src.plex import Plex print("This script requires Python 3.6 or higher")
from src.jellyfin import Jellyfin sys.exit(1)
load_dotenv(override=True) from src.main import main
main()
def cleanup_watched(watched_list_1, watched_list_2, user_mapping=None, library_mapping=None):
modified_watched_list_1 = copy.deepcopy(watched_list_1)
# remove entries from plex_watched that are in jellyfin_watched
for user_1 in watched_list_1:
user_other = None
if user_mapping:
user_other = search_mapping(user_mapping, user_1)
if user_1 in modified_watched_list_1:
if user_1 in watched_list_2:
user_2 = user_1
elif user_other in watched_list_2:
user_2 = user_other
else:
logger(f"User {user_1} and {user_other} not found in watched list 2", 1)
continue
for library_1 in watched_list_1[user_1]:
library_other = None
if library_mapping:
library_other = search_mapping(library_mapping, library_1)
if library_1 in modified_watched_list_1[user_1]:
if library_1 in watched_list_2[user_2]:
library_2 = library_1
elif library_other in watched_list_2[user_2]:
library_2 = library_other
else:
logger(f"library {library_1} and {library_other} not found in watched list 2", 1)
continue
# Movies
if isinstance(watched_list_1[user_1][library_1], list):
for item in watched_list_1[user_1][library_1]:
for watch_list_1_key, watch_list_1_value in item.items():
for watch_list_2_item in watched_list_2[user_2][library_2]:
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_1][library_1]:
logger(f"Removing {item} from {library_1}", 3)
modified_watched_list_1[user_1][library_1].remove(item)
# TV Shows
elif isinstance(watched_list_1[user_1][library_1], dict):
# Generate full list of provider ids for episodes in watch_list_2 to easily compare if they exist in watch_list_1
_, episode_watched_list_2_keys_dict, _ = generate_library_guids_dict(watched_list_2[user_2][library_2], 1)
for show_key_1 in watched_list_1[user_1][library_1].keys():
show_key_dict = dict(show_key_1)
for season in watched_list_1[user_1][library_1][show_key_1]:
for episode in watched_list_1[user_1][library_1][show_key_1][season]:
for episode_key, episode_item in episode.items():
# If episode_key and episode_item are in episode_watched_list_2_keys_dict exactly, then remove from watch_list_1
if episode_key in episode_watched_list_2_keys_dict.keys():
if episode_item in episode_watched_list_2_keys_dict[episode_key]:
if episode in modified_watched_list_1[user_1][library_1][show_key_1][season]:
logger(f"Removing {show_key_dict['title']} {episode} from {library_1}", 3)
modified_watched_list_1[user_1][library_1][show_key_1][season].remove(episode)
# Remove empty seasons
if len(modified_watched_list_1[user_1][library_1][show_key_1][season]) == 0:
if season in modified_watched_list_1[user_1][library_1][show_key_1]:
logger(f"Removing {season} from {library_1} because it is empty", 3)
del modified_watched_list_1[user_1][library_1][show_key_1][season]
# If the show is empty, remove the show
if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0:
if show_key_1 in modified_watched_list_1[user_1][library_1]:
logger(f"Removing {show_key_dict['title']} from {library_1} because it is empty", 1)
del modified_watched_list_1[user_1][library_1][show_key_1]
# If library is empty then remove it
if len(modified_watched_list_1[user_1][library_1]) == 0:
if library_1 in modified_watched_list_1[user_1]:
logger(f"Removing {library_1} from {user_1} because it is empty", 1)
del modified_watched_list_1[user_1][library_1]
# If user is empty delete user
if len(modified_watched_list_1[user_1]) == 0:
logger(f"Removing {user_1} from watched list 1 because it is empty", 1)
del modified_watched_list_1[user_1]
return modified_watched_list_1
def setup_black_white_lists(library_mapping=None):
blacklist_library = os.getenv("BLACKLIST_LIBRARY")
if blacklist_library:
if len(blacklist_library) > 0:
blacklist_library = blacklist_library.split(",")
blacklist_library = [x.strip() for x in blacklist_library]
if library_mapping:
temp_library = []
for library in blacklist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
blacklist_library = blacklist_library + temp_library
else:
blacklist_library = []
logger(f"Blacklist Library: {blacklist_library}", 1)
whitelist_library = os.getenv("WHITELIST_LIBRARY")
if whitelist_library:
if len(whitelist_library) > 0:
whitelist_library = whitelist_library.split(",")
whitelist_library = [x.strip() for x in whitelist_library]
if library_mapping:
temp_library = []
for library in whitelist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
whitelist_library = whitelist_library + temp_library
else:
whitelist_library = []
logger(f"Whitelist Library: {whitelist_library}", 1)
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 = []
logger(f"Blacklist Library Type: {blacklist_library_type}", 1)
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 = []
logger(f"Whitelist Library Type: {whitelist_library_type}", 1)
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 = []
logger(f"Blacklist Users: {blacklist_users}", 1)
whitelist_users = os.getenv("WHITELIST_USERS")
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 = []
logger(f"Whitelist Users: {whitelist_users}", 1)
return blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users
def setup_users(plex, jellyfin, blacklist_users, whitelist_users, user_mapping=None):
# 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 = {}
for plex_user in plex_users:
if user_mapping:
jellyfin_plex_mapped_user = search_mapping(user_mapping, plex_user)
if jellyfin_plex_mapped_user:
users[plex_user] = jellyfin_plex_mapped_user
continue
if plex_user in jellyfin_users:
users[plex_user] = plex_user
for jellyfin_user in jellyfin_users:
if user_mapping:
plex_jellyfin_mapped_user = search_mapping(user_mapping, jellyfin_user)
if plex_jellyfin_mapped_user:
users[plex_jellyfin_mapped_user] = jellyfin_user
continue
if jellyfin_user in plex_users:
users[jellyfin_user] = jellyfin_user
logger(f"User list that exist on both servers {users}", 1)
users_filtered = {}
for user in users:
# whitelist_user is not empty and user lowercase is not in whitelist lowercase
if len(whitelist_users) > 0:
if user not in whitelist_users and users[user] not in whitelist_users:
logger(f"{user} or {users[user]} is not in whitelist", 1)
continue
if user not in blacklist_users and users[user] not in blacklist_users:
users_filtered[user] = users[user]
logger(f"Filtered user list {users_filtered}", 1)
plex_users = []
for plex_user in plex.users:
if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values():
plex_users.append(plex_user)
jellyfin_users = {}
for jellyfin_user, jellyfin_id in jellyfin.users.items():
if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values():
jellyfin_users[jellyfin_user] = jellyfin_id
if len(plex_users) == 0:
raise Exception(f"No plex users found, users found {users} filtered users {users_filtered}")
if len(jellyfin_users) == 0:
raise Exception(f"No jellyfin users found, users found {users} filtered users {users_filtered}")
logger(f"plex_users: {plex_users}", 1)
logger(f"jellyfin_users: {jellyfin_users}", 1)
return plex_users, jellyfin_users
def main():
logfile = os.getenv("LOGFILE","log.log")
# Delete logfile if it exists
if os.path.exists(logfile):
os.remove(logfile)
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
logger(f"Dryrun: {dryrun}", 1)
user_mapping = os.getenv("USER_MAPPING")
if user_mapping:
user_mapping = json.loads(user_mapping.lower())
logger(f"User Mapping: {user_mapping}", 1)
library_mapping = os.getenv("LIBRARY_MAPPING")
if library_mapping:
library_mapping = json.loads(library_mapping)
logger(f"Library Mapping: {library_mapping}", 1)
plex = Plex()
jellyfin = Jellyfin()
# Create (black/white)lists
blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users = setup_black_white_lists(library_mapping)
# Create users list
plex_users, jellyfin_users = setup_users(plex, jellyfin, blacklist_users, whitelist_users, user_mapping)
plex_watched = plex.get_plex_watched(plex_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping)
jellyfin_watched = jellyfin.get_jellyfin_watched(jellyfin_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping)
# 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)
logger("Cleaning Plex Watched", 1)
plex_watched = cleanup_watched(plex_watched_filtered, jellyfin_watched_filtered, user_mapping, library_mapping)
logger("Cleaning Jellyfin Watched", 1)
jellyfin_watched = cleanup_watched(jellyfin_watched_filtered, plex_watched_filtered, user_mapping, library_mapping)
logger(f"plex_watched that needs to be synced to jellyfin:\n{plex_watched}", 1)
logger(f"jellyfin_watched that needs to be synced to plex:\n{jellyfin_watched}", 1)
# Update watched status
plex.update_watched(jellyfin_watched, user_mapping, library_mapping, dryrun)
jellyfin.update_watched(plex_watched, user_mapping, library_mapping, dryrun)
if __name__ == "__main__":
sleep_timer = float(os.getenv("SLEEP_TIMER", "3600"))
while(True):
try:
main()
logger(f"Looping in {sleep_timer}")
except Exception as error:
if isinstance(error, list):
for message in error:
logger(message, log_type=2)
else:
logger(error, log_type=2)
logger(traceback.format_exc(), 2)
logger(f"Retrying in {sleep_timer}", log_type=0)
except KeyboardInterrupt:
logger("Exiting", log_type=0)
os._exit(0)
sleep(sleep_timer)

View File

@@ -1,3 +1,4 @@
plexapi plexapi
requests requests
python-dotenv python-dotenv
aiohttp

View File

@@ -1,116 +1,168 @@
import os import os
from dotenv import load_dotenv from concurrent.futures import ThreadPoolExecutor
load_dotenv(override=True) from dotenv import load_dotenv
logfile = os.getenv("LOGFILE","log.log") load_dotenv(override=True)
def logger(message, log_type=0): logfile = os.getenv("LOGFILE","log.log")
debug = str_to_bool(os.getenv("DEBUG", "True"))
debug_level = os.getenv("DEBUG_LEVEL", "INFO") def logger(message: str, log_type=0):
debug = str_to_bool(os.getenv("DEBUG", "True"))
output = str(message) debug_level = os.getenv("DEBUG_LEVEL", "info").lower()
if log_type == 0:
pass output = str(message)
elif log_type == 1 and (debug or debug_level == "INFO"): if log_type == 0:
output = f"[INFO]: {output}" pass
elif log_type == 2: elif log_type == 1 and (debug and debug_level == "info"):
output = f"[ERROR]: {output}" output = f"[INFO]: {output}"
elif log_type == 3 and (debug and debug_level == "DEBUG"): elif log_type == 2:
output = f"[DEBUG]: {output}" output = f"[ERROR]: {output}"
else: elif log_type == 3 and (debug and debug_level == "debug"):
output = None output = f"[DEBUG]: {output}"
elif log_type == 4:
if output is not None: output = f"[WARNING]: {output}"
print(output) else:
file = open(logfile, "a", encoding="utf-8") output = None
file.write(output + "\n")
if output is not None:
# Reimplementation of distutils.util.strtobool due to it being deprecated print(output)
# Source: https://github.com/PostHog/posthog/blob/01e184c29d2c10c43166f1d40a334abbc3f99d8a/posthog/utils.py#L668 file = open(logfile, "a", encoding="utf-8")
def str_to_bool(value: any) -> bool: file.write(output + "\n")
if not value:
return False # Reimplementation of distutils.util.strtobool due to it being deprecated
return str(value).lower() in ("y", "yes", "t", "true", "on", "1") # Source: https://github.com/PostHog/posthog/blob/01e184c29d2c10c43166f1d40a334abbc3f99d8a/posthog/utils.py#L668
def str_to_bool(value: any) -> bool:
# Get mapped value if not value:
def search_mapping(dictionary: dict, key_value: str): return False
if key_value in dictionary.keys(): return str(value).lower() in ("y", "yes", "t", "true", "on", "1")
return dictionary[key_value]
elif key_value.lower() in dictionary.keys(): # Get mapped value
return dictionary[key_value] def search_mapping(dictionary: dict, key_value: str):
elif key_value in dictionary.values(): if key_value in dictionary.keys():
return list(dictionary.keys())[list(dictionary.values()).index(key_value)] return dictionary[key_value]
elif key_value.lower() in dictionary.values(): elif key_value.lower() in dictionary.keys():
return list(dictionary.keys())[list(dictionary.values()).index(key_value)] return dictionary[key_value.lower()]
else: elif key_value in dictionary.values():
return None return list(dictionary.keys())[list(dictionary.values()).index(key_value)]
elif key_value.lower() in dictionary.values():
return list(dictionary.keys())[list(dictionary.values()).index(key_value.lower())]
def check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping): else:
skip_reason = None return None
if library_type.lower() in blacklist_library_type:
skip_reason = "is blacklist_library_type" def check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping):
skip_reason = None
if library_title.lower() in [x.lower() for x in blacklist_library]:
skip_reason = "is blacklist_library" if library_type.lower() in blacklist_library_type:
skip_reason = "is blacklist_library_type"
library_other = None
if library_mapping: if library_title.lower() in [x.lower() for x in blacklist_library]:
library_other = search_mapping(library_mapping, library_title) skip_reason = "is blacklist_library"
if library_other:
if library_other.lower() in [x.lower() for x in blacklist_library]: library_other = None
skip_reason = "is blacklist_library" if library_mapping:
library_other = search_mapping(library_mapping, library_title)
if len(whitelist_library_type) > 0: if library_other:
if library_type.lower() not in whitelist_library_type: if library_other.lower() in [x.lower() for x in blacklist_library]:
skip_reason = "is not whitelist_library_type" skip_reason = "is blacklist_library"
# if whitelist is not empty and library is not in whitelist if len(whitelist_library_type) > 0:
if len(whitelist_library) > 0: if library_type.lower() not in whitelist_library_type:
if library_title.lower() not in [x.lower() for x in whitelist_library]: skip_reason = "is not whitelist_library_type"
skip_reason = "is not whitelist_library"
# if whitelist is not empty and library is not in whitelist
if library_other: if len(whitelist_library) > 0:
if library_other.lower() not in [x.lower() for x in whitelist_library]: if library_title.lower() not in [x.lower() for x in whitelist_library]:
skip_reason = "is not whitelist_library" skip_reason = "is not whitelist_library"
return skip_reason if library_other:
if library_other.lower() not in [x.lower() for x in whitelist_library]:
skip_reason = "is not whitelist_library"
def generate_library_guids_dict(user_list: dict, generate_output: int):
# if generate_output is 0 then only generate shows, if 1 then only generate episodes, if 2 then generate movies, if 3 then generate shows and episodes return skip_reason
show_output_dict = {}
episode_output_dict = {}
movies_output_dict = {} def generate_library_guids_dict(user_list: dict):
show_output_dict = {}
if generate_output in (0, 3): episode_output_dict = {}
show_output_keys = user_list.keys() movies_output_dict = {}
show_output_keys = ([ dict(x) for x in list(show_output_keys) ])
for show_key in show_output_keys: try:
for provider_key, prvider_value in show_key.items(): show_output_keys = user_list.keys()
# Skip title show_output_keys = ([ dict(x) for x in list(show_output_keys) ])
if provider_key.lower() == "title": for show_key in show_output_keys:
continue for provider_key, provider_value in show_key.items():
if provider_key.lower() not in show_output_dict: # Skip title
show_output_dict[provider_key.lower()] = [] if provider_key.lower() == "title":
show_output_dict[provider_key.lower()].append(prvider_value.lower()) continue
if provider_key.lower() not in show_output_dict:
if generate_output in (1, 3): show_output_dict[provider_key.lower()] = []
for show in user_list: if provider_key.lower() == "locations":
for season in user_list[show]: for show_location in provider_value:
for episode in user_list[show][season]: show_output_dict[provider_key.lower()].append(show_location)
for episode_key, episode_value in episode.items(): else:
if episode_key.lower() not in episode_output_dict: show_output_dict[provider_key.lower()].append(provider_value.lower())
episode_output_dict[episode_key.lower()] = [] except:
episode_output_dict[episode_key.lower()].append(episode_value.lower()) logger(f"Generating show_output_dict failed, skipping", 1)
if generate_output == 2: try:
for movie in user_list: for show in user_list:
for movie_key, movie_value in movie.items(): for season in user_list[show]:
if movie_key.lower() not in movies_output_dict: for episode in user_list[show][season]:
movies_output_dict[movie_key.lower()] = [] for episode_key, episode_value in episode.items():
movies_output_dict[movie_key.lower()].append(movie_value.lower()) if episode_key.lower() not in episode_output_dict:
episode_output_dict[episode_key.lower()] = []
return show_output_dict, episode_output_dict, movies_output_dict if episode_key == "locations":
for episode_location in episode_value:
episode_output_dict[episode_key.lower()].append(episode_location)
else:
episode_output_dict[episode_key.lower()].append(episode_value.lower())
except:
logger(f"Generating episode_output_dict failed, skipping", 1)
try:
for movie in user_list:
for movie_key, movie_value in movie.items():
if movie_key.lower() not in movies_output_dict:
movies_output_dict[movie_key.lower()] = []
if movie_key == "locations":
for movie_location in movie_value:
movies_output_dict[movie_key.lower()].append(movie_location)
else:
movies_output_dict[movie_key.lower()].append(movie_value.lower())
except:
logger(f"Generating movies_output_dict failed, skipping", 1)
return show_output_dict, episode_output_dict, movies_output_dict
def combine_watched_dicts(dicts: list):
combined_dict = {}
for dict in dicts:
for key, value in dict.items():
if key not in combined_dict:
combined_dict[key] = {}
for subkey, subvalue in value.items():
combined_dict[key][subkey] = subvalue
return combined_dict
def future_thread_executor(args: list, workers: int = -1):
futures_list = []
results = []
if workers == -1:
workers = min(32, os.cpu_count()*1.25)
with ThreadPoolExecutor(max_workers=workers) as executor:
for arg in args:
# * arg unpacks the list into actual arguments
futures_list.append(executor.submit(*arg))
for future in futures_list:
try:
result = future.result()
results.append(result)
except Exception as e:
raise Exception(e)
return results

View File

@@ -1,237 +1,393 @@
import requests, os import asyncio, aiohttp
from dotenv import load_dotenv from src.functions import logger, search_mapping, str_to_bool, check_skip_logic, generate_library_guids_dict, future_thread_executor, combine_watched_dicts
from src.functions import logger, search_mapping, str_to_bool, check_skip_logic, generate_library_guids_dict
class Jellyfin():
load_dotenv(override=True) def __init__(self, baseurl, token):
self.baseurl = baseurl
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL") self.token = token
jellyfin_token = os.getenv("JELLYFIN_TOKEN")
if not self.baseurl:
class Jellyfin(): raise Exception("Jellyfin baseurl not set")
def __init__(self):
self.baseurl = jellyfin_baseurl if not self.token:
self.token = jellyfin_token raise Exception("Jellyfin token not set")
if not self.baseurl: self.users = asyncio.run(self.get_users())
raise Exception("Jellyfin baseurl not set")
if not self.token: async def query(self, query, query_type, session, identifiers=None):
raise Exception("Jellyfin token not set") try:
results = None
self.users = self.get_users() headers = {
"Accept": "application/json",
"X-Emby-Token": self.token
def query(self, query, query_type): }
try: authorization = (
response = None 'MediaBrowser , '
'Client="other", '
if query_type == "get": 'Device="script", '
response = requests.get(self.baseurl + query, headers={"accept":"application/json", "X-Emby-Token": self.token}) 'DeviceId="script", '
'Version="0.0.0"'
elif query_type == "post": )
authorization = ( headers["X-Emby-Authorization"] = authorization
'MediaBrowser , '
'Client="other", ' if query_type == "get":
'Device="script", ' async with session.get(self.baseurl + query, headers=headers) as response:
'DeviceId="script", ' results = await response.json()
'Version="0.0.0"'
) elif query_type == "post":
response = requests.post(self.baseurl + query, headers={"accept":"application/json", "X-Emby-Authorization": authorization, "X-Emby-Token": self.token}) async with session.post(self.baseurl + query, headers=headers) as response:
results = await response.json()
return response.json()
except Exception as e: # append identifiers to results
logger(e, 2) if identifiers:
logger(response, 2) results["Identifiers"] = identifiers
return results
def get_users(self):
users = {} except Exception as e:
logger(f"Jellyfin: Query failed {e}", 2)
query = "/Users" raise Exception(e)
response = self.query(query, "get")
# If reponse is not empty async def get_users(self):
if response: try:
for user in response: users = {}
users[user["Name"]] = user["Id"]
query_string = "/Users"
return users async with aiohttp.ClientSession() as session:
response = await self.query(query_string, "get", session)
def get_jellyfin_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping=None):
users_watched = {} # If reponse is not empty
if response:
for user_name, user_id in users.items(): for user in response:
# Get all libraries users[user["Name"]] = user["Id"]
user_name = user_name.lower()
return users
libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"] except Exception as e:
logger(f"Jellyfin: Get users failed {e}", 2)
for library in libraries: raise Exception(e)
library_title = library["Name"]
library_id = library["Id"]
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&limit=1", "get") async def get_user_watched(self, user_name, user_id, library_type, library_id, library_title):
try:
if len(watched["Items"]) == 0: user_name = user_name.lower()
logger(f"Jellyfin: No watched items found in library {library_title}", 1) user_watched = {}
continue user_watched[user_name] = {}
else:
library_type = watched["Items"][0]["Type"] logger(f"Jellyfin: Generating watched for {user_name} in library {library_title}", 0)
# Movies
skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping) async with aiohttp.ClientSession() as session:
if library_type == "Movie":
if skip_reason: user_watched[user_name][library_title] = []
logger(f"Jellyfin: Skipping library {library_title} {skip_reason}", 1) watched = await self.query(f"/Users/{user_id}/Items?ParentId={library_id}&Filters=IsPlayed&Fields=ItemCounts,ProviderIds,MediaSources", "get", session)
continue for movie in watched["Items"]:
if movie["UserData"]["Played"] == True:
logger(f"Jellyfin: Generating watched for {user_name} in library {library_title}", 0) movie_guids = {}
# Movies movie_guids["title"] = movie["Name"]
if library_type == "Movie": if "ProviderIds" in movie:
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&Fields=ItemCounts,ProviderIds", "get") # Lowercase movie["ProviderIds"] keys
for movie in watched["Items"]: movie_guids = {k.lower(): v for k, v in movie["ProviderIds"].items()}
if movie["UserData"]["Played"] == True: if "MediaSources" in movie:
if movie["ProviderIds"]: movie_guids["locations"] = tuple([x["Path"].split("/")[-1] for x in movie["MediaSources"]])
if user_name not in users_watched: user_watched[user_name][library_title].append(movie_guids)
users_watched[user_name] = {}
if library_title not in users_watched[user_name]: # TV Shows
users_watched[user_name][library_title] = [] if library_type == "Series":
# Lowercase movie["ProviderIds"] keys user_watched[user_name][library_title] = {}
movie["ProviderIds"] = {k.lower(): v for k, v in movie["ProviderIds"].items()} watched_shows = await self.query(f"/Users/{user_id}/Items?ParentId={library_id}&isPlaceHolder=false&Fields=ProviderIds,Path,RecursiveItemCount", "get", session)
users_watched[user_name][library_title].append(movie["ProviderIds"]) watched_shows_filtered = []
for show in watched_shows["Items"]:
# TV Shows if "PlayedPercentage" in show["UserData"]:
if library_type == "Episode": if show["UserData"]["PlayedPercentage"] > 0:
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Fields=ItemCounts,ProviderIds", "get") watched_shows_filtered.append(show)
watched_shows = [x for x in watched["Items"] if x["Type"] == "Series"] seasons_tasks = []
for show in watched_shows_filtered:
for show in watched_shows: show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()}
show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()} show_guids["title"] = show["Name"]
show_guids["title"] = show["Name"] show_guids["locations"] = tuple([show["Path"].split("/")[-1]])
show_guids = frozenset(show_guids.items()) show_guids = frozenset(show_guids.items())
seasons = self.query(f"/Shows/{show['Id']}/Seasons?userId={user_id}&Fields=ItemCounts,ProviderIds", "get") identifiers = {"show_guids": show_guids, "show_id": show["Id"]}
if len(seasons["Items"]) > 0: task = asyncio.ensure_future(self.query(f"/Shows/{show['Id']}/Seasons?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount", "get", session, frozenset(identifiers.items())))
for season in seasons["Items"]: seasons_tasks.append(task)
episodes = self.query(f"/Shows/{show['Id']}/Episodes?seasonId={season['Id']}&userId={user_id}&Fields=ItemCounts,ProviderIds", "get")
if len(episodes["Items"]) > 0: seasons_watched = await asyncio.gather(*seasons_tasks)
for episode in episodes["Items"]: seasons_watched_filtered = []
if episode["UserData"]["Played"] == True:
if episode["ProviderIds"]: for seasons in seasons_watched:
if user_name not in users_watched: seasons_watched_filtered_dict = {}
users_watched[user_name] = {} seasons_watched_filtered_dict["Identifiers"] = seasons["Identifiers"]
if library_title not in users_watched[user_name]: seasons_watched_filtered_dict["Items"] = []
users_watched[user_name][library_title] = {} for season in seasons["Items"]:
if show_guids not in users_watched[user_name][library_title]: if "PlayedPercentage" in season["UserData"]:
users_watched[user_name][library_title][show_guids] = {} if season["UserData"]["PlayedPercentage"] > 0:
if season["Name"] not in users_watched[user_name][library_title][show_guids]: seasons_watched_filtered_dict["Items"].append(season)
users_watched[user_name][library_title][show_guids][season["Name"]] = []
if seasons_watched_filtered_dict["Items"]:
# Lowercase episode["ProviderIds"] keys seasons_watched_filtered.append(seasons_watched_filtered_dict)
episode["ProviderIds"] = {k.lower(): v for k, v in episode["ProviderIds"].items()}
users_watched[user_name][library_title][show_guids][season["Name"]].append(episode["ProviderIds"]) episodes_tasks = []
for seasons in seasons_watched_filtered:
return users_watched if len(seasons["Items"]) > 0:
for season in seasons["Items"]:
def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): season_identifiers = dict(seasons["Identifiers"])
for user, libraries in watched_list.items(): season_identifiers["season_id"] = season["Id"]
if user_mapping: season_identifiers["season_name"] = season["Name"]
user_other = None task = asyncio.ensure_future(self.query(f"/Shows/{season_identifiers['show_id']}/Episodes?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&isPlayed=true&Fields=ProviderIds,MediaSources", "get", session, frozenset(season_identifiers.items())))
episodes_tasks.append(task)
if user in user_mapping.keys():
user_other = user_mapping[user] watched_episodes = await asyncio.gather(*episodes_tasks)
elif user in user_mapping.values(): for episodes in watched_episodes:
user_other = search_mapping(user_mapping, user) if len(episodes["Items"]) > 0:
for episode in episodes["Items"]:
if user_other: if episode["UserData"]["Played"] == True:
logger(f"Swapping user {user} with {user_other}", 1) if "ProviderIds" in episode or "MediaSources" in episode:
user = user_other episode_identifiers = dict(episodes["Identifiers"])
show_guids = episode_identifiers["show_guids"]
user_id = None if show_guids not in user_watched[user_name][library_title]:
for key in self.users.keys(): user_watched[user_name][library_title][show_guids] = {}
if user.lower() == key.lower(): if episode_identifiers["season_name"] not in user_watched[user_name][library_title][show_guids]:
user_id = self.users[key] user_watched[user_name][library_title][show_guids][episode_identifiers["season_name"]] = []
break
episode_guids = {}
if not user_id: if "ProviderIds" in episode:
logger(f"{user} not found in Jellyfin", 2) episode_guids = {k.lower(): v for k, v in episode["ProviderIds"].items()}
break if "MediaSources" in episode:
episode_guids["locations"] = tuple([x["Path"].split("/")[-1] for x in episode["MediaSources"]])
jellyfin_libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"] user_watched[user_name][library_title][show_guids][episode_identifiers["season_name"]].append(episode_guids)
for library, videos in libraries.items(): return user_watched
if library_mapping: except Exception as e:
library_other = None logger(f"Jellyfin: Failed to get watched for {user_name} in library {library_title}, Error: {e}", 2)
raise Exception(e)
if library in library_mapping.keys():
library_other = library_mapping[library]
elif library in library_mapping.values(): async def get_users_watched(self, user_name, user_id, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping):
library_other = search_mapping(library_mapping, library) try:
# Get all libraries
if library_other: user_name = user_name.lower()
logger(f"Swapping library {library} with {library_other}", 1) tasks_watched = []
library = library_other
tasks_libraries = []
if library not in [x["Name"] for x in jellyfin_libraries]: async with aiohttp.ClientSession() as session:
logger(f"{library} not found in Jellyfin", 2) libraries = await self.query(f"/Users/{user_id}/Views", "get", session)
continue for library in libraries["Items"]:
library_id = library["Id"]
library_id = None library_title = library["Name"]
for jellyfin_library in jellyfin_libraries: identifiers = {"library_id": library_id, "library_title": library_title}
if jellyfin_library["Name"] == library: task = asyncio.ensure_future(self.query(f"/Users/{user_id}/Items?ParentId={library_id}&Filters=IsPlayed&limit=1", "get", session, identifiers=identifiers))
library_id = jellyfin_library["Id"] tasks_libraries.append(task)
continue
libraries = await asyncio.gather(*tasks_libraries, return_exceptions=True)
if library_id:
logger(f"Jellyfin: Updating watched for {user} in library {library}", 1) for watched in libraries:
library_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&limit=1", "get") if len(watched["Items"]) == 0:
library_type = library_search["Items"][0]["Type"] continue
# Movies library_id = watched["Identifiers"]["library_id"]
if library_type == "Movie": library_title = watched["Identifiers"]["library_title"]
_, _, videos_movies_ids = generate_library_guids_dict(videos, 2) library_type = watched["Items"][0]["Type"]
jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds", "get") skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping)
for jellyfin_video in jellyfin_search["Items"]:
if str_to_bool(jellyfin_video["UserData"]["Played"]) == False: if skip_reason:
jellyfin_video_id = jellyfin_video["Id"] logger(f"Jellyfin: Skipping library {library_title} {skip_reason}", 1)
continue
for movie_provider_source, movie_provider_id in jellyfin_video["ProviderIds"].items():
if movie_provider_source.lower() in videos_movies_ids: # Get watched for user
if movie_provider_id.lower() in videos_movies_ids[movie_provider_source.lower()]: task = asyncio.ensure_future(self.get_user_watched(user_name, user_id, library_type, library_id, library_title))
msg = f"{jellyfin_video['Name']} as watched for {user} in {library} for Jellyfin" tasks_watched.append(task)
if not dryrun:
logger(f"Marking {msg}", 0) watched = await asyncio.gather(*tasks_watched, return_exceptions=True)
self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", "post") return watched
else: except Exception as e:
logger(f"Dryrun {msg}", 0) logger(f"Jellyfin: Failed to get users watched, Error: {e}", 2)
break raise Exception(e)
# TV Shows
if library_type == "Episode": async def get_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping=None):
videos_shows_ids, videos_episode_ids, _ = generate_library_guids_dict(videos, 3) try:
users_watched = {}
jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds", "get") watched = []
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for user_name, user_id in users.items():
for jellyfin_show in jellyfin_shows: watched.append(await self.get_users_watched(user_name, user_id, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping))
show_found = False
for show_provider_source, show_provider_id in jellyfin_show["ProviderIds"].items(): for user_watched in watched:
if show_provider_source.lower() in videos_shows_ids: user_watched_temp = combine_watched_dicts(user_watched)
if show_provider_id.lower() in videos_shows_ids[show_provider_source.lower()]: for user, user_watched_temp in user_watched_temp.items():
show_found = True if user not in users_watched:
jellyfin_show_id = jellyfin_show["Id"] users_watched[user] = {}
jellyfin_episodes = self.query(f"/Shows/{jellyfin_show_id}/Episodes?userId={user_id}&Fields=ItemCounts,ProviderIds", "get") users_watched[user].update(user_watched_temp)
for jellyfin_episode in jellyfin_episodes["Items"]:
if str_to_bool(jellyfin_episode["UserData"]["Played"]) == False: return users_watched
jellyfin_episode_id = jellyfin_episode["Id"] except Exception as e:
logger(f"Jellyfin: Failed to get watched, Error: {e}", 2)
for episode_provider_source, episode_provider_id in jellyfin_episode["ProviderIds"].items(): raise Exception(e)
if episode_provider_source.lower() in videos_episode_ids:
if episode_provider_id.lower() in videos_episode_ids[episode_provider_source.lower()]:
msg = f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']} as watched for {user} in {library} for Jellyfin" async def update_user_watched(self, user_name, user_id, library, library_id, videos, dryrun):
if not dryrun: try:
logger(f"Marked {msg}", 0) logger(f"Jellyfin: Updating watched for {user_name} in library {library}", 1)
self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", "post") videos_shows_ids, videos_episodes_ids, videos_movies_ids = generate_library_guids_dict(videos)
else:
logger(f"Dryrun {msg}", 0) logger(f"Jellyfin: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}", 1)
break async with aiohttp.ClientSession() as session:
if videos_movies_ids:
if show_found: jellyfin_search = await self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources", "get", session)
break for jellyfin_video in jellyfin_search["Items"]:
movie_found = False
if "MediaSources" in jellyfin_video:
for movie_location in jellyfin_video["MediaSources"]:
if movie_location["Path"].split("/")[-1] in videos_movies_ids["locations"]:
movie_found = True
break
if not movie_found:
for movie_provider_source, movie_provider_id in jellyfin_video["ProviderIds"].items():
if movie_provider_source.lower() in videos_movies_ids:
if movie_provider_id.lower() in videos_movies_ids[movie_provider_source.lower()]:
movie_found = True
break
if movie_found:
jellyfin_video_id = jellyfin_video["Id"]
msg = f"{jellyfin_video['Name']} as watched for {user_name} in {library} for Jellyfin"
if not dryrun:
logger(f"Marking {msg}", 0)
await self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", "post", session)
else:
logger(f"Dryrun {msg}", 0)
else:
logger(f"Jellyfin: Skipping movie {jellyfin_video['Name']} as it is not in mark list for {user_name}", 1)
# TV Shows
if videos_shows_ids and videos_episodes_ids:
jellyfin_search = await self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds,Path", "get", session)
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for jellyfin_show in jellyfin_shows:
show_found = False
if "Path" in jellyfin_show:
if jellyfin_show["Path"].split("/")[-1] in videos_shows_ids["locations"]:
show_found = True
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
break
if show_found:
logger(f"Jellyfin: Updating watched for {user_name} in library {library} for show {jellyfin_show['Name']}", 1)
jellyfin_show_id = jellyfin_show["Id"]
jellyfin_episodes = await self.query(f"/Shows/{jellyfin_show_id}/Episodes?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", "get", session)
for jellyfin_episode in jellyfin_episodes["Items"]:
episode_found = False
if "MediaSources" in jellyfin_episode:
for episode_location in jellyfin_episode["MediaSources"]:
if episode_location["Path"].split("/")[-1] in videos_episodes_ids["locations"]:
episode_found = True
break
if not episode_found:
for episode_provider_source, episode_provider_id in jellyfin_episode["ProviderIds"].items():
if episode_provider_source.lower() in videos_episodes_ids:
if episode_provider_id.lower() in videos_episodes_ids[episode_provider_source.lower()]:
episode_found = True
break
if episode_found:
jellyfin_episode_id = jellyfin_episode["Id"]
msg = f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['Name']} as watched for {user_name} in {library} for Jellyfin"
if not dryrun:
logger(f"Marked {msg}", 0)
await self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", "post", session)
else:
logger(f"Dryrun {msg}", 0)
else:
logger(f"Jellyfin: Skipping episode {jellyfin_episode['Name']} as it is not in mark list for {user_name}", 1)
else:
logger(f"Jellyfin: Skipping show {jellyfin_show['Name']} as it is not in mark list for {user_name}", 1)
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)
except Exception as e:
logger(f"Jellyfin: Error updating watched for {user_name} in library {library}", 2)
raise Exception(e)
async def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False):
try:
tasks = []
async with aiohttp.ClientSession() as session:
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.keys():
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 = await self.query(f"/Users/{user_id}/Views", "get", session)
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", 2)
continue
else:
logger(f"Jellyfin: Library {library} not found in library list", 2)
continue
library_id = None
for jellyfin_library in jellyfin_libraries:
if jellyfin_library["Name"] == library:
library_id = jellyfin_library["Id"]
continue
if library_id:
task = self.update_user_watched(user_name, user_id, library, library_id, videos, dryrun)
tasks.append(task)
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger(f"Jellyfin: Error updating watched", 2)
raise Exception(e)

450
src/main.py Normal file
View File

@@ -0,0 +1,450 @@
import copy, os, traceback, json, asyncio
from dotenv import load_dotenv
from time import sleep, perf_counter
from src.functions import logger, str_to_bool, search_mapping, generate_library_guids_dict, future_thread_executor
from src.plex import Plex
from src.jellyfin import Jellyfin
load_dotenv(override=True)
def cleanup_watched(watched_list_1, watched_list_2, user_mapping=None, library_mapping=None):
modified_watched_list_1 = copy.deepcopy(watched_list_1)
# remove entries from plex_watched that are in jellyfin_watched
for user_1 in watched_list_1:
user_other = None
if user_mapping:
user_other = search_mapping(user_mapping, user_1)
if user_1 in modified_watched_list_1:
if user_1 in watched_list_2:
user_2 = user_1
elif user_other in watched_list_2:
user_2 = user_other
else:
logger(f"User {user_1} and {user_other} not found in watched list 2", 1)
continue
for library_1 in watched_list_1[user_1]:
library_other = None
if library_mapping:
library_other = search_mapping(library_mapping, library_1)
if library_1 in modified_watched_list_1[user_1]:
if library_1 in watched_list_2[user_2]:
library_2 = library_1
elif library_other in watched_list_2[user_2]:
library_2 = library_other
else:
logger(f"library {library_1} and {library_other} not found in watched list 2", 1)
continue
_, episode_watched_list_2_keys_dict, movies_watched_list_2_keys_dict = generate_library_guids_dict(watched_list_2[user_2][library_2])
# Movies
if isinstance(watched_list_1[user_1][library_1], list):
for movie in watched_list_1[user_1][library_1]:
movie_found = False
for movie_key, movie_value in movie.items():
if movie_key == "locations":
if "locations" in movies_watched_list_2_keys_dict.keys():
for location in movie_value:
if location in movies_watched_list_2_keys_dict["locations"]:
movie_found = True
break
else:
if movie_key in movies_watched_list_2_keys_dict.keys():
if movie_value in movies_watched_list_2_keys_dict[movie_key]:
movie_found = True
if movie_found:
logger(f"Removing {movie} from {library_1}", 3)
modified_watched_list_1[user_1][library_1].remove(movie)
break
# TV Shows
elif isinstance(watched_list_1[user_1][library_1], dict):
# Generate full list of provider ids for episodes in watch_list_2 to easily compare if they exist in watch_list_1
for show_key_1 in watched_list_1[user_1][library_1].keys():
show_key_dict = dict(show_key_1)
for season in watched_list_1[user_1][library_1][show_key_1]:
for episode in watched_list_1[user_1][library_1][show_key_1][season]:
episode_found = False
for episode_key, episode_value in episode.items():
# If episode_key and episode_value are in episode_watched_list_2_keys_dict exactly, then remove from watch_list_1
if episode_key == "locations":
if "locations" in episode_watched_list_2_keys_dict.keys():
for location in episode_value:
if location in episode_watched_list_2_keys_dict["locations"]:
episode_found = True
break
else:
if episode_key in episode_watched_list_2_keys_dict.keys():
if episode_value in episode_watched_list_2_keys_dict[episode_key]:
episode_found = True
if episode_found:
if episode in modified_watched_list_1[user_1][library_1][show_key_1][season]:
logger(f"Removing {episode} from {show_key_dict['title']}", 3)
modified_watched_list_1[user_1][library_1][show_key_1][season].remove(episode)
break
# Remove empty seasons
if len(modified_watched_list_1[user_1][library_1][show_key_1][season]) == 0:
if season in modified_watched_list_1[user_1][library_1][show_key_1]:
logger(f"Removing {season} from {show_key_dict['title']} because it is empty", 3)
del modified_watched_list_1[user_1][library_1][show_key_1][season]
# If the show is empty, remove the show
if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0:
if show_key_1 in modified_watched_list_1[user_1][library_1]:
logger(f"Removing {show_key_dict['title']} from {library_1} because it is empty", 1)
del modified_watched_list_1[user_1][library_1][show_key_1]
for user_1 in watched_list_1:
for library_1 in watched_list_1[user_1]:
if library_1 in modified_watched_list_1[user_1]:
# If library is empty then remove it
if len(modified_watched_list_1[user_1][library_1]) == 0:
logger(f"Removing {library_1} from {user_1} because it is empty", 1)
del modified_watched_list_1[user_1][library_1]
if user_1 in modified_watched_list_1:
# If user is empty delete user
if len(modified_watched_list_1[user_1]) == 0:
logger(f"Removing {user_1} from watched list 1 because it is empty", 1)
del modified_watched_list_1[user_1]
return modified_watched_list_1
def setup_black_white_lists(blacklist_library: str, whitelist_library: str, blacklist_library_type: str, whitelist_library_type: str, blacklist_users: str, whitelist_users: str, library_mapping=None, user_mapping=None):
if blacklist_library:
if len(blacklist_library) > 0:
blacklist_library = blacklist_library.split(",")
blacklist_library = [x.strip() for x in blacklist_library]
if library_mapping:
temp_library = []
for library in blacklist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
blacklist_library = blacklist_library + temp_library
else:
blacklist_library = []
logger(f"Blacklist Library: {blacklist_library}", 1)
if whitelist_library:
if len(whitelist_library) > 0:
whitelist_library = whitelist_library.split(",")
whitelist_library = [x.strip() for x in whitelist_library]
if library_mapping:
temp_library = []
for library in whitelist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
whitelist_library = whitelist_library + temp_library
else:
whitelist_library = []
logger(f"Whitelist Library: {whitelist_library}", 1)
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 = []
logger(f"Blacklist Library Type: {blacklist_library_type}", 1)
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 = []
logger(f"Whitelist Library Type: {whitelist_library_type}", 1)
if blacklist_users:
if len(blacklist_users) > 0:
blacklist_users = blacklist_users.split(",")
blacklist_users = [x.lower().strip() for x in blacklist_users]
if user_mapping:
temp_users = []
for user in blacklist_users:
user_other = search_mapping(user_mapping, user)
if user_other:
temp_users.append(user_other)
blacklist_users = blacklist_users + temp_users
else:
blacklist_users = []
logger(f"Blacklist Users: {blacklist_users}", 1)
if whitelist_users:
if len(whitelist_users) > 0:
whitelist_users = whitelist_users.split(",")
whitelist_users = [x.lower().strip() for x in whitelist_users]
if user_mapping:
temp_users = []
for user in whitelist_users:
user_other = search_mapping(user_mapping, user)
if user_other:
temp_users.append(user_other)
whitelist_users = whitelist_users + temp_users
else:
whitelist_users = []
else:
whitelist_users = []
logger(f"Whitelist Users: {whitelist_users}", 1)
return blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users
def setup_users(server_1, server_2, blacklist_users, whitelist_users, user_mapping=None):
# generate list of users from server 1 and server 2
server_1_type = server_1[0]
server_1_connection = server_1[1]
server_2_type = server_2[0]
server_2_connection = server_2[1]
print(f"Server 1: {server_1_type} {server_1_connection}")
print(f"Server 2: {server_2_type} {server_2_connection}")
server_1_users = []
if server_1_type == "plex":
server_1_users = [ x.title.lower() for x in server_1_connection.users ]
elif server_1_type == "jellyfin":
server_1_users = [ key.lower() for key in server_1_connection.users.keys() ]
server_2_users = []
if server_2_type == "plex":
server_2_users = [ x.title.lower() for x in server_2_connection.users ]
elif server_2_type == "jellyfin":
server_2_users = [ key.lower() for key in server_2_connection.users.keys() ]
# combined list of overlapping users from plex and jellyfin
users = {}
for server_1_user in server_1_users:
if user_mapping:
jellyfin_plex_mapped_user = search_mapping(user_mapping, server_1_user)
if jellyfin_plex_mapped_user:
users[server_1_user] = jellyfin_plex_mapped_user
continue
if server_1_user in server_2_users:
users[server_1_user] = server_1_user
for server_2_user in server_2_users:
if user_mapping:
plex_jellyfin_mapped_user = search_mapping(user_mapping, server_2_user)
if plex_jellyfin_mapped_user:
users[plex_jellyfin_mapped_user] = server_2_user
continue
if server_2_user in server_1_users:
users[server_2_user] = server_2_user
logger(f"User list that exist on both servers {users}", 1)
users_filtered = {}
for user in users:
# whitelist_user is not empty and user lowercase is not in whitelist lowercase
if len(whitelist_users) > 0:
if user not in whitelist_users and users[user] not in whitelist_users:
logger(f"{user} or {users[user]} is not in whitelist", 1)
continue
if user not in blacklist_users and users[user] not in blacklist_users:
users_filtered[user] = users[user]
logger(f"Filtered user list {users_filtered}", 1)
if server_1_type == "plex":
output_server_1_users = []
for plex_user in server_1_connection.users:
if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values():
output_server_1_users.append(plex_user)
elif server_1_type == "jellyfin":
output_server_1_users = {}
for jellyfin_user, jellyfin_id in server_1_connection.users.items():
if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values():
output_server_1_users[jellyfin_user] = jellyfin_id
if server_2_type == "plex":
output_server_2_users = []
for plex_user in server_2_connection.users:
if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values():
output_server_2_users.append(plex_user)
elif server_2_type == "jellyfin":
output_server_2_users = {}
for jellyfin_user, jellyfin_id in server_2_connection.users.items():
if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values():
output_server_2_users[jellyfin_user] = jellyfin_id
if len(output_server_1_users) == 0:
raise Exception(f"No users found for server 1, users found {users} filtered users {users_filtered}")
if len(output_server_2_users) == 0:
raise Exception(f"No users found for server 2, users found {users} filtered users {users_filtered}")
logger(f"Server 1 users: {output_server_1_users}", 1)
logger(f"Server 2 users: {output_server_2_users}", 1)
return output_server_1_users, output_server_2_users
def generate_server_connections():
servers = []
plex_baseurl = os.getenv("PLEX_BASEURL", None)
plex_token = os.getenv("PLEX_TOKEN", None)
plex_username = os.getenv("PLEX_USERNAME", None)
plex_password = os.getenv("PLEX_PASSWORD", None)
plex_servername = os.getenv("PLEX_SERVERNAME", None)
ssl_bypass = str_to_bool(os.getenv("SSL_BYPASS", "False"))
if plex_baseurl and plex_token:
plex_baseurl = plex_baseurl.split(",")
plex_token = plex_token.split(",")
if len(plex_baseurl) != len(plex_token):
raise Exception("PLEX_BASEURL and PLEX_TOKEN must have the same number of entries")
for i, url in enumerate(plex_baseurl):
servers.append(("plex", Plex(baseurl=url.strip(), token=plex_token[i].strip(), username=None, password=None, servername=None, ssl_bypass=ssl_bypass)))
if plex_username and plex_password and plex_servername:
plex_username = plex_username.split(",")
plex_password = plex_password.split(",")
plex_servername = plex_servername.split(",")
if len(plex_username) != len(plex_password) or len(plex_username) != len(plex_servername):
raise Exception("PLEX_USERNAME, PLEX_PASSWORD and PLEX_SERVERNAME must have the same number of entries")
for i, username in enumerate(plex_username):
servers.append(("plex", Plex(baseurl=None, token=None, username=username.strip(), password=plex_password[i].strip(), servername=plex_servername[i].strip(), ssl_bypass=ssl_bypass)))
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None)
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
if jellyfin_baseurl and jellyfin_token:
jellyfin_baseurl = jellyfin_baseurl.split(",")
jellyfin_token = jellyfin_token.split(",")
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):
servers.append(("jellyfin", Jellyfin(baseurl=baseurl.strip(), token=jellyfin_token[i].strip())))
return servers
def main_loop():
logfile = os.getenv("LOGFILE","log.log")
# Delete logfile if it exists
if os.path.exists(logfile):
os.remove(logfile)
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
logger(f"Dryrun: {dryrun}", 1)
user_mapping = os.getenv("USER_MAPPING")
if user_mapping:
user_mapping = json.loads(user_mapping.lower())
logger(f"User Mapping: {user_mapping}", 1)
library_mapping = os.getenv("LIBRARY_MAPPING")
if library_mapping:
library_mapping = json.loads(library_mapping)
logger(f"Library Mapping: {library_mapping}", 1)
# Create (black/white)lists
logger("Creating (black/white)lists", 1)
blacklist_library = os.getenv("BLACKLIST_LIBRARY", None)
whitelist_library = os.getenv("WHITELIST_LIBRARY", None)
blacklist_library_type = os.getenv("BLACKLIST_LIBRARY_TYPE", None)
whitelist_library_type = os.getenv("WHITELIST_LIBRARY_TYPE", None)
blacklist_users = os.getenv("BLACKLIST_USERS", None)
whitelist_users = os.getenv("WHITELIST_USERS", None)
blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users, library_mapping, user_mapping)
# Create server connections
logger("Creating server connections", 1)
servers = generate_server_connections()
for server_1 in servers:
# If server is the final server in the list, then we are done with the loop
if server_1 == servers[-1]:
break
# Start server_2 at the next server in the list
for server_2 in servers[servers.index(server_1) + 1:]:
server_1_connection = server_1[1]
server_2_connection = server_2[1]
# Create users list
logger("Creating users list", 1)
server_1_users, server_2_users = setup_users(server_1, server_2, blacklist_users, whitelist_users, user_mapping)
logger("Creating watched lists", 1)
server_1_watched = server_1_connection.get_watched(server_1_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping)
logger("Finished creating watched list server 1", 1)
server_2_watched = asyncio.run(server_2_connection.get_watched(server_2_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping))
logger("Finished creating watched list server 2", 1)
logger(f"Server 1 watched: {server_1_watched}", 3)
logger(f"Server 2 watched: {server_2_watched}", 3)
# clone watched so it isnt modified in the cleanup function so all duplicates are actually removed
server_1_watched_filtered = copy.deepcopy(server_1_watched)
server_2_watched_filtered = copy.deepcopy(server_2_watched)
logger("Cleaning Server 1 Watched", 1)
server_1_watched_filtered = cleanup_watched(server_1_watched, server_2_watched, user_mapping, library_mapping)
logger("Cleaning Server 2 Watched", 1)
server_2_watched_filtered = cleanup_watched(server_2_watched, server_1_watched, user_mapping, library_mapping)
logger(f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}", 1)
logger(f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}", 1)
server_1_connection.update_watched(server_2_watched_filtered, user_mapping, library_mapping, dryrun)
asyncio.run(server_2_connection.update_watched(server_1_watched_filtered, user_mapping, library_mapping, dryrun))
def main():
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
times = []
while(True):
try:
start = perf_counter()
main_loop()
end = perf_counter()
times.append(end - start)
logger(f"Looping in {sleep_duration}")
sleep(sleep_duration)
except Exception as error:
if isinstance(error, list):
for message in error:
logger(message, log_type=2)
else:
logger(error, log_type=2)
logger(traceback.format_exc(), 2)
logger(f"Retrying in {sleep_duration}", log_type=0)
sleep(sleep_duration)
except KeyboardInterrupt:
if len(times) > 0:
logger(f"Average time: {sum(times) / len(times)}", 0)
logger("Exiting", log_type=0)
os._exit(0)

View File

@@ -1,238 +1,318 @@
import re, os import re, requests
from dotenv import load_dotenv
from plexapi.server import PlexServer
from src.functions import logger, search_mapping, check_skip_logic, generate_library_guids_dict from plexapi.myplex import MyPlexAccount
from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount from src.functions import logger, search_mapping, check_skip_logic, generate_library_guids_dict, future_thread_executor
load_dotenv(override=True)
# class plex accept base url and token and username and password but default with none
plex_baseurl = os.getenv("PLEX_BASEURL") class Plex:
plex_token = os.getenv("PLEX_TOKEN") def __init__(self, baseurl=None, token=None, username=None, password=None, servername=None, ssl_bypass=False):
username = os.getenv("PLEX_USERNAME") self.baseurl = baseurl
password = os.getenv("PLEX_PASSWORD") self.token = token
servername = os.getenv("PLEX_SERVERNAME") self.username = username
self.password = password
# class plex accept base url and token and username and password but default with none self.servername = servername
class Plex: self.plex = self.login(ssl_bypass)
def __init__(self): self.admin_user = self.plex.myPlexAccount()
self.baseurl = plex_baseurl self.users = self.get_users()
self.token = plex_token
self.username = username def login(self, ssl_bypass=False):
self.password = password try:
self.servername = servername if self.baseurl and self.token:
self.plex = self.plex_login() # Login via token
self.admin_user = self.plex.myPlexAccount() if ssl_bypass:
self.users = self.get_plex_users() session = requests.Session()
session.verify = False
def plex_login(self): plex = PlexServer(self.baseurl, self.token, session=session)
try: else:
if self.baseurl and self.token: plex = PlexServer(self.baseurl, self.token)
# Login via token elif self.username and self.password and self.servername:
plex = PlexServer(self.baseurl, self.token) # Login via plex account
elif self.username and self.password and self.servername: account = MyPlexAccount(self.username, self.password)
# Login via plex account plex = account.resource(self.servername).connect()
account = MyPlexAccount(self.username, self.password) else:
plex = account.resource(self.servername).connect() raise Exception("No complete plex credentials provided")
else:
raise Exception("No complete plex credentials provided") return plex
except Exception as e:
return plex if self.username or self.password:
except Exception as e: msg = f"Failed to login via plex account {self.username}"
if self.username or self.password: logger(f"Plex: Failed to login, {msg}, Error: {e}", 2)
msg = f"Failed to login via plex account {self.username}" else:
logger(f"Plex: Failed to login, {msg}, Error: {e}", 2) logger(f"Plex: Failed to login, Error: {e}", 2)
else: raise Exception(e)
logger(f"Plex: Failed to login, Error: {e}", 2)
return None
def get_users(self):
try:
def get_plex_users(self): users = self.plex.myPlexAccount().users()
users = self.plex.myPlexAccount().users()
# append self to users
# append self to users users.append(self.plex.myPlexAccount())
users.append(self.plex.myPlexAccount())
return users
return users except Exception as e:
logger(f"Plex: Failed to get users, Error: {e}", 2)
def get_plex_user_watched(self, user, library): raise Exception(e)
if self.admin_user == user:
user_plex = self.plex
else: def get_user_watched(self, user, user_plex, library):
user_plex = PlexServer(self.baseurl, user.get_token(self.plex.machineIdentifier)) try:
user_name = user.title.lower()
watched = None user_watched = {}
user_watched[user_name] = {}
if library.type == "movie":
watched = [] logger(f"Plex: Generating watched for {user_name} in library {library.title}", 0)
library_videos = user_plex.library.section(library.title)
for video in library_videos.search(unmatched=False, unwatched=False): if library.type == "movie":
guids = {} user_watched[user_name][library.title] = []
for guid in video.guids:
guid_source = re.search(r'(.*)://', guid.id).group(1).lower() library_videos = user_plex.library.section(library.title)
guid_id = re.search(r'://(.*)', guid.id).group(1) for video in library_videos.search(unwatched=False):
guids[guid_source] = guid_id movie_guids = {}
watched.append(guids) for guid in video.guids:
guid_source = re.search(r'(.*)://', guid.id).group(1).lower()
elif library.type == "show": guid_id = re.search(r'://(.*)', guid.id).group(1)
watched = {} movie_guids[guid_source] = guid_id
library_videos = user_plex.library.section(library.title)
for show in library_videos.search(unmatched=False, unwatched=False): movie_guids["title"] = video.title
show_guids = {} movie_guids["locations"] = tuple([x.split("/")[-1] for x in video.locations])
for show_guid in show.guids:
show_guids["title"] = show.title user_watched[user_name][library.title].append(movie_guids)
# Extract after :// from guid.id
show_guid_source = re.search(r'(.*)://', show_guid.id).group(1).lower() elif library.type == "show":
show_guid_id = re.search(r'://(.*)', show_guid.id).group(1) user_watched[user_name][library.title] = {}
show_guids[show_guid_source] = show_guid_id
show_guids = frozenset(show_guids.items()) library_videos = user_plex.library.section(library.title)
for show in library_videos.search(unwatched=False):
for season in show.seasons(): show_guids = {}
episode_guids = [] for show_guid in show.guids:
for episode in season.episodes(): # Extract after :// from guid.id
if episode.viewCount > 0: show_guid_source = re.search(r'(.*)://', show_guid.id).group(1).lower()
episode_guids_temp = {} show_guid_id = re.search(r'://(.*)', show_guid.id).group(1)
for guid in episode.guids: show_guids[show_guid_source] = show_guid_id
# Extract after :// from guid.id
guid_source = re.search(r'(.*)://', guid.id).group(1).lower() show_guids["title"] = show.title
guid_id = re.search(r'://(.*)', guid.id).group(1) show_guids["locations"] = tuple([x.split("/")[-1] for x in show.locations])
episode_guids_temp[guid_source] = guid_id show_guids = frozenset(show_guids.items())
episode_guids.append(episode_guids_temp) for season in show.seasons():
episode_guids = []
if episode_guids: for episode in season.episodes():
# append show, season, episode if episode.viewCount > 0:
if show_guids not in watched: episode_guids_temp = {}
watched[show_guids] = {} for guid in episode.guids:
if season.title not in watched[show_guids]: # Extract after :// from guid.id
watched[show_guids][season.title] = {} guid_source = re.search(r'(.*)://', guid.id).group(1).lower()
watched[show_guids][season.title] = episode_guids guid_id = re.search(r'://(.*)', guid.id).group(1)
episode_guids_temp[guid_source] = guid_id
return watched
episode_guids_temp["locations"] = tuple([x.split("/")[-1] for x in episode.locations])
def get_plex_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping): episode_guids.append(episode_guids_temp)
# Get all libraries
libraries = self.plex.library.sections() if episode_guids:
users_watched = {} # append show, season, episode
if show_guids not in user_watched[user_name][library.title]:
# for not in blacklist user_watched[user_name][library.title][show_guids] = {}
for library in libraries: if season.title not in user_watched[user_name][library.title][show_guids]:
library_title = library.title user_watched[user_name][library.title][show_guids][season.title] = {}
library_type = library.type user_watched[user_name][library.title][show_guids][season.title] = episode_guids
skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping)
return user_watched
if skip_reason: except Exception as e:
logger(f"Plex: Skipping library {library_title} {skip_reason}", 1) logger(f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}", 2)
continue raise Exception(e)
for user in users:
logger(f"Plex: Generating watched for {user.title} in library {library_title}", 0) def get_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping):
user_name = user.title.lower() try:
watched = self.get_plex_user_watched(user, library) # Get all libraries
if watched: users_watched = {}
if user_name not in users_watched: args = []
users_watched[user_name] = {}
if library_title not in users_watched[user_name]: for user in users:
users_watched[user_name][library_title] = [] if self.admin_user == user:
users_watched[user_name][library_title] = watched user_plex = self.plex
else:
return users_watched user_plex = PlexServer(self.plex._baseurl, user.get_token(self.plex.machineIdentifier))
def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False): libraries = user_plex.library.sections()
for user, libraries in watched_list.items():
if user_mapping: for library in libraries:
user_other = None library_title = library.title
library_type = library.type
if user in user_mapping.keys():
user_other = user_mapping[user] skip_reason = check_skip_logic(library_title, library_type, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping)
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user) if skip_reason:
logger(f"Plex: Skipping library {library_title} {skip_reason}", 1)
if user_other: continue
logger(f"Swapping user {user} with {user_other}", 1)
user = user_other args.append([self.get_user_watched, user, user_plex, library])
for index, value in enumerate(self.users): for user_watched in future_thread_executor(args):
if user.lower() == value.title.lower(): for user, user_watched_temp in user_watched.items():
user = self.users[index] if user not in users_watched:
break users_watched[user] = {}
users_watched[user].update(user_watched_temp)
if self.admin_user == user:
user_plex = self.plex return users_watched
else: except Exception as e:
user_plex = PlexServer(self.baseurl, user.get_token(self.plex.machineIdentifier)) logger(f"Plex: Failed to get watched, Error: {e}", 2)
raise Exception(e)
for library, videos in libraries.items():
if library_mapping:
library_other = None def update_user_watched (self, user, user_plex, library, videos, dryrun):
try:
if library in library_mapping.keys(): logger(f"Plex: Updating watched for {user.title} in library {library}", 1)
library_other = library_mapping[library] videos_shows_ids, videos_episodes_ids, videos_movies_ids = generate_library_guids_dict(videos)
elif library in library_mapping.values(): logger(f"Plex: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}", 1)
library_other = search_mapping(library_mapping, library)
library_videos = user_plex.library.section(library)
if library_other: if videos_movies_ids:
logger(f"Swapping library {library} with {library_other}", 1) for movies_search in library_videos.search(unwatched=True):
library = library_other movie_found = False
for movie_location in movies_search.locations:
# if library in plex library list if movie_location.split("/")[-1] in videos_movies_ids["locations"]:
library_list = user_plex.library.sections() movie_found = True
if library.lower() not in [x.title.lower() for x in library_list]: break
logger(f"Library {library} not found in Plex library list", 2)
continue if not movie_found:
for movie_guid in movies_search.guids:
logger(f"Plex: Updating watched for {user.title} in library {library}", 1) movie_guid_source = re.search(r'(.*)://', movie_guid.id).group(1).lower()
library_videos = user_plex.library.section(library) movie_guid_id = re.search(r'://(.*)', movie_guid.id).group(1)
if library_videos.type == "movie": # If movie provider source and movie provider id are in videos_movie_ids exactly, then the movie is in the list
_, _, videos_movies_ids = generate_library_guids_dict(videos, 2) if movie_guid_source in videos_movies_ids.keys():
for movies_search in library_videos.search(unmatched=False, unwatched=True): if movie_guid_id in videos_movies_ids[movie_guid_source]:
for movie_guid in movies_search.guids: movie_found = True
movie_guid_source = re.search(r'(.*)://', movie_guid.id).group(1).lower() break
movie_guid_id = re.search(r'://(.*)', movie_guid.id).group(1)
# If movie provider source and movie provider id are in videos_movie_ids exactly, then the movie is in the list if movie_found:
if movie_guid_source in videos_movies_ids.keys(): msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex"
if movie_guid_id in videos_movies_ids[movie_guid_source]: if not dryrun:
if movies_search.viewCount == 0: logger(f"Marked {msg}", 0)
msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex" movies_search.markWatched()
if not dryrun: else:
logger(f"Marked {msg}", 0) logger(f"Dryrun {msg}", 0)
movies_search.markWatched() else:
else: logger(f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}", 1)
logger(f"Dryrun {msg}", 0)
break
if videos_shows_ids and videos_episodes_ids:
for show_search in library_videos.search(unwatched=True):
elif library_videos.type == "show": show_found = False
videos_shows_ids, videos_episode_ids, _ = generate_library_guids_dict(videos, 3) for show_location in show_search.locations:
if show_location.split("/")[-1] in videos_shows_ids["locations"]:
for show_search in library_videos.search(unmatched=False, unwatched=True): show_found = True
show_found = False break
for show_guid in show_search.guids:
show_guid_source = re.search(r'(.*)://', show_guid.id).group(1).lower() if not show_found:
show_guid_id = re.search(r'://(.*)', show_guid.id).group(1) for show_guid in show_search.guids:
show_guid_source = re.search(r'(.*)://', show_guid.id).group(1).lower()
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list show_guid_id = re.search(r'://(.*)', show_guid.id).group(1)
if show_guid_source in videos_shows_ids.keys():
if show_guid_id in videos_shows_ids[show_guid_source]: # If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list
show_found = True if show_guid_source in videos_shows_ids.keys():
for episode_search in show_search.episodes(): if show_guid_id in videos_shows_ids[show_guid_source]:
for episode_guid in episode_search.guids: show_found = True
episode_guid_source = re.search(r'(.*)://', episode_guid.id).group(1).lower() break
episode_guid_id = re.search(r'://(.*)', episode_guid.id).group(1)
if show_found:
# If episode provider source and episode provider id are in videos_episode_ids exactly, then the episode is in the list for episode_search in show_search.episodes():
if episode_guid_source in videos_episode_ids.keys(): episode_found = False
if episode_guid_id in videos_episode_ids[episode_guid_source]:
if episode_search.viewCount == 0: for episode_location in episode_search.locations:
msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex" if episode_location.split("/")[-1] in videos_episodes_ids["locations"]:
if not dryrun: episode_found = True
logger(f"Marked {msg}", 0) break
episode_search.markWatched()
else: if not episode_found:
logger(f"Dryrun {msg}", 0) for episode_guid in episode_search.guids:
break episode_guid_source = re.search(r'(.*)://', episode_guid.id).group(1).lower()
episode_guid_id = re.search(r'://(.*)', episode_guid.id).group(1)
if show_found:
break # If episode provider source and episode provider id are in videos_episodes_ids exactly, then the episode is in the list
if episode_guid_source in videos_episodes_ids.keys():
if episode_guid_id in videos_episodes_ids[episode_guid_source]:
episode_found = True
break
if episode_found:
msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex"
if not dryrun:
logger(f"Marked {msg}", 0)
episode_search.markWatched()
else:
logger(f"Dryrun {msg}", 0)
else:
logger(f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}", 1)
else:
logger(f"Plex: Skipping show {show_search.title} as it is not in mark list for {user.title}", 1)
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.title} in library {library}", 1)
except Exception as e:
logger(f"Plex: Failed to update watched for {user.title} in library {library}, Error: {e}", 2)
raise Exception(e)
def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False):
try:
args = []
for user, libraries in watched_list.items():
user_other = None
# If type of user is dict
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)
for index, value in enumerate(self.users):
if user.lower() == value.title.lower():
user = self.users[index]
break
elif user_other and user_other.lower() == value.title.lower():
user = self.users[index]
break
if self.admin_user == user:
user_plex = self.plex
else:
user_plex = PlexServer(self.plex._baseurl, user.get_token(self.plex.machineIdentifier))
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 in plex library list
library_list = user_plex.library.sections()
if library.lower() not in [x.title.lower() for x in library_list]:
if library_other:
if library_other.lower() in [x.title.lower() for x in library_list]:
logger(f"Plex: Library {library} not found, but {library_other} found, using {library_other}", 1)
library = library_other
else:
logger(f"Plex: Library {library} or {library_other} not found in library list", 2)
continue
else:
logger(f"Plex: Library {library} not found in library list", 2)
continue
args.append([self.update_user_watched, user, user_plex, library, videos, dryrun])
future_thread_executor(args)
except Exception as e:
logger(f"Plex: Failed to update watched, Error: {e}", 2)
raise Exception(e)

1
test/requirements.txt Normal file
View File

@@ -0,0 +1 @@
pytest

47
test/test_main_.py Normal file
View File

@@ -0,0 +1,47 @@
import sys
import os
# getting the name of the directory
# where the this file is present.
current = os.path.dirname(os.path.realpath(__file__))
# Getting the parent directory name
# where the current directory is present.
parent = os.path.dirname(current)
# adding the parent directory to
# the sys.path.
sys.path.append(parent)
from src.main import setup_black_white_lists
def test_setup_black_white_lists():
# Simple
blacklist_library = 'library1, library2'
whitelist_library = 'library1, library2'
blacklist_library_type = 'library_type1, library_type2'
whitelist_library_type = 'library_type1, library_type2'
blacklist_users = 'user1, user2'
whitelist_users = 'user1, user2'
results_blacklist_library, return_whitelist_library, return_blacklist_library_type, return_whitelist_library_type, return_blacklist_users, return_whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users)
assert results_blacklist_library == ['library1', 'library2']
assert return_whitelist_library == ['library1', 'library2']
assert return_blacklist_library_type == ['library_type1', 'library_type2']
assert return_whitelist_library_type == ['library_type1', 'library_type2']
assert return_blacklist_users == ['user1', 'user2']
assert return_whitelist_users == ['user1', 'user2']
# Library Mapping and user mapping
library_mapping = { "library1": "library3" }
user_mapping = { "user1": "user3" }
results_blacklist_library, return_whitelist_library, return_blacklist_library_type, return_whitelist_library_type, return_blacklist_users, return_whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users, library_mapping, user_mapping)
assert results_blacklist_library == ['library1', 'library2', 'library3']
assert return_whitelist_library == ['library1', 'library2', 'library3']
assert return_blacklist_library_type == ['library_type1', 'library_type2']
assert return_whitelist_library_type == ['library_type1', 'library_type2']
assert return_blacklist_users == ['user1', 'user2', 'user3']
assert return_whitelist_users == ['user1', 'user2', 'user3']

View File

@@ -0,0 +1,176 @@
import sys
import os
# getting the name of the directory
# where the this file is present.
current = os.path.dirname(os.path.realpath(__file__))
# Getting the parent directory name
# where the current directory is present.
parent = os.path.dirname(current)
# adding the parent directory to
# the sys.path.
sys.path.append(parent)
from src.main import cleanup_watched
tv_shows_watched_list_1 = {
frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): {
"Season 1": [
{'imdb': 'tt0550489', 'tmdb': '282843', 'tvdb': '176357', 'locations': ('Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv',)},
{'imdb': 'tt0550487', 'tmdb': '282861', 'tvdb': '300385', 'locations': ('Criminal Minds S01E02 Compulsion WEBDL-720p.mkv',)}
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{'locations': ('Test S01E01.mkv',)},
{'locations': ('Test S01E02.mkv',)}
]
}
}
movies_watched_list_1 = [
{"imdb":"tt2380307", "tmdb":"354912", 'title': 'Coco', 'locations': ('Coco (2017) Remux-1080p.mkv',)},
{"tmdbcollection":"448150", "imdb":"tt1431045", "tmdb":"293660", 'title': 'Deadpool', 'locations': ('Deadpool (2016) Remux-1080p.mkv',)},
]
tv_shows_watched_list_2 = {
frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): {
"Season 1": [
{'imdb': 'tt0550487', 'tmdb': '282861', 'tvdb': '300385', 'locations': ('Criminal Minds S01E02 Compulsion WEBDL-720p.mkv',)},
{'imdb': 'tt0550498', 'tmdb': '282865', 'tvdb': '300474', 'locations': ("Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",)}
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{'locations': ('Test S01E02.mkv',)},
{'locations': ('Test S01E03.mkv',)}
]
}
}
movies_watched_list_2 = [
{"imdb":"tt2380307", "tmdb":"354912", 'title': 'Coco', 'locations': ('Coco (2017) Remux-1080p.mkv',)},
{'imdb': 'tt0384793', 'tmdb': '9788', 'tvdb': '9103', 'title': 'Accepted', 'locations': ('Accepted (2006) Remux-1080p.mkv',)}
]
# Test to see if objects get deleted all the way up to the root.
tv_shows_2_watched_list_1 = {
frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): {
"Season 1": [
{'imdb': 'tt0550489', 'tmdb': '282843', 'tvdb': '176357', 'locations': ('Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv',)},
]
}
}
expected_tv_show_watched_list_1 = {
frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): {
"Season 1": [
{'imdb': 'tt0550489', 'tmdb': '282843', 'tvdb': '176357', 'locations': ('Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv',)}
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{'locations': ('Test S01E01.mkv',)}
]
}
}
expected_movie_watched_list_1 = [
{"tmdbcollection":"448150", "imdb":"tt1431045", "tmdb":"293660", 'title': 'Deadpool', 'locations': ('Deadpool (2016) Remux-1080p.mkv',)}
]
expected_tv_show_watched_list_2 = {
frozenset({("tvdb", "75710"), ("title", "Criminal Minds"), ("imdb", "tt0452046"), ("locations", ("Criminal Minds",)), ("tmdb", "4057")}): {
"Season 1": [
{'imdb': 'tt0550498', 'tmdb': '282865', 'tvdb': '300474', 'locations': ("Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",)}
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{'locations': ('Test S01E03.mkv',)}
]
}
}
expected_movie_watched_list_2 = [
{'imdb': 'tt0384793', 'tmdb': '9788', 'tvdb': '9103', 'title': 'Accepted', 'locations': ('Accepted (2006) Remux-1080p.mkv',)}
]
def test_simple_cleanup_watched():
user_watched_list_1 = {
"user1": {
"TV Shows": tv_shows_watched_list_1,
"Movies": movies_watched_list_1,
"Other Shows": tv_shows_2_watched_list_1
},
}
user_watched_list_2 = {
"user1": {
"TV Shows": tv_shows_watched_list_2,
"Movies": movies_watched_list_2,
"Other Shows": tv_shows_2_watched_list_1
}
}
expected_watched_list_1 = {
"user1": {
"TV Shows": expected_tv_show_watched_list_1
, "Movies": expected_movie_watched_list_1
}
}
expected_watched_list_2 = {
"user1": {
"TV Shows": expected_tv_show_watched_list_2
, "Movies": expected_movie_watched_list_2
}
}
return_watched_list_1 = cleanup_watched(user_watched_list_1, user_watched_list_2)
return_watched_list_2 = cleanup_watched(user_watched_list_2, user_watched_list_1)
assert return_watched_list_1 == expected_watched_list_1
assert return_watched_list_2 == expected_watched_list_2
def test_mapping_cleanup_watched():
user_watched_list_1 = {
"user1": {
"TV Shows": tv_shows_watched_list_1,
"Movies": movies_watched_list_1,
"Other Shows": tv_shows_2_watched_list_1
},
}
user_watched_list_2 = {
"user2": {
"Shows": tv_shows_watched_list_2,
"Movies": movies_watched_list_2,
"Other Shows": tv_shows_2_watched_list_1
}
}
expected_watched_list_1 = {
"user1": {
"TV Shows": expected_tv_show_watched_list_1
, "Movies": expected_movie_watched_list_1
}
}
expected_watched_list_2 = {
"user2": {
"Shows": expected_tv_show_watched_list_2
, "Movies": expected_movie_watched_list_2
}
}
user_mapping = { "user1": "user2" }
library_mapping = { "TV Shows": "Shows" }
return_watched_list_1 = cleanup_watched(user_watched_list_1, user_watched_list_2, user_mapping=user_mapping, library_mapping=library_mapping)
return_watched_list_2 = cleanup_watched(user_watched_list_2, user_watched_list_1, user_mapping=user_mapping, library_mapping=library_mapping)
assert return_watched_list_1 == expected_watched_list_1
assert return_watched_list_2 == expected_watched_list_2