50 Commits

Author SHA1 Message Date
Luigi311
87b4a950f1 Merge pull request #75 from luigi311/dev
Variants, Pin versions, CI, Plex usernames
2023-05-17 13:38:25 -06:00
Luigi311
9f61c7338d Plex: Cleanup username_title 2023-05-17 13:22:00 -06:00
Luigi311
ffc81dad69 CI: Add back in dev based on alpine 2023-05-15 15:12:25 -06:00
Luigi311
7eba46b5cb plex: Fix username/title 2023-05-15 14:57:46 -06:00
Luigi311
aa177666a5 Plex: Fix username/title selection 2023-05-15 11:17:28 -06:00
Luigi311
7de7b42fd2 Users: Default to username and fall back to title 2023-05-15 11:10:03 -06:00
Luigi311
03d1fd8019 Log both servers users instead of exiting immediately 2023-05-15 10:44:30 -06:00
Luigi311
485ec5fe2d Add docker-compose file 2023-04-29 20:31:24 -06:00
Luigi311
59bfbd9811 Merge pull request #71 from luigi311/fix-docker-build/push
Do not publish on PR, fix condition check on build
2023-04-13 13:02:55 -06:00
Luigi311
1e485b37f8 Do not publish on PR, fix condition check on build 2023-04-13 12:56:52 -06:00
Luigi311
4adf94f24b Update ci.yml
Action: Use github.repository and github.actor instead
2023-04-13 10:28:01 -06:00
Luigi311
1a0fab36d3 Merge pull request #66 from Nicba1010/main
General build improvements
2023-04-13 09:50:59 -06:00
Roberto Banić
a1ef3b5a8d Add conditional to DockerHub login 2023-04-13 16:45:05 +02:00
Luigi311
0c47ee7119 Merge pull request #68 from Nicba1010/refactor-black-white
Refactor black/whitelist processing
2023-04-13 08:37:38 -06:00
Roberto Banić
e51cf6e482 Refactor black/whitelist processing 2023-04-13 12:56:28 +02:00
Roberto Banić
24d5de813d Remove DOCKER_USERNAME environment variable from docker_meta step 2023-04-13 11:23:32 +02:00
Roberto Banić
9921b2a355 Change is_default_branch to other default branch check 2023-04-13 11:21:28 +02:00
Roberto Banić
faa378c75e Add is_default_branch conditional to latest tag 2023-04-13 11:20:19 +02:00
Roberto Banić
26199100dc Update tags 2023-04-13 11:19:56 +02:00
Roberto Banić
bee854f059 Exclude DockerHub in case there is no username set 2023-04-13 10:48:03 +02:00
Roberto Banić
73c1ebf3ed Pin pytest version 2023-04-13 02:26:12 +02:00
Roberto Banić
397dd17429 Specify Python version 2023-04-13 02:26:11 +02:00
Roberto Banić
73d18dad92 Rename Dockerfile to Dockerfile.alpine 2023-04-13 02:26:10 +02:00
Roberto Banić
94d63a3fdb Add ghcr.io image name to the docker metadata action step 2023-04-13 02:26:09 +02:00
Roberto Banić
120d89e8be Add dashes to tags 2023-04-13 02:26:08 +02:00
Roberto Banić
eb5534c61c Add ghcr.io registry 2023-04-13 02:26:07 +02:00
Roberto Banić
99d217e8f1 Update ci.yml to perform a multi-variant build 2023-04-13 02:26:05 +02:00
Roberto Banić
f7e3f8ae2a Update Dockerfile to use the alpine Python 3 base image 2023-04-13 02:26:04 +02:00
Roberto Banić
2cebd2d73d Pin dependency versions to enable reproducible builds 2023-04-13 02:25:13 +02:00
Luigi311
18df322c41 Merge pull request #65 from luigi311/dev
Dev
2023-04-11 09:29:08 -06:00
Luigi311
fc80f50560 Fix codeql issues 2023-04-11 08:57:49 -06:00
Luigi311
4870ff9e7a Cleanup 2023-04-11 08:48:30 -06:00
Luigi311
58337bd38c Test: Use is None 2023-04-10 23:05:22 -06:00
Luigi311
e6d1e0933a Merge pull request #64 from luigi311/fix_indexing
Fix indexing with check_remove_entry
2023-04-10 17:20:36 -06:00
Luigi311
68e3f25ba4 Fix indexing 2023-04-10 16:59:54 -06:00
Luigi311
c981426db6 Merge pull request #62 from agustinmorantes/dev
Add "RUN_ONLY_ONCE" option
2023-04-10 11:54:17 -06:00
Agustín Morantes
916b16b12c Add "RUN_ONLY_ONCE" option 2023-04-10 14:39:28 -03:00
Luigi311
a178d230de Jellfyfin: Fix more issues with ids 2023-04-07 17:31:25 -06:00
Luigi311
fffb04728a Jellfyin: Fix issue with ids. Do not show marked for partial 2023-04-07 15:17:00 -06:00
Luigi311
658361383a Update README.md 2023-04-07 13:41:39 -06:00
Luigi311
3330026de6 Merge pull request #57 from luigi311/partial_watch
Partially implement in progress syncing
2023-03-31 12:14:53 -06:00
Luigi311
25fe426720 Plex: Implement partial play syncing 2023-03-26 23:55:56 -06:00
Luigi311
8d53b5b8c0 Take into account comparing two partially watched/one watched video 2023-03-23 22:50:13 -06:00
Luigi311
0774735f0f Plex: Add title to episode_guids 2023-03-23 22:49:14 -06:00
Luigi311
a5540b94d5 Gather partially watched movie/episodes with todo for processing. 2023-03-22 19:48:19 -06:00
Luigi311
c69d59858d Merge pull request #54 from luigi311/dev
Fix variable overwrites, Fix errors when plex user has no access
2023-03-22 11:29:36 -06:00
Luigi311
962b1149ad Plex: Use token, Check for token on mark 2023-03-18 12:15:59 -06:00
Luigi311
a8edee0354 Jellyfin: Fix user_watched_temp overwrite issues. 2023-03-18 12:12:12 -06:00
Luigi311
3627dde64d Plex: Do not error if user has no access 2023-03-18 11:56:56 -06:00
Luigi311
80ec0e42c2 Dockerfile: Add sync directions to ENV 2023-03-16 14:57:57 -06:00
19 changed files with 1279 additions and 624 deletions

View File

@@ -1 +1,15 @@
.env
.dockerignore
.env
.env.sample
.git
.github
.gitignore
.idea
.vscode
Dockerfile*
README.md
test
venv

View File

@@ -9,6 +9,9 @@ DEBUG = "False"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "False"
## How often to run the script in seconds
SLEEP_DURATION = "3600"
@@ -27,7 +30,7 @@ LOGFILE = "log.log"
## Comma separated for multiple options
#BLACKLIST_LIBRARY = ""
#WHITELIST_LIBRARY = ""
#BLACKLIST_LIBRARY_TYPE = ""
#BLACKLIST_LIBRARY_TYPE = ""
#WHITELIST_LIBRARY_TYPE = ""
#BLACKLIST_USERS = ""
WHITELIST_USERS = "testuser1,testuser2"

View File

@@ -8,7 +8,7 @@ on:
paths-ignore:
- .gitignore
- "*.md"
jobs:
pytest:
runs-on: ubuntu-latest
@@ -24,25 +24,34 @@ jobs:
docker:
runs-on: ubuntu-latest
needs: pytest
strategy:
matrix:
include:
- dockerfile: Dockerfile.alpine
variant: alpine
- dockerfile: Dockerfile.slim
variant: slim
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: docker_meta
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
if: "${{ env.DOCKER_USERNAME != '' }}"
uses: docker/metadata-action@v4
with:
images: ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched # list of Docker images to use as base name for tags
images: |
${{ secrets.DOCKER_USERNAME }}/jellyplex-watched,enable=${{ secrets.DOCKER_USERNAME != '' }}
# Do not push to ghcr.io on PRs due to permission issues
ghcr.io/${{ github.repository }},enable=${{ github.event_name != 'pull_request' }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable=${{ matrix.variant == 'alpine' && github.ref_name == github.event.repository.default_branch }}
type=raw,value=dev,enable=${{ matrix.variant == 'alpine' && github.ref_name == 'dev' }}
type=raw,value=latest,suffix=-${{ matrix.variant }},enable={{ is_default_branch }}
type=ref,event=branch,suffix=-${{ matrix.variant }}
type=ref,event=pr,suffix=-${{ matrix.variant }}
type=semver,pattern={{ version }},suffix=-${{ matrix.variant }}
type=semver,pattern={{ major }}.{{ minor }},suffix=-${{ matrix.variant }}
type=sha,suffix=-${{ matrix.variant }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -51,30 +60,40 @@ jobs:
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: "${{ steps.docker_meta.outcome == 'success' }}"
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
if: "${{ env.DOCKER_USERNAME != '' }}"
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registry
if: "${{ steps.docker_meta.outcome == 'success' }}"
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
id: build
if: "${{ steps.docker_meta.outcome == 'skipped' }}"
if: "${{ steps.docker_meta.outputs.tags == '' }}"
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: false
tags: jellyplex-watched:action
- name: Build Push
id: build_push
if: "${{ steps.docker_meta.outcome == 'success' }}"
if: "${{ steps.docker_meta.outputs.tags != '' }}"
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_meta.outputs.tags }}

41
Dockerfile.alpine Normal file
View File

@@ -0,0 +1,41 @@
FROM python:3-alpine
ENV DRYRUN 'True'
ENV DEBUG 'True'
ENV DEBUG_LEVEL 'INFO'
ENV SLEEP_DURATION '3600'
ENV LOGFILE 'log.log'
ENV USER_MAPPING ''
ENV LIBRARY_MAPPING ''
ENV PLEX_BASEURL ''
ENV PLEX_TOKEN ''
ENV PLEX_USERNAME ''
ENV PLEX_PASSWORD ''
ENV PLEX_SERVERNAME ''
ENV JELLYFIN_BASEURL ''
ENV JELLYFIN_TOKEN ''
ENV SYNC_FROM_PLEX_TO_JELLYFIN 'True'
ENV SYNC_FROM_JELLYFIN_TO_PLEX 'True'
ENV SYNC_FROM_PLEX_TO_PLEX 'True'
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN 'True'
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"]

View File

@@ -1,41 +1,40 @@
FROM python:3-slim
ENV DRYRUN 'True'
ENV DEBUG 'True'
ENV DEBUG_LEVEL 'INFO'
ENV SLEEP_DURATION '3600'
ENV LOGFILE 'log.log'
ENV USER_MAPPING ''
ENV LIBRARY_MAPPING ''
ENV PLEX_BASEURL ''
ENV PLEX_TOKEN ''
ENV PLEX_USERNAME ''
ENV PLEX_PASSWORD ''
ENV PLEX_SERVERNAME ''
ENV JELLYFIN_BASEURL ''
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
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY ./requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-u", "main.py"]
FROM python:3-slim
ENV DRYRUN 'True'
ENV DEBUG 'True'
ENV DEBUG_LEVEL 'INFO'
ENV SLEEP_DURATION '3600'
ENV LOGFILE 'log.log'
ENV USER_MAPPING ''
ENV LIBRARY_MAPPING ''
ENV PLEX_BASEURL ''
ENV PLEX_TOKEN ''
ENV PLEX_USERNAME ''
ENV PLEX_PASSWORD ''
ENV PLEX_SERVERNAME ''
ENV JELLYFIN_BASEURL ''
ENV JELLYFIN_TOKEN ''
ENV SYNC_FROM_PLEX_TO_JELLYFIN 'True'
ENV SYNC_FROM_JELLYFIN_TO_PLEX 'True'
ENV SYNC_FROM_PLEX_TO_PLEX 'True'
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN 'True'
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"]

333
README.md
View File

@@ -1,149 +1,184 @@
# 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&utm_medium=referral&utm_content=luigi311/JellyPlex-Watched&utm_campaign=Badge_Grade)
Sync watched between jellyfin and plex locally
## Description
Keep in sync all your users watched history between jellyfin and plex servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the .env plex/jellyfin section separated by commas.
## Configuration
```bash
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "False"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## How often to run the script in seconds
SLEEP_DURATION = "3600"
## Log file where all output will be written to
LOGFILE = "log.log"
## Map usernames between servers in the event that they are different, order does not matter
## Comma separated for multiple options
USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" }
## Map libraries between servers in the even that they are different, order does not matter
## Comma separated for multiple options
LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" }
## 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.
## Comma separated for multiple options
BLACKLIST_LIBRARY = ""
WHITELIST_LIBRARY = ""
BLACKLIST_LIBRARY_TYPE = ""
WHITELIST_LIBRARY_TYPE = ""
BLACKLIST_USERS = ""
WHITELIST_USERS = "testuser1,testuser2"
# Plex
## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers
## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly
## Comma separated list for multiple servers
PLEX_BASEURL = "http://localhost:32400, https://nas:32400"
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
## Comma separated list for multiple servers
PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2"
## If not using plex token then use username and password of the server admin along with the servername
## Comma separated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "False"
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
# Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma separated list for multiple servers
JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma separated list for multiple servers
JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2"
```
## Installation
### Baremetal
- Setup virtualenv of your choice
- Install dependencies
```bash
pip install -r requirements.txt
```
- Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens
- Run
```bash
python main.py
```
### Docker
- Build docker image
```bash
docker build -t jellyplex-watched .
```
- or use pre-built image
```bash
docker pull luigi311/jellyplex-watched:latest
```
#### With variables
- Run
```bash
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
```
#### With .env
- Create a .env file similar to .env.sample and set the variables to match your setup
- Run
```bash
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
```
## Contributing
I am open to receiving 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.
# 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\&utm_medium=referral\&utm_content=luigi311/JellyPlex-Watched\&utm_campaign=Badge_Grade)
Sync watched between jellyfin and plex locally
## Description
Keep in sync all your users watched history between jellyfin and plex servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the .env plex/jellyfin section separated by commas.
## Features
### Plex
* \[x] Match via Filenames
* \[x] Match via provider ids
* \[x] Map usersnames
* \[x] Use single login
* \[x] One Way/Multi Way sync
* \[x] Sync Watched
* \[x] Sync Inprogress
### Jellyfin
* \[x] Match via Filenames
* \[x] Match via provider ids
* \[x] Map usersnames
* \[x] Use single login
* \[x] One Way/Multi Way sync
* \[x] Sync Watched
* \[ ] Sync Inprogress
### Emby
* \[ ] Match via Filenames
* \[ ] Match via provider ids
* \[ ] Map usersnames
* \[ ] Use single login
* \[ ] One Way/Multi Way sync
* \[ ] Sync Watched
* \[ ] Sync Inprogress
## Configuration
```bash
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "False"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "False"
## How often to run the script in seconds
SLEEP_DURATION = "3600"
## Log file where all output will be written to
LOGFILE = "log.log"
## Map usernames between servers in the event that they are different, order does not matter
## Comma separated for multiple options
USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" }
## Map libraries between servers in the even that they are different, order does not matter
## Comma separated for multiple options
LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" }
## 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.
## Comma separated for multiple options
BLACKLIST_LIBRARY = ""
WHITELIST_LIBRARY = ""
BLACKLIST_LIBRARY_TYPE = ""
WHITELIST_LIBRARY_TYPE = ""
BLACKLIST_USERS = ""
WHITELIST_USERS = "testuser1,testuser2"
# Plex
## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers
## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly
## Comma separated list for multiple servers
PLEX_BASEURL = "http://localhost:32400, https://nas:32400"
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
## Comma separated list for multiple servers
PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2"
## If not using plex token then use username and password of the server admin along with the servername
## Comma separated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "False"
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
# Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma separated list for multiple servers
JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma separated list for multiple servers
JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2"
```
## Installation
### Baremetal
* Setup virtualenv of your choice
* Install dependencies
```bash
pip install -r requirements.txt
```
* Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens
* Run
```bash
python main.py
```
### Docker
* Build docker image
```bash
docker build -t jellyplex-watched .
```
* or use pre-built image
```bash
docker pull luigi311/jellyplex-watched:latest
```
#### With variables
* Run
```bash
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
```
#### With .env
* Create a .env file similar to .env.sample and set the variables to match your setup
* Run
```bash
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
```
## Contributing
I am open to receiving 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.

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
version: '3'
services:
jellyplex-watched:
image: luigi311/jellyplex-watched:latest
container_name: jellyplex-watched
restart: always
environment:
- DRYRUN=True
- DEBUG=True
- DEBUG_LEVEL=info
- RUN_ONLY_ONCE=False
- SLEEP_DURATION=3600
- LOGFILE=/tmp/log.log
- USER_MAPPING=
- LIBRARY_MAPPING={"TV Shows":"Shows"}
- BLACKLIST_LIBRARY=
- WHITELIST_LIBRARY=
- BLACKLIST_LIBRARY_TYPE=
- WHITELIST_LIBRARY_TYPE=
- BLACKLIST_USERS=
- WHITELIST_USERS=
- PLEX_BASEURL=
- PLEX_TOKEN=
- JELLYFIN_BASEURL=
- JELLYFIN_TOKEN=
- SSL_BYPASS=True
- SYNC_FROM_PLEX_TO_JELLYFIN=True
- SYNC_FROM_JELLYFIN_TO_PLEX=True
- SYNC_FROM_PLEX_TO_PLEX=True
- SYNC_FROM_JELLYFIN_TO_JELLYFIN=True

View File

@@ -1,4 +1,4 @@
plexapi
requests
python-dotenv
aiohttp
PlexAPI==4.13.4
requests==2.28.2
python-dotenv==1.0.0
aiohttp==3.8.4

View File

@@ -11,18 +11,20 @@ def setup_black_white_lists(
library_mapping=None,
user_mapping=None,
):
blacklist_library, blacklist_library_type, blacklist_users = setup_black_lists(
blacklist_library, blacklist_library_type, blacklist_users = setup_x_lists(
blacklist_library,
blacklist_library_type,
blacklist_users,
"White",
library_mapping,
user_mapping,
)
whitelist_library, whitelist_library_type, whitelist_users = setup_white_lists(
whitelist_library, whitelist_library_type, whitelist_users = setup_x_lists(
whitelist_library,
whitelist_library_type,
whitelist_users,
"Black",
library_mapping,
user_mapping,
)
@@ -36,104 +38,93 @@ def setup_black_white_lists(
whitelist_users,
)
def setup_black_lists(
blacklist_library,
blacklist_library_type,
blacklist_users,
def setup_x_lists(
xlist_library,
xlist_library_type,
xlist_users,
xlist_type,
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 xlist_library:
if len(xlist_library) > 0:
xlist_library = xlist_library.split(",")
xlist_library = [x.strip() for x in xlist_library]
if library_mapping:
temp_library = []
for library in blacklist_library:
for library in xlist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
blacklist_library = blacklist_library + temp_library
xlist_library = xlist_library + temp_library
else:
blacklist_library = []
logger(f"Blacklist Library: {blacklist_library}", 1)
xlist_library = []
logger(f"{xlist_type}list Library: {xlist_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]
if xlist_library_type:
if len(xlist_library_type) > 0:
xlist_library_type = xlist_library_type.split(",")
xlist_library_type = [x.lower().strip() for x in xlist_library_type]
else:
blacklist_library_type = []
logger(f"Blacklist Library Type: {blacklist_library_type}", 1)
xlist_library_type = []
logger(f"{xlist_type}list Library Type: {xlist_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 xlist_users:
if len(xlist_users) > 0:
xlist_users = xlist_users.split(",")
xlist_users = [x.lower().strip() for x in xlist_users]
if user_mapping:
temp_users = []
for user in blacklist_users:
for user in xlist_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)
return blacklist_library, blacklist_library_type, blacklist_users
def setup_white_lists(
whitelist_library,
whitelist_library_type,
whitelist_users,
library_mapping=None,
user_mapping=None,
):
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 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 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
xlist_users = xlist_users + temp_users
else:
whitelist_users = []
xlist_users = []
else:
whitelist_users = []
logger(f"Whitelist Users: {whitelist_users}", 1)
xlist_users = []
logger(f"{xlist_type}list Users: {xlist_users}", 1)
return xlist_library, xlist_library_type, xlist_users
return whitelist_library, whitelist_library_type, whitelist_users

View File

@@ -39,6 +39,14 @@ def str_to_bool(value: any) -> bool:
return str(value).lower() in ("y", "yes", "t", "true", "on", "1")
# Search for nested element in list
def contains_nested(element, lst):
for i, item in enumerate(lst):
if element in item:
return i
return None
# Get mapped value
def search_mapping(dictionary: dict, key_value: str):
if key_value in dictionary.keys():

View File

@@ -1,9 +1,7 @@
import asyncio, aiohttp, traceback
from math import floor
from src.functions import (
logger,
search_mapping,
)
from src.functions import logger, search_mapping, contains_nested
from src.library import (
check_skip_logic,
generate_library_guids_dict,
@@ -13,6 +11,56 @@ from src.watched import (
)
def get_movie_guids(movie):
if "ProviderIds" in movie:
logger(
f"Jellyfin: {movie['Name']} {movie['ProviderIds']} {movie['MediaSources']}",
3,
)
else:
logger(
f"Jellyfin: {movie['Name']} {movie['MediaSources']['Path']}",
3,
)
# Create a dictionary for the movie with its title
movie_guids = {"title": movie["Name"]}
# If the movie has provider IDs, add them to the dictionary
if "ProviderIds" in movie:
movie_guids.update({k.lower(): v for k, v in movie["ProviderIds"].items()})
# If the movie has media sources, add them to the dictionary
if "MediaSources" in movie:
movie_guids["locations"] = tuple(
[x["Path"].split("/")[-1] for x in movie["MediaSources"]]
)
movie_guids["status"] = {
"completed": movie["UserData"]["Played"],
# Convert ticks to milliseconds to match Plex
"time": floor(movie["UserData"]["PlaybackPositionTicks"] / 10000),
}
return movie_guids
def get_episode_guids(episode):
# Create a dictionary for the episode with its provider IDs and media sources
episode_dict = {k.lower(): v for k, v in episode["ProviderIds"].items()}
episode_dict["title"] = episode["Name"]
episode_dict["locations"] = tuple(
[x["Path"].split("/")[-1] for x in episode["MediaSources"]]
)
episode_dict["status"] = {
"completed": episode["UserData"]["Played"],
"time": floor(episode["UserData"]["PlaybackPositionTicks"] / 10000),
}
return episode_dict
class Jellyfin:
def __init__(self, baseurl, token):
self.baseurl = baseurl
@@ -114,48 +162,43 @@ class Jellyfin:
session,
)
in_progress = await self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
session,
)
for movie in watched["Items"]:
# Check if the movie has been played
if (
movie["UserData"]["Played"] is True
and "MediaSources" in movie
and movie["MediaSources"] is not {}
):
if "MediaSources" in movie and movie["MediaSources"] != {}:
logger(
f"Jellyfin: Adding {movie['Name']} to {user_name} watched list",
3,
)
if "ProviderIds" in movie:
logger(
f"Jellyfin: {movie['Name']} {movie['ProviderIds']} {movie['MediaSources']}",
3,
)
else:
logger(
f"Jellyfin: {movie['Name']} {movie['MediaSources']['Path']}",
3,
)
# Create a dictionary for the movie with its title
movie_guids = {"title": movie["Name"]}
# Get the movie's GUIDs
movie_guids = get_movie_guids(movie)
# If the movie has provider IDs, add them to the dictionary
if "ProviderIds" in movie:
movie_guids.update(
{
k.lower(): v
for k, v in movie["ProviderIds"].items()
}
)
# Append the movie dictionary to the list for the given user and library
user_watched[user_name][library_title].append(movie_guids)
logger(
f"Jellyfin: Added {movie_guids} to {user_name} watched list",
3,
)
# If the movie has media sources, add them to the dictionary
if "MediaSources" in movie:
movie_guids["locations"] = tuple(
[
x["Path"].split("/")[-1]
for x in movie["MediaSources"]
]
)
# Get all partially watched movies greater than 1 minute
for movie in in_progress["Items"]:
if "MediaSources" in movie and movie["MediaSources"] != {}:
if movie["UserData"]["PlaybackPositionTicks"] < 600000000:
continue
logger(
f"Jellyfin: Adding {movie['Name']} to {user_name} watched list",
3,
)
# Get the movie's GUIDs
movie_guids = get_movie_guids(movie)
# Append the movie dictionary to the list for the given user and library
user_watched[user_name][library_title].append(movie_guids)
@@ -244,16 +287,26 @@ class Jellyfin:
season_identifiers = dict(seasons["Identifiers"])
season_identifiers["season_id"] = season["Id"]
season_identifiers["season_name"] = season["Name"]
episode_task = asyncio.ensure_future(
watched_task = asyncio.ensure_future(
self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&isPlayed=true&Fields=ProviderIds,MediaSources",
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsPlayed&Fields=ProviderIds,MediaSources",
"get",
session,
frozenset(season_identifiers.items()),
)
)
episodes_tasks.append(episode_task)
in_progress_task = asyncio.ensure_future(
self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsResumable&Fields=ProviderIds,MediaSources",
"get",
session,
frozenset(season_identifiers.items()),
)
)
episodes_tasks.append(watched_task)
episodes_tasks.append(in_progress_task)
# Retrieve the episodes for each watched season
watched_episodes = await asyncio.gather(*episodes_tasks)
@@ -268,24 +321,19 @@ class Jellyfin:
season_dict["Episodes"] = []
for episode in episodes["Items"]:
if (
episode["UserData"]["Played"] is True
and "MediaSources" in episode
"MediaSources" in episode
and episode["MediaSources"] is not {}
):
# Create a dictionary for the episode with its provider IDs and media sources
episode_dict = {
k.lower(): v
for k, v in episode["ProviderIds"].items()
}
episode_dict["title"] = episode["Name"]
episode_dict["locations"] = tuple(
[
x["Path"].split("/")[-1]
for x in episode["MediaSources"]
]
)
# Add the episode dictionary to the season's list of episodes
season_dict["Episodes"].append(episode_dict)
# If watched or watched more than a minute
if (
episode["UserData"]["Played"] == True
or episode["UserData"]["PlaybackPositionTicks"]
> 600000000
):
episode_dict = get_episode_guids(episode)
# Add the episode dictionary to the season's list of episodes
season_dict["Episodes"].append(episode_dict)
# Add the season dictionary to the show's list of seasons
if (
season_dict["Identifiers"]["show_guids"]
@@ -460,8 +508,8 @@ class Jellyfin:
watched = await asyncio.gather(*watched, return_exceptions=True)
for user_watched in watched:
user_watched_temp = combine_watched_dicts(user_watched)
for user, user_watched_temp in user_watched_temp.items():
user_watched_combine = combine_watched_dicts(user_watched)
for user, user_watched_temp in user_watched_combine.items():
if user not in users_watched:
users_watched[user] = {}
users_watched[user].update(user_watched_temp)
@@ -498,18 +546,30 @@ class Jellyfin:
session,
)
for jellyfin_video in jellyfin_search["Items"]:
movie_found = False
movie_status = None
if "MediaSources" in jellyfin_video:
for movie_location in jellyfin_video["MediaSources"]:
if (
movie_location["Path"].split("/")[-1]
in videos_movies_ids["locations"]
contains_nested(
movie_location["Path"].split("/")[-1],
videos_movies_ids["locations"],
)
is not None
):
movie_found = True
for video in videos:
if (
contains_nested(
movie_location["Path"].split("/")[-1],
video["locations"],
)
is not None
):
movie_status = video["status"]
break
break
if not movie_found:
if not movie_status:
for (
movie_provider_source,
movie_provider_id,
@@ -521,21 +581,37 @@ class Jellyfin:
movie_provider_source.lower()
]
):
movie_found = True
for video in videos:
if (
movie_provider_id.lower()
in video[movie_provider_source.lower()]
):
movie_status = video["status"]
break
break
if movie_found:
if movie_status:
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,
)
if movie_status["completed"]:
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"Dryrun {msg}", 0)
# TODO add support for partially watched movies
msg = f"{jellyfin_video['Name']} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin"
if not dryrun:
pass
# logger(f"Marked {msg}", 0)
else:
pass
# logger(f"Dryrun {msg}", 0)
else:
logger(
f"Jellyfin: Skipping movie {jellyfin_video['Name']} as it is not in mark list for {user_name}",
@@ -558,10 +634,27 @@ class Jellyfin:
if "Path" in jellyfin_show:
if (
jellyfin_show["Path"].split("/")[-1]
in videos_shows_ids["locations"]
contains_nested(
jellyfin_show["Path"].split("/")[-1],
videos_shows_ids["locations"],
)
is not None
):
show_found = True
episode_videos = []
for show, seasons in videos.items():
show = {k: v for k, v in show}
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
show["locations"],
)
is not None
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
if not show_found:
for show_provider_source, show_provider_id in jellyfin_show[
@@ -575,7 +668,16 @@ class Jellyfin:
]
):
show_found = True
break
episode_videos = []
for show, seasons in videos.items():
show = {k: v for k, v in show}
if (
show_provider_id.lower()
in show[show_provider_source.lower()]
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
if show_found:
logger(
@@ -591,20 +693,34 @@ class Jellyfin:
)
for jellyfin_episode in jellyfin_episodes["Items"]:
episode_found = False
episode_status = None
if "MediaSources" in jellyfin_episode:
for episode_location in jellyfin_episode[
"MediaSources"
]:
if (
episode_location["Path"].split("/")[-1]
in videos_episodes_ids["locations"]
contains_nested(
episode_location["Path"].split("/")[-1],
videos_episodes_ids["locations"],
)
is not None
):
episode_found = True
for episode in episode_videos:
if (
contains_nested(
episode_location["Path"].split(
"/"
)[-1],
episode["locations"],
)
is not None
):
episode_status = episode["status"]
break
break
if not episode_found:
if not episode_status:
for (
episode_provider_source,
episode_provider_id,
@@ -619,24 +735,48 @@ class Jellyfin:
episode_provider_source.lower()
]
):
episode_found = True
for episode in episode_videos:
if (
episode_provider_id.lower()
in episode[
episode_provider_source.lower()
]
):
episode_status = episode[
"status"
]
break
break
if episode_found:
jellyfin_episode_id = jellyfin_episode["Id"]
msg = (
f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['Name']}"
+ f" 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,
if episode_status:
if episode_status["completed"]:
jellyfin_episode_id = jellyfin_episode["Id"]
msg = (
f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']}"
+ f" 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"Dryrun {msg}", 0)
# TODO add support for partially watched episodes
jellyfin_episode_id = jellyfin_episode["Id"]
msg = (
f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']}"
+ f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin"
)
if not dryrun:
pass
# logger(f"Marked {msg}", 0)
else:
pass
# logger(f"Dryrun {msg}", 0)
else:
logger(
f"Jellyfin: Skipping episode {jellyfin_episode['Name']} as it is not in mark list for {user_name}",
@@ -663,6 +803,7 @@ class Jellyfin:
f"Jellyfin: Error updating watched for {user_name} in library {library}, {e}",
2,
)
logger(traceback.format_exc(), 2)
raise Exception(e)
async def update_watched(

View File

@@ -132,6 +132,8 @@ def check_whitelist_logic(
def show_title_dict(user_list: dict):
try:
show_output_dict = {}
show_output_dict["locations"] = []
show_counter = 0 # Initialize a counter for the current show position
show_output_keys = user_list.keys()
show_output_keys = [dict(x) for x in list(show_output_keys)]
@@ -141,15 +143,19 @@ def show_title_dict(user_list: dict):
if provider_key.lower() == "title":
continue
if provider_key.lower() not in show_output_dict:
show_output_dict[provider_key.lower()] = []
show_output_dict[provider_key.lower()] = [None] * show_counter
if provider_key.lower() == "locations":
for show_location in provider_value:
show_output_dict[provider_key.lower()].append(show_location)
show_output_dict[provider_key.lower()].append(provider_value)
else:
show_output_dict[provider_key.lower()].append(
provider_value.lower()
)
show_counter += 1
for key in show_output_dict:
if len(show_output_dict[key]) < show_counter:
show_output_dict[key].append(None)
return show_output_dict
except Exception:
logger("Generating show_output_dict failed, skipping", 1)
@@ -159,22 +165,52 @@ def show_title_dict(user_list: dict):
def episode_title_dict(user_list: dict):
try:
episode_output_dict = {}
episode_output_dict["completed"] = []
episode_output_dict["time"] = []
episode_output_dict["locations"] = []
episode_counter = 0 # Initialize a counter for the current episode position
# Iterate through the shows, seasons, and episodes in user_list
for show in user_list:
for season in user_list[show]:
for episode in user_list[show][season]:
# Iterate through the keys and values in each episode
for episode_key, episode_value in episode.items():
if episode_key.lower() not in episode_output_dict:
episode_output_dict[episode_key.lower()] = []
# If the key is not "status", add the key to episode_output_dict if it doesn't exist
if episode_key != "status":
if episode_key.lower() not in episode_output_dict:
# Initialize the list with None values up to the current episode position
episode_output_dict[episode_key.lower()] = [
None
] * episode_counter
# If the key is "locations", append each location to the list
if episode_key == "locations":
for episode_location in episode_value:
episode_output_dict[episode_key.lower()].append(
episode_location
)
episode_output_dict[episode_key.lower()].append(
episode_value
)
# If the key is "status", append the "completed" and "time" values
elif episode_key == "status":
episode_output_dict["completed"].append(
episode_value["completed"]
)
episode_output_dict["time"].append(episode_value["time"])
# For other keys, append the value to the list
else:
episode_output_dict[episode_key.lower()].append(
episode_value.lower()
)
# Increment the episode_counter
episode_counter += 1
# Extend the lists in episode_output_dict with None values to match the current episode_counter
for key in episode_output_dict:
if len(episode_output_dict[key]) < episode_counter:
episode_output_dict[key].append(None)
return episode_output_dict
except Exception:
logger("Generating episode_output_dict failed, skipping", 1)
@@ -184,16 +220,30 @@ def episode_title_dict(user_list: dict):
def movies_title_dict(user_list: dict):
try:
movies_output_dict = {}
movies_output_dict["completed"] = []
movies_output_dict["time"] = []
movies_output_dict["locations"] = []
movie_counter = 0 # Initialize a counter for the current movie position
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 != "status":
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)
movies_output_dict[movie_key.lower()].append(movie_value)
elif movie_key == "status":
movies_output_dict["completed"].append(movie_value["completed"])
movies_output_dict["time"].append(movie_value["time"])
else:
movies_output_dict[movie_key.lower()].append(movie_value.lower())
movie_counter += 1
for key in movies_output_dict:
if len(movies_output_dict[key]) < movie_counter:
movies_output_dict[key].append(None)
return movies_output_dict
except Exception:
logger("Generating movies_output_dict failed, skipping", 1)

View File

@@ -40,15 +40,23 @@ def setup_users(
# Check if users is none or empty
if output_server_1_users is None or len(output_server_1_users) == 0:
raise Exception(
f"No users found for server 1 {server_1[0]}, users found {users}, filtered users {users_filtered}, server 1 users {server_1[1].users}"
logger(
f"No users found for server 1 {server_1[0]}, users: {server_1_users}, overlapping users {users}, filtered users {users_filtered}, server 1 users {server_1[1].users}"
)
if output_server_2_users is None or len(output_server_2_users) == 0:
raise Exception(
f"No users found for server 2 {server_2[0]}, users found {users} filtered users {users_filtered}, server 2 users {server_2[1].users}"
logger(
f"No users found for server 2 {server_2[0]}, users: {server_2_users}, overlapping users {users} filtered users {users_filtered}, server 2 users {server_2[1].users}"
)
if (
output_server_1_users is None
or len(output_server_1_users) == 0
or output_server_2_users is None
or len(output_server_2_users) == 0
):
raise Exception("No users found for one or both servers")
logger(f"Server 1 users: {output_server_1_users}", 1)
logger(f"Server 2 users: {output_server_2_users}", 1)
@@ -365,6 +373,7 @@ def main_loop():
def main():
run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False"))
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
times = []
while True:
@@ -377,6 +386,9 @@ def main():
if len(times) > 0:
logger(f"Average time: {sum(times) / len(times)}", 0)
if run_only_once:
break
logger(f"Looping in {sleep_duration}")
sleep(sleep_duration)
@@ -389,6 +401,9 @@ def main():
logger(traceback.format_exc(), 2)
if run_only_once:
break
logger(f"Retrying in {sleep_duration}", log_type=0)
sleep(sleep_duration)

View File

@@ -1,5 +1,6 @@
import re, requests, os, traceback
from urllib3.poolmanager import PoolManager
from math import floor
from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount
@@ -8,6 +9,7 @@ from src.functions import (
logger,
search_mapping,
future_thread_executor,
contains_nested,
)
from src.library import (
check_skip_logic,
@@ -27,14 +29,70 @@ class HostNameIgnoringAdapter(requests.adapters.HTTPAdapter):
)
def get_movie_guids(video, completed=True):
logger(f"Plex: {video.title} {video.guids} {video.locations}", 3)
movie_guids = {}
try:
for guid in video.guids:
# Extract source and id from guid.id
m = re.match(r"(.*)://(.*)", guid.id)
guid_source, guid_id = m.group(1).lower(), m.group(2)
movie_guids[guid_source] = guid_id
except Exception:
logger(f"Plex: Failed to get guids for {video.title}, Using location only", 1)
movie_guids["title"] = video.title
movie_guids["locations"] = tuple([x.split("/")[-1] for x in video.locations])
movie_guids["status"] = {
"completed": completed,
"time": video.viewOffset,
}
return movie_guids
def get_episode_guids(episode, show, completed=True):
episode_guids_temp = {}
try:
for guid in episode.guids:
# Extract after :// from guid.id
m = re.match(r"(.*)://(.*)", guid.id)
guid_source, guid_id = m.group(1).lower(), m.group(2)
episode_guids_temp[guid_source] = guid_id
except Exception:
logger(
f"Plex: Failed to get guids for {episode.title} in {show.title}, Using location only",
1,
)
episode_guids_temp["title"] = episode.title
episode_guids_temp["locations"] = tuple(
[x.split("/")[-1] for x in episode.locations]
)
episode_guids_temp["status"] = {
"completed": completed,
"time": episode.viewOffset,
}
return episode_guids_temp
def get_user_library_watched_show(show):
try:
show_guids = {}
for show_guid in show.guids:
# Extract source and id from guid.id
m = re.match(r"(.*)://(.*)", show_guid.id)
show_guid_source, show_guid_id = m.group(1).lower(), m.group(2)
show_guids[show_guid_source] = show_guid_id
try:
for show_guid in show.guids:
# Extract source and id from guid.id
m = re.match(r"(.*)://(.*)", show_guid.id)
show_guid_source, show_guid_id = m.group(1).lower(), m.group(2)
show_guids[show_guid_source] = show_guid_id
except Exception:
logger(
f"Plex: Failed to get guids for {show.title}, Using location only", 1
)
show_guids["title"] = show.title
show_guids["locations"] = tuple([x.split("/")[-1] for x in show.locations])
@@ -42,30 +100,23 @@ def get_user_library_watched_show(show):
# Get all watched episodes for show
episode_guids = {}
watched_episodes = show.watched()
for episode in watched_episodes:
episode_guids_temp = {}
try:
if len(episode.guids) > 0:
for guid in episode.guids:
# Extract after :// from guid.id
m = re.match(r"(.*)://(.*)", guid.id)
guid_source, guid_id = m.group(1).lower(), m.group(2)
episode_guids_temp[guid_source] = guid_id
except Exception:
logger(
f"Plex: Failed to get guids for {episode.title} in {show.title}, Using location only",
1,
watched = show.watched()
for episode in show.episodes():
if episode in watched:
if episode.parentTitle not in episode_guids:
episode_guids[episode.parentTitle] = []
episode_guids[episode.parentTitle].append(
get_episode_guids(episode, show, completed=True)
)
elif episode.viewOffset > 0:
if episode.parentTitle not in episode_guids:
episode_guids[episode.parentTitle] = []
episode_guids_temp["locations"] = tuple(
[x.split("/")[-1] for x in episode.locations]
)
if episode.parentTitle not in episode_guids:
episode_guids[episode.parentTitle] = []
episode_guids[episode.parentTitle].append(episode_guids_temp)
episode_guids[episode.parentTitle].append(
get_episode_guids(episode, show, completed=False)
)
return show_guids, episode_guids
@@ -75,7 +126,7 @@ def get_user_library_watched_show(show):
def get_user_library_watched(user, user_plex, library):
try:
user_name = user.title.lower()
user_name = user.username.lower() if user.username else user.title.lower()
user_watched = {}
user_watched[user_name] = {}
@@ -89,32 +140,37 @@ def get_user_library_watched(user, user_plex, library):
if library.type == "movie":
user_watched[user_name][library.title] = []
# Get all watched movies
for video in library_videos.search(unwatched=False):
logger(f"Plex: Adding {video.title} to {user_name} watched list", 3)
logger(f"Plex: {video.title} {video.guids} {video.locations}", 3)
movie_guids = {}
for guid in video.guids:
# Extract source and id from guid.id
m = re.match(r"(.*)://(.*)", guid.id)
guid_source, guid_id = m.group(1).lower(), m.group(2)
movie_guids[guid_source] = guid_id
movie_guids["title"] = video.title
movie_guids["locations"] = tuple(
[x.split("/")[-1] for x in video.locations]
)
movie_guids = get_movie_guids(video, completed=True)
user_watched[user_name][library.title].append(movie_guids)
# Get all partially watched movies greater than 1 minute
for video in library_videos.search(inProgress=True):
if video.viewOffset < 60000:
continue
logger(f"Plex: Adding {video.title} to {user_name} watched list", 3)
movie_guids = get_movie_guids(video, completed=False)
user_watched[user_name][library.title].append(movie_guids)
logger(f"Plex: Added {movie_guids} to {user_name} watched list", 3)
elif library.type == "show":
user_watched[user_name][library.title] = {}
shows = library_videos.search(unwatched=False)
# Parallelize show processing
args = []
for show in shows:
# Get all watched shows
for show in library_videos.search(unwatched=False):
args.append([get_user_library_watched_show, show])
# Get all partially watched shows
for show in library_videos.search(inProgress=True):
args.append([get_user_library_watched_show, show])
for show_guids, episode_guids in future_thread_executor(
@@ -144,11 +200,26 @@ def get_user_library_watched(user, user_plex, library):
return {}
def find_video(plex_search, video_ids):
def find_video(plex_search, video_ids, videos=None):
try:
for location in plex_search.locations:
if location.split("/")[-1] in video_ids["locations"]:
return True
if (
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
episode_videos = []
if videos:
for show, seasons in videos.items():
show = {k: v for k, v in show}
if (
contains_nested(location.split("/")[-1], show["locations"])
is not None
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
return True, episode_videos
for guid in plex_search.guids:
guid_source = re.search(r"(.*)://", guid.id).group(1).lower()
@@ -157,11 +228,52 @@ def find_video(plex_search, video_ids):
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list
if guid_source in video_ids.keys():
if guid_id in video_ids[guid_source]:
return True
episode_videos = []
if videos:
for show, seasons in videos.items():
show = {k: v for k, v in show}
if guid_source in show["ids"].keys():
if guid_id in show["ids"][guid_source]:
for season in seasons:
for episode in season:
episode_videos.append(episode)
return False
return True, episode_videos
return False, []
except Exception:
return False
return False, []
def get_video_status(plex_search, video_ids, videos):
try:
for location in plex_search.locations:
if (
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
for video in videos:
if (
contains_nested(location.split("/")[-1], video["locations"])
is not None
):
return video["status"]
for guid in plex_search.guids:
guid_source = re.search(r"(.*)://", guid.id).group(1).lower()
guid_id = re.search(r"://(.*)", guid.id).group(1)
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list
if guid_source in video_ids.keys():
if guid_id in video_ids[guid_source]:
for video in videos:
if guid_source in video["ids"].keys():
if guid_id in video["ids"][guid_source]:
return video["status"]
return None
except Exception:
return None
def update_user_watched(user, user_plex, library, videos, dryrun):
@@ -180,13 +292,24 @@ def update_user_watched(user, user_plex, library, videos, dryrun):
library_videos = user_plex.library.section(library)
if videos_movies_ids:
for movies_search in library_videos.search(unwatched=True):
if find_video(movies_search, videos_movies_ids):
msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex"
if not dryrun:
logger(f"Marked {msg}", 0)
movies_search.markWatched()
else:
logger(f"Dryrun {msg}", 0)
video_status = get_video_status(
movies_search, videos_movies_ids, videos
)
if video_status:
if video_status["completed"]:
msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex"
if not dryrun:
logger(f"Marked {msg}", 0)
movies_search.markWatched()
else:
logger(f"Dryrun {msg}", 0)
elif video_status["time"] > 60_000:
msg = f"{movies_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex"
if not dryrun:
logger(f"Marked {msg}", 0)
movies_search.updateProgress(video_status["time"])
else:
logger(f"Dryrun {msg}", 0)
else:
logger(
f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}",
@@ -195,15 +318,29 @@ def update_user_watched(user, user_plex, library, videos, dryrun):
if videos_shows_ids and videos_episodes_ids:
for show_search in library_videos.search(unwatched=True):
if find_video(show_search, videos_shows_ids):
show_found, episode_videos = find_video(
show_search, videos_shows_ids, videos
)
if show_found:
for episode_search in show_search.episodes():
if find_video(episode_search, videos_episodes_ids):
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()
video_status = get_video_status(
episode_search, videos_episodes_ids, episode_videos
)
if video_status:
if video_status["completed"]:
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"Dryrun {msg}", 0)
msg = f"{show_search.title} {episode_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex"
if not dryrun:
logger(f"Marked {msg}", 0)
episode_search.updateProgress(video_status["time"])
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}",
@@ -307,10 +444,19 @@ class Plex:
if self.admin_user == user:
user_plex = self.plex
else:
user_plex = self.login(
self.plex._baseurl,
user.get_token(self.plex.machineIdentifier),
)
token = user.get_token(self.plex.machineIdentifier)
if token:
user_plex = self.login(
self.plex._baseurl,
token,
)
else:
logger(
f"Plex: Failed to get token for {user.title}, skipping",
2,
)
users_watched[user.title] = {}
continue
libraries = user_plex.library.sections()
@@ -363,10 +509,16 @@ class Plex:
user_other = search_mapping(user_mapping, user)
for index, value in enumerate(self.users):
if user.lower() == value.title.lower():
username_title = (
value.username.lower()
if value.username
else value.title.lower()
)
if user.lower() == username_title:
user = self.users[index]
break
elif user_other and user_other.lower() == value.title.lower():
elif user_other and user_other.lower() == username_title:
user = self.users[index]
break
@@ -380,11 +532,19 @@ class Plex:
)
user = self.plex.myPlexAccount().user(user)
user_plex = PlexServer(
self.plex._baseurl,
user.get_token(self.plex.machineIdentifier),
session=self.session,
)
token = user.get_token(self.plex.machineIdentifier)
if token:
user_plex = PlexServer(
self.plex._baseurl,
token,
session=self.session,
)
else:
logger(
f"Plex: Failed to get token for {user.title}, skipping",
2,
)
continue
for library, videos in libraries.items():
library_other = None

View File

@@ -11,7 +11,11 @@ def generate_user_list(server):
server_users = []
if server_type == "plex":
server_users = [x.title.lower() for x in server_connection.users]
for user in server_connection.users:
server_users.append(
user.username.lower() if user.username else user.title.lower()
)
elif server_type == "jellyfin":
server_users = [key.lower() for key in server_connection.users.keys()]
@@ -66,9 +70,13 @@ def generate_server_users(server, users):
if server[0] == "plex":
server_users = []
for plex_user in server[1].users:
username_title = (
plex_user.username if plex_user.username else plex_user.title
)
if (
plex_user.title.lower() in users.keys()
or plex_user.title.lower() in users.values()
username_title.lower() in users.keys()
or username_title.lower() in users.values()
):
server_users.append(plex_user)
elif server[0] == "jellyfin":

View File

@@ -1,9 +1,6 @@
import copy
from src.functions import (
logger,
search_mapping,
)
from src.functions import logger, search_mapping, contains_nested
from src.library import generate_library_guids_dict
@@ -29,6 +26,48 @@ def combine_watched_dicts(dicts: list):
return combined_dict
def check_remove_entry(video, library, video_index, library_watched_list_2):
if video_index is not None:
if (
library_watched_list_2["completed"][video_index]
== video["status"]["completed"]
) and (library_watched_list_2["time"][video_index] == video["status"]["time"]):
logger(
f"Removing {video['title']} from {library} due to exact match",
3,
)
return True
elif (
library_watched_list_2["completed"][video_index] == True
and video["status"]["completed"] == False
):
logger(
f"Removing {video['title']} from {library} due to being complete in one library and not the other",
3,
)
return True
elif (
library_watched_list_2["completed"][video_index] == False
and video["status"]["completed"] == False
) and (video["status"]["time"] < library_watched_list_2["time"][video_index]):
logger(
f"Removing {video['title']} from {library} due to more time watched in one library than the other",
3,
)
return True
elif (
library_watched_list_2["completed"][video_index] == True
and video["status"]["completed"] == True
):
logger(
f"Removing {video['title']} from {library} due to being complete in both libraries",
3,
)
return True
return False
def cleanup_watched(
watched_list_1, watched_list_2, user_mapping=None, library_mapping=None
):
@@ -60,31 +99,37 @@ def cleanup_watched(
# Movies
if isinstance(watched_list_1[user_1][library_1], list):
for movie in watched_list_1[user_1][library_1]:
if is_movie_in_dict(movie, movies_watched_list_2_keys_dict):
logger(f"Removing {movie} from {library_1}", 3)
modified_watched_list_1[user_1][library_1].remove(movie)
movie_index = get_movie_index_in_dict(
movie, movies_watched_list_2_keys_dict
)
if movie_index is not None:
if check_remove_entry(
movie,
library_1,
movie_index,
movies_watched_list_2_keys_dict,
):
modified_watched_list_1[user_1][library_1].remove(movie)
# TV Shows
elif isinstance(watched_list_1[user_1][library_1], dict):
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
]:
if is_episode_in_dict(
episode_index = get_episode_index_in_dict(
episode, episode_watched_list_2_keys_dict
):
if (
episode
in modified_watched_list_1[user_1][library_1][
show_key_1
][season]
)
if episode_index is not None:
if check_remove_entry(
episode,
library_1,
episode_index,
episode_watched_list_2_keys_dict,
):
logger(
f"Removing {episode} from {show_key_dict['title']}",
3,
)
modified_watched_list_1[user_1][library_1][
show_key_1
][season].remove(episode)
@@ -148,7 +193,7 @@ def get_other(watched_list, object_1, object_2):
return None
def is_movie_in_dict(movie, movies_watched_list_2_keys_dict):
def get_movie_index_in_dict(movie, movies_watched_list_2_keys_dict):
# Iterate through the keys and values of the movie dictionary
for movie_key, movie_value in movie.items():
# If the key is "locations", check if the "locations" key is present in the movies_watched_list_2_keys_dict dictionary
@@ -156,37 +201,40 @@ def is_movie_in_dict(movie, movies_watched_list_2_keys_dict):
if "locations" in movies_watched_list_2_keys_dict.keys():
# Iterate through the locations in the movie dictionary
for location in movie_value:
# If the location is in the movies_watched_list_2_keys_dict dictionary, return True
if location in movies_watched_list_2_keys_dict["locations"]:
return True
# If the location is in the movies_watched_list_2_keys_dict dictionary, return index of the key
return contains_nested(
location, movies_watched_list_2_keys_dict["locations"]
)
# If the key is not "locations", check if the movie_key is present in the movies_watched_list_2_keys_dict dictionary
else:
if movie_key in movies_watched_list_2_keys_dict.keys():
# If the movie_value is in the movies_watched_list_2_keys_dict dictionary, return True
if movie_value in movies_watched_list_2_keys_dict[movie_key]:
return True
return movies_watched_list_2_keys_dict[movie_key].index(movie_value)
# If the loop completes without finding a match, return False
return False
return None
def is_episode_in_dict(episode, episode_watched_list_2_keys_dict):
def get_episode_index_in_dict(episode, episode_watched_list_2_keys_dict):
# Iterate through the keys and values of the episode dictionary
for episode_key, episode_value in episode.items():
# If the key is "locations", check if the "locations" key is present in the episode_watched_list_2_keys_dict dictionary
if episode_key == "locations":
if "locations" in episode_watched_list_2_keys_dict.keys():
if episode_key in episode_watched_list_2_keys_dict.keys():
if episode_key == "locations":
# Iterate through the locations in the episode dictionary
for location in episode_value:
# If the location is in the episode_watched_list_2_keys_dict dictionary, return True
if location in episode_watched_list_2_keys_dict["locations"]:
return True
# If the key is not "locations", check if the episode_key is present in the episode_watched_list_2_keys_dict dictionary
else:
if episode_key in episode_watched_list_2_keys_dict.keys():
# If the location is in the episode_watched_list_2_keys_dict dictionary, return index of the key
return contains_nested(
location, episode_watched_list_2_keys_dict["locations"]
)
else:
# If the episode_value is in the episode_watched_list_2_keys_dict dictionary, return True
if episode_value in episode_watched_list_2_keys_dict[episode_key]:
return True
return episode_watched_list_2_keys_dict[episode_key].index(
episode_value
)
# If the loop completes without finding a match, return False
return False
return None

View File

@@ -1 +1 @@
pytest
pytest==7.3.0

View File

@@ -49,8 +49,11 @@ show_list = {
"tmdb": "2181581",
"tvdb": "8444132",
"locations": (
"The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",
(
"The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",
)
),
"status": {"completed": True, "time": 0},
}
]
}
@@ -60,29 +63,41 @@ movie_list = [
"title": "Coco",
"imdb": "tt2380307",
"tmdb": "354912",
"locations": ("Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv"),
"locations": [("Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv")],
"status": {"completed": True, "time": 0},
}
]
show_titles = {
"imdb": ["tt3581920"],
"locations": ["The Last of Us"],
"locations": [("The Last of Us",)],
"tmdb": ["100088"],
"tvdb": ["392256"],
}
episode_titles = {
"imdb": ["tt11957006"],
"locations": [
"The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv"
("The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",)
],
"tmdb": ["2181581"],
"tvdb": ["8444132"],
"completed": [True],
"time": [0],
}
movie_titles = {
"imdb": ["tt2380307"],
"locations": ["Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv"],
"locations": [
[
(
"Coco (2017) Remux-2160p.mkv",
"Coco (2017) Remux-1080p.mkv",
)
]
],
"title": ["coco"],
"tmdb": ["354912"],
"completed": [True],
"time": [0],
}
@@ -133,7 +148,7 @@ def test_check_skip_logic():
library_mapping,
)
assert skip_reason == None
assert skip_reason is None
def test_check_blacklist_logic():
@@ -182,7 +197,7 @@ def test_check_blacklist_logic():
library_other,
)
assert skip_reason == None
assert skip_reason is None
library_title = "Movies"
library_type = "movies"
@@ -195,7 +210,7 @@ def test_check_blacklist_logic():
library_other,
)
assert skip_reason == None
assert skip_reason is None
def test_check_whitelist_logic():
@@ -244,7 +259,7 @@ def test_check_whitelist_logic():
library_other,
)
assert skip_reason == None
assert skip_reason is None
library_title = "Movies"
library_type = "movies"
@@ -257,7 +272,7 @@ def test_check_whitelist_logic():
library_other,
)
assert skip_reason == None
assert skip_reason is None
def test_show_title_dict():

View File

@@ -30,42 +30,43 @@ tv_shows_watched_list_1 = {
"imdb": "tt0550489",
"tmdb": "282843",
"tvdb": "176357",
"title": "Extreme Aggressor",
"locations": (
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
),
"status": {"completed": True, "time": 0},
},
{
"imdb": "tt0550487",
"tmdb": "282861",
"tvdb": "300385",
"title": "Compulsion",
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",),
"status": {"completed": True, "time": 0},
},
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{"locations": ("Test S01E01.mkv",)},
{"locations": ("Test S01E02.mkv",)},
{
"title": "S01E01",
"locations": ("Test S01E01.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "S01E02",
"locations": ("Test S01E02.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "S01E04",
"locations": ("Test S01E04.mkv",),
"status": {"completed": False, "time": 5},
},
]
},
}
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(
{
@@ -81,32 +82,146 @@ tv_shows_watched_list_2 = {
"imdb": "tt0550487",
"tmdb": "282861",
"tvdb": "300385",
"title": "Compulsion",
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",),
"status": {"completed": True, "time": 0},
},
{
"imdb": "tt0550498",
"tmdb": "282865",
"tvdb": "300474",
"title": "Won't Get Fooled Again",
"locations": (
"Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",
),
"status": {"completed": True, "time": 0},
},
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{"locations": ("Test S01E02.mkv",)},
{"locations": ("Test S01E03.mkv",)},
{
"title": "S01E02",
"locations": ("Test S01E02.mkv",),
"status": {"completed": False, "time": 10},
},
{
"title": "S01E03",
"locations": ("Test S01E03.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "S01E04",
"locations": ("Test S01E04.mkv",),
"status": {"completed": False, "time": 10},
},
]
},
}
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",
"title": "Extreme Aggressor",
"locations": (
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
),
"status": {"completed": True, "time": 0},
}
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{
"title": "S01E01",
"locations": ("Test S01E01.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "S01E02",
"locations": ("Test S01E02.mkv",),
"status": {"completed": True, "time": 0},
},
]
},
}
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",
"title": "Won't Get Fooled Again",
"locations": (
"Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",
),
"status": {"completed": True, "time": 0},
}
]
},
frozenset({("title", "Test"), ("locations", ("Test",))}): {
"Season 1": [
{
"title": "S01E03",
"locations": ("Test S01E03.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "S01E04",
"locations": ("Test S01E04.mkv",),
"status": {"completed": False, "time": 10},
},
]
},
}
movies_watched_list_1 = [
{
"imdb": "tt2380307",
"tmdb": "354912",
"title": "Coco",
"locations": ("Coco (2017) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
{
"tmdbcollection": "448150",
"imdb": "tt1431045",
"tmdb": "293660",
"title": "Deadpool",
"locations": ("Deadpool (2016) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
]
movies_watched_list_2 = [
{
"imdb": "tt2380307",
"tmdb": "354912",
"title": "Coco",
"locations": ("Coco (2017) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
{
"imdb": "tt0384793",
@@ -114,9 +229,33 @@ movies_watched_list_2 = [
"tvdb": "9103",
"title": "Accepted",
"locations": ("Accepted (2006) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
]
expected_movie_watched_list_1 = [
{
"tmdbcollection": "448150",
"imdb": "tt1431045",
"tmdb": "293660",
"title": "Deadpool",
"locations": ("Deadpool (2016) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0},
}
]
expected_movie_watched_list_2 = [
{
"imdb": "tt0384793",
"tmdb": "9788",
"tvdb": "9103",
"title": "Accepted",
"locations": ("Accepted (2006) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0},
}
]
# Test to see if objects get deleted all the way up to the root.
tv_shows_2_watched_list_1 = {
frozenset(
@@ -133,86 +272,16 @@ tv_shows_2_watched_list_1 = {
"imdb": "tt0550489",
"tmdb": "282843",
"tvdb": "176357",
"title": "Extreme Aggressor",
"locations": (
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
),
"status": {"completed": True, "time": 0},
},
]
}
}
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 = {
@@ -311,18 +380,21 @@ def test_combine_watched_dicts():
"tmdb": "12429",
"imdb": "tt0876563",
"locations": ("Ponyo (2008) Bluray-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "Spirited Away",
"tmdb": "129",
"imdb": "tt0245429",
"locations": ("Spirited Away (2001) Bluray-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "Castle in the Sky",
"tmdb": "10515",
"imdb": "tt0092067",
"locations": ("Castle in the Sky (1986) Bluray-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
]
}
@@ -349,6 +421,7 @@ def test_combine_watched_dicts():
"locations": (
"11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv",
),
"status": {"completed": True, "time": 0},
}
]
}
@@ -365,18 +438,21 @@ def test_combine_watched_dicts():
"tmdb": "12429",
"imdb": "tt0876563",
"locations": ("Ponyo (2008) Bluray-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "Spirited Away",
"tmdb": "129",
"imdb": "tt0245429",
"locations": ("Spirited Away (2001) Bluray-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
{
"title": "Castle in the Sky",
"tmdb": "10515",
"imdb": "tt0092067",
"locations": ("Castle in the Sky (1986) Bluray-1080p.mkv",),
"status": {"completed": True, "time": 0},
},
],
"Anime Shows": {},
@@ -399,6 +475,7 @@ def test_combine_watched_dicts():
"locations": (
"11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv",
),
"status": {"completed": True, "time": 0},
}
]
}