Compare commits
264 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4771f736b0 | ||
|
|
8d7436579e | ||
|
|
43e1df98b1 | ||
|
|
3017030f52 | ||
|
|
348a0b8226 | ||
|
|
4e60c08120 | ||
|
|
10b58379cd | ||
|
|
fa9201b20f | ||
|
|
86f72997b4 | ||
|
|
62d0319aad | ||
|
|
a096a09eb7 | ||
|
|
7294241fed | ||
|
|
a5995d3999 | ||
|
|
30f31b2f3f | ||
|
|
bc09c873e9 | ||
|
|
8428be9dda | ||
|
|
6a45ad18f9 | ||
|
|
023b638729 | ||
|
|
7e13c14636 | ||
|
|
0c218fa9dd | ||
|
|
b3b0ccac73 | ||
|
|
fa0134551f | ||
|
|
34d62c9021 | ||
|
|
920bbbb3be | ||
|
|
762e5f10da | ||
|
|
27797cb361 | ||
|
|
066f9d1f66 | ||
|
|
acf7c2cdf2 | ||
|
|
469857a31a | ||
|
|
405e5decf2 | ||
|
|
da9abf8a24 | ||
|
|
128c6a1c76 | ||
|
|
99f32c10ef | ||
|
|
44e42f99db | ||
|
|
b1639eab0f | ||
|
|
679d3535b1 | ||
|
|
a795d4bba5 | ||
|
|
0a025cf5fa | ||
|
|
6a1ceb4db3 | ||
|
|
99c339c405 | ||
|
|
bd75d865ba | ||
|
|
d30e03b702 | ||
|
|
3b749faefb | ||
|
|
74f29d44b3 | ||
|
|
a397ceb54e | ||
|
|
502b3616df | ||
|
|
1a7178e32d | ||
|
|
7119956ec7 | ||
|
|
24035e217e | ||
|
|
21ffce674f | ||
|
|
4185f5fc94 | ||
|
|
3fdcc99304 | ||
|
|
0fa2a698ac | ||
|
|
2b871c58ed | ||
|
|
5078243938 | ||
|
|
b67e6d7257 | ||
|
|
632dfbcadb | ||
|
|
1f7da2f609 | ||
|
|
b3175305bd | ||
|
|
5b1933cb08 | ||
|
|
ae71ca0940 | ||
|
|
9b38729b95 | ||
|
|
402c286742 | ||
|
|
dcd4ac1d36 | ||
|
|
e6fbf746d8 | ||
|
|
803d248cb8 | ||
|
|
713be6970c | ||
|
|
62509f16db | ||
|
|
84899aef50 | ||
|
|
86b30e1887 | ||
|
|
033ef76cfe | ||
|
|
815596379c | ||
|
|
bc5e8bc65d | ||
|
|
b32de7259b | ||
|
|
29cb0cebd5 | ||
|
|
6744ebcb5b | ||
|
|
c6b026a82d | ||
|
|
cc706938ce | ||
|
|
84b98db36b | ||
|
|
01ad15e2bd | ||
|
|
54adf0e56f | ||
|
|
025e40b098 | ||
|
|
4534854001 | ||
|
|
362d54b471 | ||
|
|
fa533ff65e | ||
|
|
96fe367562 | ||
|
|
9566ffa384 | ||
|
|
f5835e1e72 | ||
|
|
fe65716706 | ||
|
|
873735900f | ||
|
|
28c166146e | ||
|
|
c6affc3108 | ||
|
|
59b49fd0df | ||
|
|
6ec003f899 | ||
|
|
95f2a9ad30 | ||
|
|
7317e8533d | ||
|
|
f80c20d70c | ||
|
|
01fc13c3e0 | ||
|
|
1edfecae42 | ||
|
|
9dab9a4632 | ||
|
|
98a824bfdc | ||
|
|
8fa9351ef1 | ||
|
|
64b2197844 | ||
|
|
26f1f80be7 | ||
|
|
2e4c2a6817 | ||
|
|
9498335e22 | ||
|
|
26f40110d0 | ||
|
|
9375d482b0 | ||
|
|
de9180a124 | ||
|
|
ba480d2cb7 | ||
|
|
5014748ee1 | ||
|
|
4e25ae5539 | ||
|
|
a2b802a5de | ||
|
|
9739b27718 | ||
|
|
bdf6476689 | ||
|
|
b8b627be1a | ||
|
|
03cad668aa | ||
|
|
2e0ec9aa38 | ||
|
|
4b02aae889 | ||
|
|
c91ba0b1b3 | ||
|
|
6b7f8b04e6 | ||
|
|
5472baab51 | ||
|
|
d5b6859bf8 | ||
|
|
8e23847c79 | ||
|
|
0c1579bae7 | ||
|
|
3dc50fff95 | ||
|
|
b8273f50c2 | ||
|
|
dbea28e9c6 | ||
|
|
a1b11ab039 | ||
|
|
1841b0dea6 | ||
|
|
b311bf2770 | ||
|
|
df13cef760 | ||
|
|
76ac264b25 | ||
|
|
93bc94add5 | ||
|
|
79325b8c61 | ||
|
|
58c1eb7004 | ||
|
|
466f292feb | ||
|
|
4de25a0d4a | ||
|
|
43d6bc0d82 | ||
|
|
b53d7c9ecc | ||
|
|
116d50a75a | ||
|
|
e1fb365096 | ||
|
|
03617dacfc | ||
|
|
e6b33f1bc9 | ||
|
|
d9e6a554f6 | ||
|
|
7ef37fe848 | ||
|
|
dd64617cbd | ||
|
|
a227c01a7f | ||
|
|
da53609385 | ||
|
|
e94a8fb2c3 | ||
|
|
d87542ab78 | ||
|
|
945ffb2fb3 | ||
|
|
da808ba25e | ||
|
|
e4b4c7ba39 | ||
|
|
43ead4bb0f | ||
|
|
c4a2f8af39 | ||
|
|
fd281a50b6 | ||
|
|
f8ef4fe6c9 | ||
|
|
faef0ae246 | ||
|
|
117932e272 | ||
|
|
4297708d3e | ||
|
|
2d00d8cb3e | ||
|
|
0190788658 | ||
|
|
b46d4a7166 | ||
|
|
994d529f59 | ||
|
|
7f347ae186 | ||
|
|
4a4c9f9ccf | ||
|
|
cd4ce186ca | ||
|
|
ca5403f97b | ||
|
|
7bb76f62a5 | ||
|
|
dcdbe44648 | ||
|
|
f91005f0ba | ||
|
|
5baea978ab | ||
|
|
9cc1f96eea | ||
|
|
2a65c4b5ca | ||
|
|
e1ef6615cc | ||
|
|
d607c9c821 | ||
|
|
f6b2186824 | ||
|
|
a3fc53059c | ||
|
|
6afe123947 | ||
|
|
7e9c6bb338 | ||
|
|
89a2768fc9 | ||
|
|
9ff3bdf302 | ||
|
|
2c48e89435 | ||
|
|
6ccb68aeb3 | ||
|
|
032243de0a | ||
|
|
5b1b9ec222 | ||
|
|
375c6b23a5 | ||
|
|
b378dff0dc | ||
|
|
23f2d287d6 | ||
|
|
3cd73e54a1 | ||
|
|
bf5d875079 | ||
|
|
aef884523b | ||
|
|
2a59f38faf | ||
|
|
3a0e60c772 | ||
|
|
fb657d41db | ||
|
|
ac7f389563 | ||
|
|
237e82eceb | ||
|
|
8fab4304a4 | ||
|
|
971c9e9147 | ||
|
|
cacbca5a07 | ||
|
|
e4dbd8adfb | ||
|
|
19f77c89e7 | ||
|
|
ce1b922f71 | ||
|
|
81e967864d | ||
|
|
29f55104bc | ||
|
|
ff2e2deb20 | ||
|
|
3fa55cb41b | ||
|
|
aa5d97a0d5 | ||
|
|
89c4f15ae8 | ||
|
|
1351bfc1cf | ||
|
|
32cc76f043 | ||
|
|
968cb2091d | ||
|
|
8986c1037b | ||
|
|
87b4a950f1 | ||
|
|
9f61c7338d | ||
|
|
ffc81dad69 | ||
|
|
7eba46b5cb | ||
|
|
aa177666a5 | ||
|
|
7de7b42fd2 | ||
|
|
03d1fd8019 | ||
|
|
485ec5fe2d | ||
|
|
59bfbd9811 | ||
|
|
1e485b37f8 | ||
|
|
4adf94f24b | ||
|
|
1a0fab36d3 | ||
|
|
a1ef3b5a8d | ||
|
|
0c47ee7119 | ||
|
|
e51cf6e482 | ||
|
|
24d5de813d | ||
|
|
9921b2a355 | ||
|
|
faa378c75e | ||
|
|
26199100dc | ||
|
|
bee854f059 | ||
|
|
73c1ebf3ed | ||
|
|
397dd17429 | ||
|
|
73d18dad92 | ||
|
|
94d63a3fdb | ||
|
|
120d89e8be | ||
|
|
eb5534c61c | ||
|
|
99d217e8f1 | ||
|
|
f7e3f8ae2a | ||
|
|
2cebd2d73d | ||
|
|
18df322c41 | ||
|
|
fc80f50560 | ||
|
|
4870ff9e7a | ||
|
|
58337bd38c | ||
|
|
e6d1e0933a | ||
|
|
68e3f25ba4 | ||
|
|
c981426db6 | ||
|
|
916b16b12c | ||
|
|
a178d230de | ||
|
|
fffb04728a | ||
|
|
658361383a | ||
|
|
3330026de6 | ||
|
|
25fe426720 | ||
|
|
8d53b5b8c0 | ||
|
|
0774735f0f | ||
|
|
a5540b94d5 | ||
|
|
c69d59858d | ||
|
|
962b1149ad | ||
|
|
a8edee0354 | ||
|
|
3627dde64d | ||
|
|
80ec0e42c2 |
@@ -1 +1,15 @@
|
|||||||
|
.dockerignore
|
||||||
.env
|
.env
|
||||||
|
.env.sample
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
Dockerfile*
|
||||||
|
README.md
|
||||||
|
|
||||||
|
test
|
||||||
|
|
||||||
|
venv
|
||||||
60
.env.sample
60
.env.sample
@@ -9,17 +9,39 @@ DEBUG = "False"
|
|||||||
## Debugging level, "info" is default, "debug" is more verbose
|
## Debugging level, "info" is default, "debug" is more verbose
|
||||||
DEBUG_LEVEL = "info"
|
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
|
## 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"
|
||||||
|
|
||||||
|
## Mark file where all shows/movies that have been marked as played will be written to
|
||||||
|
MARK_FILE = "mark.log"
|
||||||
|
|
||||||
|
## Timeout for requests for jellyfin
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
## Generate guids
|
||||||
|
## Generating guids is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the location only, useful when using same files on multiple servers
|
||||||
|
GENERATE_GUIDS = "True"
|
||||||
|
|
||||||
|
## Generate locations
|
||||||
|
## Generating locations is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the guid only, useful when using different files on multiple servers
|
||||||
|
GENERATE_LOCATIONS = "True"
|
||||||
|
|
||||||
|
## Max threads for processing
|
||||||
|
MAX_THREADS = 2
|
||||||
|
|
||||||
## Map usernames between servers in the event that they are different, order does not matter
|
## Map usernames between servers in the event that they are different, order does not matter
|
||||||
## Comma separated for multiple options
|
## Comma separated for multiple options
|
||||||
#USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" }
|
#USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" }
|
||||||
|
|
||||||
## Map libraries between servers in the even that they are different, order does not matter
|
## Map libraries between servers in the event that they are different, order does not matter
|
||||||
## Comma separated for multiple options
|
## Comma separated for multiple options
|
||||||
#LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" }
|
#LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" }
|
||||||
|
|
||||||
@@ -33,7 +55,6 @@ LOGFILE = "log.log"
|
|||||||
WHITELIST_USERS = "testuser1,testuser2"
|
WHITELIST_USERS = "testuser1,testuser2"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Plex
|
# Plex
|
||||||
|
|
||||||
## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers
|
## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers
|
||||||
@@ -55,13 +76,6 @@ PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2"
|
|||||||
## Set to True if running into ssl certificate errors
|
## Set to True if running into ssl certificate errors
|
||||||
SSL_BYPASS = "False"
|
SSL_BYPASS = "False"
|
||||||
|
|
||||||
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
|
||||||
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
|
||||||
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
|
|
||||||
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
|
|
||||||
SYNC_FROM_PLEX_TO_PLEX = "True"
|
|
||||||
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
|
|
||||||
|
|
||||||
|
|
||||||
# Jellyfin
|
# Jellyfin
|
||||||
|
|
||||||
@@ -72,3 +86,31 @@ JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096"
|
|||||||
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
|
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
|
||||||
## Comma separated list for multiple servers
|
## Comma separated list for multiple servers
|
||||||
JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2"
|
JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2"
|
||||||
|
|
||||||
|
|
||||||
|
# Emby
|
||||||
|
|
||||||
|
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_BASEURL = "http://localhost:8097"
|
||||||
|
|
||||||
|
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
|
||||||
|
|
||||||
|
|
||||||
|
# Syncing Options
|
||||||
|
|
||||||
|
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
||||||
|
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
||||||
|
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_EMBY_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_EMBY = "True"
|
||||||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [Luigi311]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -24,7 +24,9 @@ A clear and concise description of what you expected to happen.
|
|||||||
If applicable, add logs to help explain your problem ideally with DEBUG set to true, be sure to remove sensitive information
|
If applicable, add logs to help explain your problem ideally with DEBUG set to true, be sure to remove sensitive information
|
||||||
|
|
||||||
**Type:**
|
**Type:**
|
||||||
|
- [ ] Docker Compose
|
||||||
- [ ] Docker
|
- [ ] Docker
|
||||||
|
- [ ] Unraid
|
||||||
- [ ] Native
|
- [ ] Native
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
|||||||
184
.github/workflows/ci.yml
vendored
184
.github/workflows/ci.yml
vendored
@@ -1,5 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- .gitignore
|
- .gitignore
|
||||||
@@ -9,11 +10,18 @@ on:
|
|||||||
- .gitignore
|
- .gitignore
|
||||||
- "*.md"
|
- "*.md"
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: '3.13'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: pip install -r requirements.txt && pip install -r test/requirements.txt
|
run: pip install -r requirements.txt && pip install -r test/requirements.txt
|
||||||
@@ -21,60 +29,178 @@ jobs:
|
|||||||
- name: "Run tests"
|
- name: "Run tests"
|
||||||
run: pytest -vvv
|
run: pytest -vvv
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
sudo apt update && sudo apt install -y docker-compose
|
||||||
|
|
||||||
|
- name: "Checkout JellyPlex-Watched-CI"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: luigi311/JellyPlex-Watched-CI
|
||||||
|
path: JellyPlex-Watched-CI
|
||||||
|
|
||||||
|
- name: "Start containers"
|
||||||
|
run: |
|
||||||
|
JellyPlex-Watched-CI/start_containers.sh
|
||||||
|
|
||||||
|
# Wait for containers to start
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
for FOLDER in $(find "JellyPlex-Watched-CI" -type f -name "docker-compose.yml" -exec dirname {} \;); do
|
||||||
|
docker compose -f "${FOLDER}/docker-compose.yml" logs
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Test Plex"
|
||||||
|
run: |
|
||||||
|
mv test/ci_plex.env .env
|
||||||
|
python main.py
|
||||||
|
python test/validate_ci_marklog.py --plex
|
||||||
|
|
||||||
|
rm mark.log
|
||||||
|
|
||||||
|
- name: "Test Jellyfin"
|
||||||
|
run: |
|
||||||
|
mv test/ci_jellyfin.env .env
|
||||||
|
python main.py
|
||||||
|
python test/validate_ci_marklog.py --jellyfin
|
||||||
|
|
||||||
|
rm mark.log
|
||||||
|
|
||||||
|
- name: "Test Emby"
|
||||||
|
run: |
|
||||||
|
mv test/ci_emby.env .env
|
||||||
|
python main.py
|
||||||
|
python test/validate_ci_marklog.py --emby
|
||||||
|
|
||||||
|
rm mark.log
|
||||||
|
|
||||||
|
- name: "Test Guids"
|
||||||
|
run: |
|
||||||
|
mv test/ci_guids.env .env
|
||||||
|
python main.py
|
||||||
|
python test/validate_ci_marklog.py --guids
|
||||||
|
|
||||||
|
rm mark.log
|
||||||
|
|
||||||
|
- name: "Test Locations"
|
||||||
|
run: |
|
||||||
|
mv test/ci_locations.env .env
|
||||||
|
python main.py
|
||||||
|
python test/validate_ci_marklog.py --locations
|
||||||
|
|
||||||
|
rm mark.log
|
||||||
|
|
||||||
|
- name: "Test writing to the servers"
|
||||||
|
run: |
|
||||||
|
# Test writing to the servers
|
||||||
|
mv test/ci_write.env .env
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Test again to test if it can handle existing data
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
python test/validate_ci_marklog.py --write
|
||||||
|
|
||||||
|
rm mark.log
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: pytest
|
needs:
|
||||||
|
- pytest
|
||||||
|
- test
|
||||||
|
env:
|
||||||
|
DEFAULT_VARIANT: alpine
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- dockerfile: Dockerfile.alpine
|
||||||
|
variant: alpine
|
||||||
|
- dockerfile: Dockerfile.slim
|
||||||
|
variant: slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ secrets.DOCKER_USERNAME }}/jellyplex-watched,enable=${{ secrets.DOCKER_USERNAME != '' }}
|
||||||
|
# Do not push to ghcr.io on PRs due to permission issues, only push if the owner is luigi311 so it doesnt fail on forks
|
||||||
|
ghcr.io/${{ github.repository }},enable=${{ github.event_name != 'pull_request' && github.repository_owner == 'luigi311'}}
|
||||||
|
flavor: latest=false
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable=${{ matrix.variant == env.DEFAULT_VARIANT && startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
type=raw,value=latest,suffix=-${{ matrix.variant }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
|
||||||
|
type=ref,event=branch,suffix=-${{ matrix.variant }}
|
||||||
|
type=ref,event=branch,enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
|
||||||
|
|
||||||
|
type=ref,event=pr,suffix=-${{ matrix.variant }}
|
||||||
|
type=ref,event=pr,enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
|
||||||
|
|
||||||
|
type=semver,pattern={{ version }},suffix=-${{ matrix.variant }}
|
||||||
|
type=semver,pattern={{ version }},enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
|
||||||
|
|
||||||
|
type=semver,pattern={{ major }}.{{ minor }},suffix=-${{ matrix.variant }}
|
||||||
|
type=semver,pattern={{ major }}.{{ minor }},enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
|
||||||
|
|
||||||
|
type=sha,suffix=-${{ matrix.variant }}
|
||||||
|
type=sha,enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
if: "${{ env.DOCKER_USERNAME != '' }}"
|
if: "${{ env.DOCKER_USERNAME != '' }}"
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
|
||||||
images: ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched # list of Docker images to use as base name for tags
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v2
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
if: "${{ steps.docker_meta.outcome == 'success' }}"
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
if: "${{ steps.docker_meta.outcome == 'success' }}"
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
id: build
|
id: build
|
||||||
if: "${{ steps.docker_meta.outcome == 'skipped' }}"
|
if: "${{ steps.docker_meta.outputs.tags == '' }}"
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: false
|
push: false
|
||||||
tags: jellyplex-watched:action
|
tags: jellyplex-watched:action
|
||||||
|
|
||||||
- name: Build Push
|
- name: Build Push
|
||||||
id: build_push
|
id: build_push
|
||||||
if: "${{ steps.docker_meta.outcome == 'success' }}"
|
if: "${{ steps.docker_meta.outputs.tags != '' }}"
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
.env
|
**.env*
|
||||||
*.prof
|
*.prof
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|||||||
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@@ -11,6 +11,17 @@
|
|||||||
"program": "main.py",
|
"program": "main.py",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": true
|
"justMyCode": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pytest",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "pytest",
|
||||||
|
"args": [
|
||||||
|
"-vv"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
41
Dockerfile
41
Dockerfile
@@ -1,41 +0,0 @@
|
|||||||
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"]
|
|
||||||
49
Dockerfile.alpine
Normal file
49
Dockerfile.alpine
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
ENV PUID=1000
|
||||||
|
ENV PGID=1000
|
||||||
|
ENV GOSU_VERSION=1.17
|
||||||
|
|
||||||
|
RUN apk add --no-cache tini dos2unix
|
||||||
|
|
||||||
|
# Install gosu
|
||||||
|
RUN set -eux; \
|
||||||
|
\
|
||||||
|
apk add --no-cache --virtual .gosu-deps \
|
||||||
|
ca-certificates \
|
||||||
|
dpkg \
|
||||||
|
gnupg \
|
||||||
|
; \
|
||||||
|
\
|
||||||
|
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||||
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||||
|
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
|
||||||
|
\
|
||||||
|
# verify the signature
|
||||||
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
|
gpgconf --kill all; \
|
||||||
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
\
|
||||||
|
# clean up fetch dependencies
|
||||||
|
apk del --no-network .gosu-deps; \
|
||||||
|
\
|
||||||
|
chmod +x /usr/local/bin/gosu; \
|
||||||
|
# verify that the binary works
|
||||||
|
gosu --version; \
|
||||||
|
gosu nobody true
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./requirements.txt ./
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN chmod +x *.sh && \
|
||||||
|
dos2unix *.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||||
|
CMD ["python", "-u", "main.py"]
|
||||||
23
Dockerfile.slim
Normal file
23
Dockerfile.slim
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV PUID=1000
|
||||||
|
ENV PGID=1000
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install tini gosu dos2unix --yes --no-install-recommends && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./requirements.txt ./
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN chmod +x *.sh && \
|
||||||
|
dos2unix *.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/tini", "--", "/app/entrypoint.sh"]
|
||||||
|
CMD ["python", "-u", "main.py"]
|
||||||
127
README.md
127
README.md
@@ -1,92 +1,48 @@
|
|||||||
# JellyPlex-Watched
|
# JellyPlex-Watched
|
||||||
|
|
||||||
[](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&utm_medium=referral&utm_content=luigi311/JellyPlex-Watched&utm_campaign=Badge_Grade)
|
[](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&utm_medium=referral&utm_content=luigi311/JellyPlex-Watched&utm_campaign=Badge_Grade)
|
||||||
|
|
||||||
Sync watched between jellyfin and plex locally
|
Sync watched between jellyfin, plex and emby locally
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Keep in sync all your users watched history between jellyfin and plex servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the .env plex/jellyfin section separated by commas.
|
Keep in sync all your users watched history between jellyfin, plex and emby servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the .env plex/jellyfin section separated by commas.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Plex
|
||||||
|
|
||||||
|
- \[x] Match via filenames
|
||||||
|
- \[x] Match via provider ids
|
||||||
|
- \[x] Map usernames
|
||||||
|
- \[x] Use single login
|
||||||
|
- \[x] One way/multi way sync
|
||||||
|
- \[x] Sync watched
|
||||||
|
- \[x] Sync in progress
|
||||||
|
|
||||||
|
### Jellyfin
|
||||||
|
|
||||||
|
- \[x] Match via filenames
|
||||||
|
- \[x] Match via provider ids
|
||||||
|
- \[x] Map usernames
|
||||||
|
- \[x] Use single login
|
||||||
|
- \[x] One way/multi way sync
|
||||||
|
- \[x] Sync watched
|
||||||
|
- \[x] Sync in progress
|
||||||
|
|
||||||
|
### Emby
|
||||||
|
|
||||||
|
- \[x] Match via filenames
|
||||||
|
- \[x] Match via provider ids
|
||||||
|
- \[x] Map usernames
|
||||||
|
- \[x] Use single login
|
||||||
|
- \[x] One way/multi way sync
|
||||||
|
- \[x] Sync watched
|
||||||
|
- \[x] Sync in progress
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
```bash
|
Full list of configuration options can be found in the [.env.sample](.env.sample)
|
||||||
# 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
|
## Installation
|
||||||
|
|
||||||
@@ -140,9 +96,18 @@ JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2"
|
|||||||
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
|
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Troubleshooting/Issues
|
||||||
|
|
||||||
|
- Jellyfin
|
||||||
|
|
||||||
|
- Attempt to decode JSON with unexpected mimetype, make sure you enable remote access or add your docker subnet to lan networks in jellyfin settings
|
||||||
|
|
||||||
|
- Configuration
|
||||||
|
- Do not use quotes around variables in docker compose
|
||||||
|
|
||||||
## Contributing
|
## 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.
|
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.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Sync watched status between media servers locally
|
||||||
|
|
||||||
|
services:
|
||||||
|
jellyplex-watched:
|
||||||
|
image: luigi311/jellyplex-watched:latest
|
||||||
|
container_name: jellyplex-watched
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
env_file: "./.env"
|
||||||
61
entrypoint.sh
Normal file
61
entrypoint.sh
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if user is root
|
||||||
|
if [ "$(id -u)" = '0' ]; then
|
||||||
|
echo "User is root, checking if we need to create a user and group based on environment variables"
|
||||||
|
# Create group and user based on environment variables
|
||||||
|
if [ ! "$(getent group "$PGID")" ]; then
|
||||||
|
# If groupadd exists, use it
|
||||||
|
if command -v groupadd > /dev/null; then
|
||||||
|
groupadd -g "$PGID" jellyplex_watched
|
||||||
|
elif command -v addgroup > /dev/null; then
|
||||||
|
addgroup -g "$PGID" jellyplex_watched
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If user id does not exist, create the user
|
||||||
|
if [ ! "$(getent passwd "$PUID")" ]; then
|
||||||
|
if command -v useradd > /dev/null; then
|
||||||
|
useradd --no-create-home -u "$PUID" -g "$PGID" jellyplex_watched
|
||||||
|
elif command -v adduser > /dev/null; then
|
||||||
|
# Get the group name based on the PGID since adduser does not have a flag to specify the group id
|
||||||
|
# and if the group id already exists the group name will be sommething unexpected
|
||||||
|
GROUPNAME=$(getent group "$PGID" | cut -d: -f1)
|
||||||
|
|
||||||
|
# Use alpine busybox adduser syntax
|
||||||
|
adduser -D -H -u "$PUID" -G "$GROUPNAME" jellyplex_watched
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# If user is not root, set the PUID and PGID to the current user
|
||||||
|
PUID=$(id -u)
|
||||||
|
PGID=$(id -g)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get directory of log and mark file to create base folder if it doesnt exist
|
||||||
|
LOG_DIR=$(dirname "$LOG_FILE")
|
||||||
|
# If LOG_DIR is set, create the directory
|
||||||
|
if [ -n "$LOG_DIR" ]; then
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
MARK_DIR=$(dirname "$MARK_FILE")
|
||||||
|
if [ -n "$MARK_DIR" ]; then
|
||||||
|
mkdir -p "$MARK_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting JellyPlex-Watched with UID: $PUID and GID: $PGID"
|
||||||
|
|
||||||
|
# If root run as the created user
|
||||||
|
if [ "$(id -u)" = '0' ]; then
|
||||||
|
chown -R "$PUID:$PGID" "$LOG_DIR"
|
||||||
|
chown -R "$PUID:$PGID" "$MARK_DIR"
|
||||||
|
|
||||||
|
# Run the application as the created user
|
||||||
|
exec gosu "$PUID:$PGID" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the application as the current user
|
||||||
|
exec "$@"
|
||||||
6
main.py
6
main.py
@@ -1,9 +1,9 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Check python version 3.6 or higher
|
# Check python version 3.9 or higher
|
||||||
if not (3, 6) <= tuple(map(int, sys.version_info[:2])):
|
if not (3, 9) <= tuple(map(int, sys.version_info[:2])):
|
||||||
print("This script requires Python 3.6 or higher")
|
print("This script requires Python 3.9 or higher")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from src.main import main
|
from src.main import main
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@@ -11,18 +11,20 @@ def setup_black_white_lists(
|
|||||||
library_mapping=None,
|
library_mapping=None,
|
||||||
user_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,
|
||||||
blacklist_library_type,
|
blacklist_library_type,
|
||||||
blacklist_users,
|
blacklist_users,
|
||||||
|
"Black",
|
||||||
library_mapping,
|
library_mapping,
|
||||||
user_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,
|
||||||
whitelist_library_type,
|
whitelist_library_type,
|
||||||
whitelist_users,
|
whitelist_users,
|
||||||
|
"White",
|
||||||
library_mapping,
|
library_mapping,
|
||||||
user_mapping,
|
user_mapping,
|
||||||
)
|
)
|
||||||
@@ -37,103 +39,54 @@ def setup_black_white_lists(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_black_lists(
|
def setup_x_lists(
|
||||||
blacklist_library,
|
xlist_library,
|
||||||
blacklist_library_type,
|
xlist_library_type,
|
||||||
blacklist_users,
|
xlist_users,
|
||||||
|
xlist_type,
|
||||||
library_mapping=None,
|
library_mapping=None,
|
||||||
user_mapping=None,
|
user_mapping=None,
|
||||||
):
|
):
|
||||||
if blacklist_library:
|
if xlist_library:
|
||||||
if len(blacklist_library) > 0:
|
if len(xlist_library) > 0:
|
||||||
blacklist_library = blacklist_library.split(",")
|
xlist_library = xlist_library.split(",")
|
||||||
blacklist_library = [x.strip() for x in blacklist_library]
|
xlist_library = [x.strip() for x in xlist_library]
|
||||||
if library_mapping:
|
if library_mapping:
|
||||||
temp_library = []
|
temp_library = []
|
||||||
for library in blacklist_library:
|
for library in xlist_library:
|
||||||
library_other = search_mapping(library_mapping, library)
|
library_other = search_mapping(library_mapping, library)
|
||||||
if library_other:
|
if library_other:
|
||||||
temp_library.append(library_other)
|
temp_library.append(library_other)
|
||||||
|
|
||||||
blacklist_library = blacklist_library + temp_library
|
xlist_library = xlist_library + temp_library
|
||||||
else:
|
else:
|
||||||
blacklist_library = []
|
xlist_library = []
|
||||||
logger(f"Blacklist Library: {blacklist_library}", 1)
|
logger(f"{xlist_type}list Library: {xlist_library}", 1)
|
||||||
|
|
||||||
if blacklist_library_type:
|
if xlist_library_type:
|
||||||
if len(blacklist_library_type) > 0:
|
if len(xlist_library_type) > 0:
|
||||||
blacklist_library_type = blacklist_library_type.split(",")
|
xlist_library_type = xlist_library_type.split(",")
|
||||||
blacklist_library_type = [x.lower().strip() for x in blacklist_library_type]
|
xlist_library_type = [x.lower().strip() for x in xlist_library_type]
|
||||||
else:
|
else:
|
||||||
blacklist_library_type = []
|
xlist_library_type = []
|
||||||
logger(f"Blacklist Library Type: {blacklist_library_type}", 1)
|
logger(f"{xlist_type}list Library Type: {xlist_library_type}", 1)
|
||||||
|
|
||||||
if blacklist_users:
|
if xlist_users:
|
||||||
if len(blacklist_users) > 0:
|
if len(xlist_users) > 0:
|
||||||
blacklist_users = blacklist_users.split(",")
|
xlist_users = xlist_users.split(",")
|
||||||
blacklist_users = [x.lower().strip() for x in blacklist_users]
|
xlist_users = [x.lower().strip() for x in xlist_users]
|
||||||
if user_mapping:
|
if user_mapping:
|
||||||
temp_users = []
|
temp_users = []
|
||||||
for user in blacklist_users:
|
for user in xlist_users:
|
||||||
user_other = search_mapping(user_mapping, user)
|
user_other = search_mapping(user_mapping, user)
|
||||||
if user_other:
|
if user_other:
|
||||||
temp_users.append(user_other)
|
temp_users.append(user_other)
|
||||||
|
|
||||||
blacklist_users = blacklist_users + temp_users
|
xlist_users = xlist_users + temp_users
|
||||||
else:
|
else:
|
||||||
blacklist_users = []
|
xlist_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:
|
else:
|
||||||
whitelist_library = []
|
xlist_users = []
|
||||||
logger(f"Whitelist Library: {whitelist_library}", 1)
|
logger(f"{xlist_type}list Users: {xlist_users}", 1)
|
||||||
|
|
||||||
if whitelist_library_type:
|
return xlist_library, xlist_library_type, xlist_users
|
||||||
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
|
|
||||||
else:
|
|
||||||
whitelist_users = []
|
|
||||||
else:
|
|
||||||
whitelist_users = []
|
|
||||||
logger(f"Whitelist Users: {whitelist_users}", 1)
|
|
||||||
|
|
||||||
return whitelist_library, whitelist_library_type, whitelist_users
|
|
||||||
|
|||||||
139
src/connection.py
Normal file
139
src/connection.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from src.functions import logger, str_to_bool
|
||||||
|
from src.plex import Plex
|
||||||
|
from src.jellyfin import Jellyfin
|
||||||
|
from src.emby import Emby
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
|
def jellyfin_emby_server_connection(server_baseurl, server_token, server_type):
|
||||||
|
servers = []
|
||||||
|
|
||||||
|
server_baseurl = server_baseurl.split(",")
|
||||||
|
server_token = server_token.split(",")
|
||||||
|
|
||||||
|
if len(server_baseurl) != len(server_token):
|
||||||
|
raise Exception(
|
||||||
|
f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries"
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, baseurl in enumerate(server_baseurl):
|
||||||
|
baseurl = baseurl.strip()
|
||||||
|
if baseurl[-1] == "/":
|
||||||
|
baseurl = baseurl[:-1]
|
||||||
|
|
||||||
|
if server_type == "jellyfin":
|
||||||
|
server = Jellyfin(baseurl=baseurl, token=server_token[i].strip())
|
||||||
|
servers.append(
|
||||||
|
(
|
||||||
|
"jellyfin",
|
||||||
|
server,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif server_type == "emby":
|
||||||
|
server = Emby(baseurl=baseurl, token=server_token[i].strip())
|
||||||
|
servers.append(
|
||||||
|
(
|
||||||
|
"emby",
|
||||||
|
server,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception("Unknown server type")
|
||||||
|
|
||||||
|
logger(f"{server_type} Server {i} info: {server.info()}", 3)
|
||||||
|
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
def generate_server_connections():
|
||||||
|
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):
|
||||||
|
server = Plex(
|
||||||
|
baseurl=url.strip(),
|
||||||
|
token=plex_token[i].strip(),
|
||||||
|
username=None,
|
||||||
|
password=None,
|
||||||
|
servername=None,
|
||||||
|
ssl_bypass=ssl_bypass,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger(f"Plex Server {i} info: {server.info()}", 3)
|
||||||
|
|
||||||
|
servers.append(
|
||||||
|
(
|
||||||
|
"plex",
|
||||||
|
server,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
server = Plex(
|
||||||
|
baseurl=None,
|
||||||
|
token=None,
|
||||||
|
username=username.strip(),
|
||||||
|
password=plex_password[i].strip(),
|
||||||
|
servername=plex_servername[i].strip(),
|
||||||
|
ssl_bypass=ssl_bypass,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger(f"Plex Server {i} info: {server.info()}", 3)
|
||||||
|
servers.append(
|
||||||
|
(
|
||||||
|
"plex",
|
||||||
|
server,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None)
|
||||||
|
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
|
||||||
|
|
||||||
|
if jellyfin_baseurl and jellyfin_token:
|
||||||
|
servers.extend(
|
||||||
|
jellyfin_emby_server_connection(
|
||||||
|
jellyfin_baseurl, jellyfin_token, "jellyfin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
emby_baseurl = os.getenv("EMBY_BASEURL", None)
|
||||||
|
emby_token = os.getenv("EMBY_TOKEN", None)
|
||||||
|
|
||||||
|
if emby_baseurl and emby_token:
|
||||||
|
servers.extend(
|
||||||
|
jellyfin_emby_server_connection(emby_baseurl, emby_token, "emby")
|
||||||
|
)
|
||||||
|
|
||||||
|
return servers
|
||||||
25
src/emby.py
Normal file
25
src/emby.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from src.jellyfin_emby import JellyfinEmby
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
|
||||||
|
class Emby(JellyfinEmby):
|
||||||
|
def __init__(self, baseurl, token):
|
||||||
|
authorization = (
|
||||||
|
"Emby , "
|
||||||
|
'Client="JellyPlex-Watched", '
|
||||||
|
'Device="script", '
|
||||||
|
'DeviceId="script", '
|
||||||
|
'Version="6.0.2"'
|
||||||
|
)
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Emby-Token": token,
|
||||||
|
"X-Emby-Authorization": authorization,
|
||||||
|
}
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
server_type="Emby", baseurl=baseurl, token=token, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_partial_update_supported(self, server_version):
|
||||||
|
return server_version > version.parse("4.4")
|
||||||
@@ -4,7 +4,8 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
logfile = os.getenv("LOGFILE", "log.log")
|
log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
|
||||||
|
mark_file = os.getenv("MARK_FILE", os.getenv("MARKFILE", "mark.log"))
|
||||||
|
|
||||||
|
|
||||||
def logger(message: str, log_type=0):
|
def logger(message: str, log_type=0):
|
||||||
@@ -22,12 +23,40 @@ def logger(message: str, log_type=0):
|
|||||||
output = f"[DEBUG]: {output}"
|
output = f"[DEBUG]: {output}"
|
||||||
elif log_type == 4:
|
elif log_type == 4:
|
||||||
output = f"[WARNING]: {output}"
|
output = f"[WARNING]: {output}"
|
||||||
|
elif log_type == 5:
|
||||||
|
output = f"[MARK]: {output}"
|
||||||
|
elif log_type == 6:
|
||||||
|
output = f"[DRYRUN]: {output}"
|
||||||
else:
|
else:
|
||||||
output = None
|
output = None
|
||||||
|
|
||||||
if output is not None:
|
if output is not None:
|
||||||
print(output)
|
print(output)
|
||||||
file = open(logfile, "a", encoding="utf-8")
|
with open(f"{log_file}", "a", encoding="utf-8") as file:
|
||||||
|
file.write(output + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def log_marked(
|
||||||
|
server_type: str,
|
||||||
|
server_name: str,
|
||||||
|
username: str,
|
||||||
|
library: str,
|
||||||
|
movie_show: str,
|
||||||
|
episode: str = None,
|
||||||
|
duration=None,
|
||||||
|
):
|
||||||
|
if mark_file is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
output = f"{server_type}/{server_name}/{username}/{library}/{movie_show}"
|
||||||
|
|
||||||
|
if episode:
|
||||||
|
output += f"/{episode}"
|
||||||
|
|
||||||
|
if duration:
|
||||||
|
output += f"/{duration}"
|
||||||
|
|
||||||
|
with open(f"{mark_file}", "a", encoding="utf-8") as file:
|
||||||
file.write(output + "\n")
|
file.write(output + "\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +68,21 @@ def str_to_bool(value: any) -> bool:
|
|||||||
return str(value).lower() in ("y", "yes", "t", "true", "on", "1")
|
return str(value).lower() in ("y", "yes", "t", "true", "on", "1")
|
||||||
|
|
||||||
|
|
||||||
|
# Search for nested element in list
|
||||||
|
def contains_nested(element, lst):
|
||||||
|
if lst is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for i, item in enumerate(lst):
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
if element in item:
|
||||||
|
return i
|
||||||
|
elif element == item:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Get mapped value
|
# Get mapped value
|
||||||
def search_mapping(dictionary: dict, key_value: str):
|
def search_mapping(dictionary: dict, key_value: str):
|
||||||
if key_value in dictionary.keys():
|
if key_value in dictionary.keys():
|
||||||
@@ -55,12 +99,39 @@ def search_mapping(dictionary: dict, key_value: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def future_thread_executor(args: list, workers: int = -1):
|
# Return list of objects that exist in both lists including mappings
|
||||||
|
def match_list(list1, list2, list_mapping=None):
|
||||||
|
output = []
|
||||||
|
for element in list1:
|
||||||
|
if element in list2:
|
||||||
|
output.append(element)
|
||||||
|
elif list_mapping:
|
||||||
|
element_other = search_mapping(list_mapping, element)
|
||||||
|
if element_other in list2:
|
||||||
|
output.append(element)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def future_thread_executor(
|
||||||
|
args: list, threads: int = None, override_threads: bool = False
|
||||||
|
):
|
||||||
futures_list = []
|
futures_list = []
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
if workers == -1:
|
workers = min(int(os.getenv("MAX_THREADS", 32)), os.cpu_count() * 2)
|
||||||
workers = min(32, os.cpu_count() * 2)
|
if threads:
|
||||||
|
workers = min(threads, workers)
|
||||||
|
|
||||||
|
if override_threads:
|
||||||
|
workers = threads
|
||||||
|
|
||||||
|
# If only one worker, run in main thread to avoid overhead
|
||||||
|
if workers == 1:
|
||||||
|
results = []
|
||||||
|
for arg in args:
|
||||||
|
results.append(arg[0](*arg[1:]))
|
||||||
|
return results
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
for arg in args:
|
for arg in args:
|
||||||
|
|||||||
753
src/jellyfin.py
753
src/jellyfin.py
@@ -1,752 +1,25 @@
|
|||||||
import asyncio, aiohttp, traceback
|
from src.jellyfin_emby import JellyfinEmby
|
||||||
|
from packaging import version
|
||||||
from src.functions import (
|
|
||||||
logger,
|
|
||||||
search_mapping,
|
|
||||||
)
|
|
||||||
from src.library import (
|
|
||||||
check_skip_logic,
|
|
||||||
generate_library_guids_dict,
|
|
||||||
)
|
|
||||||
from src.watched import (
|
|
||||||
combine_watched_dicts,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Jellyfin:
|
class Jellyfin(JellyfinEmby):
|
||||||
def __init__(self, baseurl, token):
|
def __init__(self, baseurl, token):
|
||||||
self.baseurl = baseurl
|
|
||||||
self.token = token
|
|
||||||
|
|
||||||
if not self.baseurl:
|
|
||||||
raise Exception("Jellyfin baseurl not set")
|
|
||||||
|
|
||||||
if not self.token:
|
|
||||||
raise Exception("Jellyfin token not set")
|
|
||||||
|
|
||||||
self.users = asyncio.run(self.get_users())
|
|
||||||
|
|
||||||
async def query(self, query, query_type, session, identifiers=None):
|
|
||||||
try:
|
|
||||||
results = None
|
|
||||||
headers = {"Accept": "application/json", "X-Emby-Token": self.token}
|
|
||||||
authorization = (
|
authorization = (
|
||||||
"MediaBrowser , "
|
"MediaBrowser , "
|
||||||
'Client="other", '
|
'Client="JellyPlex-Watched", '
|
||||||
'Device="script", '
|
'Device="script", '
|
||||||
'DeviceId="script", '
|
'DeviceId="script", '
|
||||||
'Version="0.0.0"'
|
'Version="6.0.2", '
|
||||||
|
f'Token="{token}"'
|
||||||
)
|
)
|
||||||
headers["X-Emby-Authorization"] = authorization
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
if query_type == "get":
|
"Authorization": authorization,
|
||||||
async with session.get(
|
|
||||||
self.baseurl + query, headers=headers
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
raise Exception(
|
|
||||||
f"Query failed with status {response.status} {response.reason}"
|
|
||||||
)
|
|
||||||
results = await response.json()
|
|
||||||
|
|
||||||
elif query_type == "post":
|
|
||||||
async with session.post(
|
|
||||||
self.baseurl + query, headers=headers
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
raise Exception(
|
|
||||||
f"Query failed with status {response.status} {response.reason}"
|
|
||||||
)
|
|
||||||
results = await response.json()
|
|
||||||
|
|
||||||
if not isinstance(results, list) and not isinstance(results, dict):
|
|
||||||
raise Exception("Query result is not of type list or dict")
|
|
||||||
|
|
||||||
# append identifiers to results
|
|
||||||
if identifiers:
|
|
||||||
results["Identifiers"] = identifiers
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger(f"Jellyfin: Query {query_type} {query}\nResults {results}\n{e}", 2)
|
|
||||||
raise Exception(e)
|
|
||||||
|
|
||||||
async def get_users(self):
|
|
||||||
try:
|
|
||||||
users = {}
|
|
||||||
|
|
||||||
query_string = "/Users"
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
response = await self.query(query_string, "get", session)
|
|
||||||
|
|
||||||
# If response is not empty
|
|
||||||
if response:
|
|
||||||
for user in response:
|
|
||||||
users[user["Name"]] = user["Id"]
|
|
||||||
|
|
||||||
return users
|
|
||||||
except Exception as e:
|
|
||||||
logger(f"Jellyfin: Get users failed {e}", 2)
|
|
||||||
raise Exception(e)
|
|
||||||
|
|
||||||
async def get_user_library_watched(
|
|
||||||
self, user_name, user_id, library_type, library_id, library_title
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
user_name = user_name.lower()
|
|
||||||
user_watched = {}
|
|
||||||
user_watched[user_name] = {}
|
|
||||||
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Generating watched for {user_name} in library {library_title}",
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
# Movies
|
|
||||||
if library_type == "Movie":
|
|
||||||
user_watched[user_name][library_title] = []
|
|
||||||
watched = await self.query(
|
|
||||||
f"/Users/{user_id}/Items"
|
|
||||||
+ f"?ParentId={library_id}&Filters=IsPlayed&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 {}
|
|
||||||
):
|
|
||||||
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"]}
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Append the movie dictionary to the list for the given user and library
|
|
||||||
user_watched[user_name][library_title].append(movie_guids)
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Added {movie_guids} to {user_name} watched list",
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TV Shows
|
|
||||||
if library_type in ["Series", "Episode"]:
|
|
||||||
# Initialize an empty dictionary for the given user and library
|
|
||||||
user_watched[user_name][library_title] = {}
|
|
||||||
|
|
||||||
# Retrieve a list of watched TV shows
|
|
||||||
watched_shows = await self.query(
|
|
||||||
f"/Users/{user_id}/Items"
|
|
||||||
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
|
|
||||||
"get",
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter the list of shows to only include those that have been partially or fully watched
|
|
||||||
watched_shows_filtered = []
|
|
||||||
for show in watched_shows["Items"]:
|
|
||||||
if "PlayedPercentage" in show["UserData"]:
|
|
||||||
if show["UserData"]["PlayedPercentage"] > 0:
|
|
||||||
watched_shows_filtered.append(show)
|
|
||||||
|
|
||||||
# Create a list of tasks to retrieve the seasons of each watched show
|
|
||||||
seasons_tasks = []
|
|
||||||
for show in watched_shows_filtered:
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Adding {show['Name']} to {user_name} watched list",
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
show_guids = {
|
|
||||||
k.lower(): v for k, v in show["ProviderIds"].items()
|
|
||||||
}
|
|
||||||
show_guids["title"] = show["Name"]
|
|
||||||
show_guids["locations"] = tuple([show["Path"].split("/")[-1]])
|
|
||||||
show_guids = frozenset(show_guids.items())
|
|
||||||
show_identifiers = {
|
|
||||||
"show_guids": show_guids,
|
|
||||||
"show_id": show["Id"],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
season_task = asyncio.ensure_future(
|
super().__init__(
|
||||||
self.query(
|
server_type="Jellyfin", baseurl=baseurl, token=token, headers=headers
|
||||||
f"/Shows/{show['Id']}/Seasons"
|
|
||||||
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount",
|
|
||||||
"get",
|
|
||||||
session,
|
|
||||||
frozenset(show_identifiers.items()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
seasons_tasks.append(season_task)
|
|
||||||
|
|
||||||
# Retrieve the seasons for each watched show
|
|
||||||
seasons_watched = await asyncio.gather(*seasons_tasks)
|
|
||||||
|
|
||||||
# Filter the list of seasons to only include those that have been partially or fully watched
|
|
||||||
seasons_watched_filtered = []
|
|
||||||
for seasons in seasons_watched:
|
|
||||||
seasons_watched_filtered_dict = {}
|
|
||||||
seasons_watched_filtered_dict["Identifiers"] = seasons[
|
|
||||||
"Identifiers"
|
|
||||||
]
|
|
||||||
seasons_watched_filtered_dict["Items"] = []
|
|
||||||
for season in seasons["Items"]:
|
|
||||||
if "PlayedPercentage" in season["UserData"]:
|
|
||||||
if season["UserData"]["PlayedPercentage"] > 0:
|
|
||||||
seasons_watched_filtered_dict["Items"].append(
|
|
||||||
season
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if seasons_watched_filtered_dict["Items"]:
|
def is_partial_update_supported(self, server_version):
|
||||||
seasons_watched_filtered.append(
|
return server_version >= version.parse("10.9.0")
|
||||||
seasons_watched_filtered_dict
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a list of tasks to retrieve the episodes of each watched season
|
|
||||||
episodes_tasks = []
|
|
||||||
for seasons in seasons_watched_filtered:
|
|
||||||
if len(seasons["Items"]) > 0:
|
|
||||||
for season in seasons["Items"]:
|
|
||||||
season_identifiers = dict(seasons["Identifiers"])
|
|
||||||
season_identifiers["season_id"] = season["Id"]
|
|
||||||
season_identifiers["season_name"] = season["Name"]
|
|
||||||
episode_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",
|
|
||||||
"get",
|
|
||||||
session,
|
|
||||||
frozenset(season_identifiers.items()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
episodes_tasks.append(episode_task)
|
|
||||||
|
|
||||||
# Retrieve the episodes for each watched season
|
|
||||||
watched_episodes = await asyncio.gather(*episodes_tasks)
|
|
||||||
|
|
||||||
# Iterate through the watched episodes
|
|
||||||
for episodes in watched_episodes:
|
|
||||||
# If the season has any watched episodes
|
|
||||||
if len(episodes["Items"]) > 0:
|
|
||||||
# Create a dictionary for the season with its identifier and episodes
|
|
||||||
season_dict = {}
|
|
||||||
season_dict["Identifiers"] = dict(episodes["Identifiers"])
|
|
||||||
season_dict["Episodes"] = []
|
|
||||||
for episode in episodes["Items"]:
|
|
||||||
if (
|
|
||||||
episode["UserData"]["Played"] is True
|
|
||||||
and "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)
|
|
||||||
# Add the season dictionary to the show's list of seasons
|
|
||||||
if (
|
|
||||||
season_dict["Identifiers"]["show_guids"]
|
|
||||||
not in user_watched[user_name][library_title]
|
|
||||||
):
|
|
||||||
user_watched[user_name][library_title][
|
|
||||||
season_dict["Identifiers"]["show_guids"]
|
|
||||||
] = {}
|
|
||||||
|
|
||||||
if (
|
|
||||||
season_dict["Identifiers"]["season_name"]
|
|
||||||
not in user_watched[user_name][library_title][
|
|
||||||
season_dict["Identifiers"]["show_guids"]
|
|
||||||
]
|
|
||||||
):
|
|
||||||
user_watched[user_name][library_title][
|
|
||||||
season_dict["Identifiers"]["show_guids"]
|
|
||||||
][season_dict["Identifiers"]["season_name"]] = []
|
|
||||||
|
|
||||||
user_watched[user_name][library_title][
|
|
||||||
season_dict["Identifiers"]["show_guids"]
|
|
||||||
][season_dict["Identifiers"]["season_name"]] = season_dict[
|
|
||||||
"Episodes"
|
|
||||||
]
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Added {season_dict['Episodes']} to {user_name} {season_dict['Identifiers']['show_guids']} watched list",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Got watched for {user_name} in library {library_title}", 1
|
|
||||||
)
|
|
||||||
if library_title in user_watched[user_name]:
|
|
||||||
logger(f"Jellyfin: {user_watched[user_name][library_title]}", 3)
|
|
||||||
|
|
||||||
return user_watched
|
|
||||||
except Exception as e:
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger(traceback.format_exc(), 2)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def get_users_watched(
|
|
||||||
self,
|
|
||||||
user_name,
|
|
||||||
user_id,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
# Get all libraries
|
|
||||||
user_name = user_name.lower()
|
|
||||||
tasks_watched = []
|
|
||||||
|
|
||||||
tasks_libraries = []
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
libraries = await self.query(f"/Users/{user_id}/Views", "get", session)
|
|
||||||
for library in libraries["Items"]:
|
|
||||||
library_id = library["Id"]
|
|
||||||
library_title = library["Name"]
|
|
||||||
identifiers = {
|
|
||||||
"library_id": library_id,
|
|
||||||
"library_title": library_title,
|
|
||||||
}
|
|
||||||
task = asyncio.ensure_future(
|
|
||||||
self.query(
|
|
||||||
f"/Users/{user_id}/Items"
|
|
||||||
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
|
|
||||||
"get",
|
|
||||||
session,
|
|
||||||
identifiers=identifiers,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tasks_libraries.append(task)
|
|
||||||
|
|
||||||
libraries = await asyncio.gather(
|
|
||||||
*tasks_libraries, return_exceptions=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for watched in libraries:
|
|
||||||
if len(watched["Items"]) == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
library_id = watched["Identifiers"]["library_id"]
|
|
||||||
library_title = watched["Identifiers"]["library_title"]
|
|
||||||
# Get all library types excluding "Folder"
|
|
||||||
types = set(
|
|
||||||
[
|
|
||||||
x["Type"]
|
|
||||||
for x in watched["Items"]
|
|
||||||
if x["Type"] in ["Movie", "Series", "Episode"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
skip_reason = check_skip_logic(
|
|
||||||
library_title,
|
|
||||||
types,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
)
|
|
||||||
|
|
||||||
if skip_reason:
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Skipping library {library_title}: {skip_reason}",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# If there are multiple types in library raise error
|
|
||||||
if types is None or len(types) < 1:
|
|
||||||
all_types = set([x["Type"] for x in watched["Items"]])
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Skipping Library {library_title} found types: {types}, all types: {all_types}",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
for library_type in types:
|
|
||||||
# Get watched for user
|
|
||||||
task = asyncio.ensure_future(
|
|
||||||
self.get_user_library_watched(
|
|
||||||
user_name,
|
|
||||||
user_id,
|
|
||||||
library_type,
|
|
||||||
library_id,
|
|
||||||
library_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tasks_watched.append(task)
|
|
||||||
|
|
||||||
watched = await asyncio.gather(*tasks_watched, return_exceptions=True)
|
|
||||||
|
|
||||||
return watched
|
|
||||||
except Exception as e:
|
|
||||||
logger(f"Jellyfin: Failed to get users watched, Error: {e}", 2)
|
|
||||||
raise Exception(e)
|
|
||||||
|
|
||||||
async def get_watched(
|
|
||||||
self,
|
|
||||||
users,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping=None,
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
users_watched = {}
|
|
||||||
watched = []
|
|
||||||
|
|
||||||
for user_name, user_id in users.items():
|
|
||||||
watched.append(
|
|
||||||
self.get_users_watched(
|
|
||||||
user_name,
|
|
||||||
user_id,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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():
|
|
||||||
if user not in users_watched:
|
|
||||||
users_watched[user] = {}
|
|
||||||
users_watched[user].update(user_watched_temp)
|
|
||||||
|
|
||||||
return users_watched
|
|
||||||
except Exception as e:
|
|
||||||
logger(f"Jellyfin: Failed to get watched, Error: {e}", 2)
|
|
||||||
raise Exception(e)
|
|
||||||
|
|
||||||
async def update_user_watched(
|
|
||||||
self, user_name, user_id, library, library_id, videos, dryrun
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Updating watched for {user_name} in library {library}", 1
|
|
||||||
)
|
|
||||||
(
|
|
||||||
videos_shows_ids,
|
|
||||||
videos_episodes_ids,
|
|
||||||
videos_movies_ids,
|
|
||||||
) = generate_library_guids_dict(videos)
|
|
||||||
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
if videos_movies_ids:
|
|
||||||
jellyfin_search = await self.query(
|
|
||||||
f"/Users/{user_id}/Items"
|
|
||||||
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
|
|
||||||
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie",
|
|
||||||
"get",
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
|
|
||||||
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series",
|
|
||||||
"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"
|
|
||||||
+ f"?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']}"
|
|
||||||
+ 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"Jellyfin: Skipping episode {jellyfin_episode['Name']} as it is not in mark list for {user_name}",
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Skipping show {jellyfin_show['Name']} as it is not in mark list for {user_name}",
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
|
|
||||||
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}, {e}",
|
|
||||||
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",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
logger(
|
|
||||||
f"Jellyfin: Library {library} not found in library list",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
library_id = None
|
|
||||||
for jellyfin_library in jellyfin_libraries:
|
|
||||||
if jellyfin_library["Name"] == library:
|
|
||||||
library_id = jellyfin_library["Id"]
|
|
||||||
continue
|
|
||||||
|
|
||||||
if library_id:
|
|
||||||
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, {e}", 2)
|
|
||||||
raise Exception(e)
|
|
||||||
|
|||||||
820
src/jellyfin_emby.py
Normal file
820
src/jellyfin_emby.py
Normal file
@@ -0,0 +1,820 @@
|
|||||||
|
# Functions for Jellyfin and Emby
|
||||||
|
|
||||||
|
import traceback, os
|
||||||
|
from math import floor
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import requests
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
from src.functions import (
|
||||||
|
logger,
|
||||||
|
search_mapping,
|
||||||
|
contains_nested,
|
||||||
|
log_marked,
|
||||||
|
str_to_bool,
|
||||||
|
)
|
||||||
|
from src.library import generate_library_guids_dict
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
|
||||||
|
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_guids(server_type, item):
|
||||||
|
if item.get("Name"):
|
||||||
|
guids = {"title": item.get("Name")}
|
||||||
|
else:
|
||||||
|
logger(f"{server_type}: Name not found in {item.get('Id')}", 1)
|
||||||
|
guids = {"title": None}
|
||||||
|
|
||||||
|
if "ProviderIds" in item:
|
||||||
|
guids.update({k.lower(): v for k, v in item["ProviderIds"].items()})
|
||||||
|
else:
|
||||||
|
logger(f"{server_type}: ProviderIds not found in {item.get('Name')}", 1)
|
||||||
|
|
||||||
|
if "MediaSources" in item:
|
||||||
|
guids["locations"] = tuple(
|
||||||
|
[x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(f"{server_type}: MediaSources not found in {item.get('Name')}", 1)
|
||||||
|
guids["locations"] = tuple()
|
||||||
|
|
||||||
|
if "UserData" in item:
|
||||||
|
guids["status"] = {
|
||||||
|
"completed": item["UserData"]["Played"],
|
||||||
|
# Convert ticks to milliseconds to match Plex
|
||||||
|
"time": floor(item["UserData"]["PlaybackPositionTicks"] / 10000),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger(f"{server_type}: UserData not found in {item.get('Name')}", 1)
|
||||||
|
guids["status"] = {}
|
||||||
|
|
||||||
|
return guids
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_status(server_video, videos_ids, videos):
|
||||||
|
video_status = None
|
||||||
|
|
||||||
|
if generate_locations:
|
||||||
|
if "MediaSources" in server_video:
|
||||||
|
for video_location in server_video["MediaSources"]:
|
||||||
|
if "Path" in video_location:
|
||||||
|
if (
|
||||||
|
contains_nested(
|
||||||
|
video_location["Path"].split("/")[-1],
|
||||||
|
videos_ids["locations"],
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
for video in videos:
|
||||||
|
if (
|
||||||
|
contains_nested(
|
||||||
|
video_location["Path"].split("/")[-1],
|
||||||
|
video["locations"],
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
video_status = video["status"]
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
if generate_guids:
|
||||||
|
if not video_status:
|
||||||
|
for (
|
||||||
|
video_provider_source,
|
||||||
|
video_provider_id,
|
||||||
|
) in server_video["ProviderIds"].items():
|
||||||
|
if video_provider_source.lower() in videos_ids:
|
||||||
|
if (
|
||||||
|
video_provider_id.lower()
|
||||||
|
in videos_ids[video_provider_source.lower()]
|
||||||
|
):
|
||||||
|
for video in videos:
|
||||||
|
if video_provider_id.lower() in video.get(
|
||||||
|
video_provider_source.lower(), []
|
||||||
|
):
|
||||||
|
video_status = video["status"]
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
return video_status
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinEmby:
|
||||||
|
def __init__(self, server_type, baseurl, token, headers):
|
||||||
|
if server_type not in ["Jellyfin", "Emby"]:
|
||||||
|
raise Exception(f"Server type {server_type} not supported")
|
||||||
|
self.server_type = server_type
|
||||||
|
self.baseurl = baseurl
|
||||||
|
self.token = token
|
||||||
|
self.headers = headers
|
||||||
|
self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300))
|
||||||
|
|
||||||
|
if not self.baseurl:
|
||||||
|
raise Exception(f"{self.server_type} baseurl not set")
|
||||||
|
|
||||||
|
if not self.token:
|
||||||
|
raise Exception(f"{self.server_type} token not set")
|
||||||
|
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.users = self.get_users()
|
||||||
|
self.server_name = self.info(name_only=True)
|
||||||
|
|
||||||
|
def query(self, query, query_type, identifiers=None, json=None):
|
||||||
|
try:
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if query_type == "get":
|
||||||
|
response = self.session.get(
|
||||||
|
self.baseurl + query, headers=self.headers, timeout=self.timeout
|
||||||
|
)
|
||||||
|
if response.status_code not in [200, 204]:
|
||||||
|
raise Exception(
|
||||||
|
f"Query failed with status {response.status_code} {response.reason}"
|
||||||
|
)
|
||||||
|
if response.status_code == 204:
|
||||||
|
results = None
|
||||||
|
else:
|
||||||
|
results = response.json()
|
||||||
|
|
||||||
|
elif query_type == "post":
|
||||||
|
response = self.session.post(
|
||||||
|
self.baseurl + query,
|
||||||
|
headers=self.headers,
|
||||||
|
json=json,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
if response.status_code not in [200, 204]:
|
||||||
|
raise Exception(
|
||||||
|
f"Query failed with status {response.status_code} {response.reason}"
|
||||||
|
)
|
||||||
|
if response.status_code == 204:
|
||||||
|
results = None
|
||||||
|
else:
|
||||||
|
results = response.json()
|
||||||
|
|
||||||
|
if results is not None:
|
||||||
|
if not isinstance(results, list) and not isinstance(results, dict):
|
||||||
|
raise Exception("Query result is not of type list or dict")
|
||||||
|
|
||||||
|
# append identifiers to results
|
||||||
|
if identifiers:
|
||||||
|
results["Identifiers"] = identifiers
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def info(self, name_only: bool = False) -> str:
|
||||||
|
try:
|
||||||
|
query_string = "/System/Info/Public"
|
||||||
|
|
||||||
|
response = self.query(query_string, "get")
|
||||||
|
|
||||||
|
if response:
|
||||||
|
if name_only:
|
||||||
|
return f"{response['ServerName']}"
|
||||||
|
return f"{self.server_type} {response['ServerName']}: {response['Version']}"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"{self.server_type}: Get server name failed {e}", 2)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def get_server_version(self):
|
||||||
|
try:
|
||||||
|
response = self.query("/System/Info/Public", "get")
|
||||||
|
|
||||||
|
if response:
|
||||||
|
return version.parse(response["Version"])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"{self.server_type}: Get server version failed: {e}", 2)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def get_users(self):
|
||||||
|
try:
|
||||||
|
users = {}
|
||||||
|
|
||||||
|
query_string = "/Users"
|
||||||
|
response = self.query(query_string, "get")
|
||||||
|
|
||||||
|
# If response is not empty
|
||||||
|
if response:
|
||||||
|
for user in response:
|
||||||
|
users[user["Name"]] = user["Id"]
|
||||||
|
|
||||||
|
return users
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"{self.server_type}: Get users failed {e}", 2)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
try:
|
||||||
|
libraries = {}
|
||||||
|
|
||||||
|
# Theres no way to get all libraries so individually get list of libraries from all users
|
||||||
|
users = self.get_users()
|
||||||
|
|
||||||
|
for _, user_id in users.items():
|
||||||
|
user_libraries = self.query(f"/Users/{user_id}/Views", "get")
|
||||||
|
for library in user_libraries["Items"]:
|
||||||
|
library_id = library["Id"]
|
||||||
|
library_title = library["Name"]
|
||||||
|
|
||||||
|
# Get library items to check the type
|
||||||
|
media_info = self.query(
|
||||||
|
f"/Users/{user_id}/Items"
|
||||||
|
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
|
||||||
|
"get",
|
||||||
|
)
|
||||||
|
|
||||||
|
types = set(
|
||||||
|
[
|
||||||
|
x["Type"]
|
||||||
|
for x in media_info["Items"]
|
||||||
|
if x["Type"] in ["Movie", "Series", "Episode"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
all_types = set([x["Type"] for x in media_info["Items"]])
|
||||||
|
|
||||||
|
if not types:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Skipping Library {library_title} found wanted types: {all_types}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
libraries[library_title] = str(types)
|
||||||
|
|
||||||
|
return libraries
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"{self.server_type}: Get libraries failed {e}", 2)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def get_user_library_watched(
|
||||||
|
self, user_name, user_id, library_type, library_id, library_title
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user_name = user_name.lower()
|
||||||
|
user_watched = {}
|
||||||
|
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Generating watched for {user_name} in library {library_title}",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Movies
|
||||||
|
if library_type == "Movie":
|
||||||
|
user_watched[library_title] = []
|
||||||
|
watched = self.query(
|
||||||
|
f"/Users/{user_id}/Items"
|
||||||
|
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||||
|
"get",
|
||||||
|
).get("Items", [])
|
||||||
|
|
||||||
|
in_progress = self.query(
|
||||||
|
f"/Users/{user_id}/Items"
|
||||||
|
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||||
|
"get",
|
||||||
|
).get("Items", [])
|
||||||
|
|
||||||
|
for movie in watched + in_progress:
|
||||||
|
# Skip if theres no user data which means the movie has not been watched
|
||||||
|
if "UserData" not in movie:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if theres no media tied to the movie
|
||||||
|
if "MediaSources" not in movie or movie["MediaSources"] == {}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if not watched or watched less than a minute
|
||||||
|
if (
|
||||||
|
movie["UserData"]["Played"] == True
|
||||||
|
or movie["UserData"]["PlaybackPositionTicks"] > 600000000
|
||||||
|
):
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Adding {movie.get('Name')} to {user_name} watched list",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the movie's GUIDs
|
||||||
|
movie_guids = get_guids(self.server_type, movie)
|
||||||
|
|
||||||
|
# Append the movie dictionary to the list for the given user and library
|
||||||
|
user_watched[library_title].append(movie_guids)
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Added {movie_guids} to {user_name} watched list",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TV Shows
|
||||||
|
if library_type in ["Series", "Episode"]:
|
||||||
|
# Initialize an empty dictionary for the given user and library
|
||||||
|
user_watched[library_title] = {}
|
||||||
|
|
||||||
|
# Retrieve a list of watched TV shows
|
||||||
|
watched_shows = self.query(
|
||||||
|
f"/Users/{user_id}/Items"
|
||||||
|
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
|
||||||
|
"get",
|
||||||
|
).get("Items", [])
|
||||||
|
|
||||||
|
# Filter the list of shows to only include those that have been partially or fully watched
|
||||||
|
watched_shows_filtered = []
|
||||||
|
for show in watched_shows:
|
||||||
|
if "UserData" not in show:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "PlayedPercentage" in show["UserData"]:
|
||||||
|
if show["UserData"]["PlayedPercentage"] > 0:
|
||||||
|
watched_shows_filtered.append(show)
|
||||||
|
|
||||||
|
# Retrieve the watched/partially watched list of episodes of each watched show
|
||||||
|
for show in watched_shows_filtered:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Adding {show.get('Name')} to {user_name} watched list",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()}
|
||||||
|
show_guids["title"] = show["Name"]
|
||||||
|
show_guids["locations"] = (
|
||||||
|
tuple([show["Path"].split("/")[-1]])
|
||||||
|
if "Path" in show
|
||||||
|
else tuple()
|
||||||
|
)
|
||||||
|
|
||||||
|
show_guids = frozenset(show_guids.items())
|
||||||
|
|
||||||
|
show_episodes = self.query(
|
||||||
|
f"/Shows/{show['Id']}/Episodes"
|
||||||
|
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources",
|
||||||
|
"get",
|
||||||
|
).get("Items", [])
|
||||||
|
|
||||||
|
# Iterate through the episodes
|
||||||
|
# Create a list to store the episodes
|
||||||
|
mark_episodes_list = []
|
||||||
|
for episode in show_episodes:
|
||||||
|
if "UserData" not in episode:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
"MediaSources" not in episode
|
||||||
|
or episode["MediaSources"] == {}
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If watched or watched more than a minute
|
||||||
|
if (
|
||||||
|
episode["UserData"]["Played"] == True
|
||||||
|
or episode["UserData"]["PlaybackPositionTicks"] > 600000000
|
||||||
|
):
|
||||||
|
episode_guids = get_guids(self.server_type, episode)
|
||||||
|
mark_episodes_list.append(episode_guids)
|
||||||
|
|
||||||
|
if mark_episodes_list:
|
||||||
|
# Add the show dictionary to the user's watched list
|
||||||
|
if show_guids not in user_watched[library_title]:
|
||||||
|
user_watched[library_title][show_guids] = []
|
||||||
|
|
||||||
|
user_watched[library_title][show_guids] = mark_episodes_list
|
||||||
|
for episode in mark_episodes_list:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Added {episode} to {user_name} watched list",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Got watched for {user_name} in library {library_title}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
if library_title in user_watched:
|
||||||
|
logger(f"{self.server_type}: {user_watched[library_title]}", 3)
|
||||||
|
|
||||||
|
return user_watched
|
||||||
|
except Exception as e:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger(traceback.format_exc(), 2)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_watched(self, users, sync_libraries):
|
||||||
|
try:
|
||||||
|
users_watched = {}
|
||||||
|
watched = []
|
||||||
|
|
||||||
|
for user_name, user_id in users.items():
|
||||||
|
libraries = []
|
||||||
|
|
||||||
|
all_libraries = self.query(f"/Users/{user_id}/Views", "get")
|
||||||
|
for library in all_libraries["Items"]:
|
||||||
|
library_id = library["Id"]
|
||||||
|
library_title = library["Name"]
|
||||||
|
|
||||||
|
if library_title not in sync_libraries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
identifiers = {
|
||||||
|
"library_id": library_id,
|
||||||
|
"library_title": library_title,
|
||||||
|
}
|
||||||
|
libraries.append(
|
||||||
|
self.query(
|
||||||
|
f"/Users/{user_id}/Items"
|
||||||
|
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
|
||||||
|
"get",
|
||||||
|
identifiers=identifiers,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for library in libraries:
|
||||||
|
if len(library["Items"]) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
library_id = library["Identifiers"]["library_id"]
|
||||||
|
library_title = library["Identifiers"]["library_title"]
|
||||||
|
|
||||||
|
# Get all library types excluding "Folder"
|
||||||
|
types = set(
|
||||||
|
[
|
||||||
|
x["Type"]
|
||||||
|
for x in library["Items"]
|
||||||
|
if x["Type"] in ["Movie", "Series", "Episode"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for library_type in types:
|
||||||
|
# Get watched for user
|
||||||
|
watched = self.get_user_library_watched(
|
||||||
|
user_name,
|
||||||
|
user_id,
|
||||||
|
library_type,
|
||||||
|
library_id,
|
||||||
|
library_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_name.lower() not in users_watched:
|
||||||
|
users_watched[user_name.lower()] = {}
|
||||||
|
users_watched[user_name.lower()].update(watched)
|
||||||
|
|
||||||
|
return users_watched
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"{self.server_type}: Failed to get watched, Error: {e}", 2)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def update_user_watched(
|
||||||
|
self, user_name, user_id, library, library_id, videos, update_partial, dryrun
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Updating watched for {user_name} in library {library}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
(
|
||||||
|
videos_shows_ids,
|
||||||
|
videos_episodes_ids,
|
||||||
|
videos_movies_ids,
|
||||||
|
) = generate_library_guids_dict(videos)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not videos_movies_ids
|
||||||
|
and not videos_shows_ids
|
||||||
|
and not videos_episodes_ids
|
||||||
|
):
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: No videos to mark as watched for {user_name} in library {library}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if videos_movies_ids:
|
||||||
|
jellyfin_search = self.query(
|
||||||
|
f"/Users/{user_id}/Items"
|
||||||
|
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
|
||||||
|
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie",
|
||||||
|
"get",
|
||||||
|
)
|
||||||
|
for jellyfin_video in jellyfin_search["Items"]:
|
||||||
|
movie_status = get_video_status(
|
||||||
|
jellyfin_video, videos_movies_ids, videos
|
||||||
|
)
|
||||||
|
|
||||||
|
if movie_status:
|
||||||
|
jellyfin_video_id = jellyfin_video["Id"]
|
||||||
|
if movie_status["completed"]:
|
||||||
|
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library}"
|
||||||
|
if not dryrun:
|
||||||
|
logger(msg, 5)
|
||||||
|
self.query(
|
||||||
|
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
|
||||||
|
"post",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
self.server_type,
|
||||||
|
self.server_name,
|
||||||
|
user_name,
|
||||||
|
library,
|
||||||
|
jellyfin_video.get("Name"),
|
||||||
|
)
|
||||||
|
elif update_partial:
|
||||||
|
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library}"
|
||||||
|
|
||||||
|
if not dryrun:
|
||||||
|
logger(msg, 5)
|
||||||
|
playback_position_payload = {
|
||||||
|
"PlaybackPositionTicks": movie_status["time"]
|
||||||
|
* 10_000,
|
||||||
|
}
|
||||||
|
self.query(
|
||||||
|
f"/Users/{user_id}/Items/{jellyfin_video_id}/UserData",
|
||||||
|
"post",
|
||||||
|
json=playback_position_payload,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
self.server_type,
|
||||||
|
self.server_name,
|
||||||
|
user_name,
|
||||||
|
library,
|
||||||
|
jellyfin_video.get("Name"),
|
||||||
|
duration=floor(movie_status["time"] / 60_000),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TV Shows
|
||||||
|
if videos_shows_ids and videos_episodes_ids:
|
||||||
|
jellyfin_search = self.query(
|
||||||
|
f"/Users/{user_id}/Items"
|
||||||
|
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
|
||||||
|
+ "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series",
|
||||||
|
"get",
|
||||||
|
)
|
||||||
|
jellyfin_shows = [x for x in jellyfin_search["Items"]]
|
||||||
|
|
||||||
|
for jellyfin_show in jellyfin_shows:
|
||||||
|
show_found = False
|
||||||
|
episode_videos = []
|
||||||
|
|
||||||
|
if generate_locations:
|
||||||
|
if "Path" in jellyfin_show:
|
||||||
|
if (
|
||||||
|
contains_nested(
|
||||||
|
jellyfin_show["Path"].split("/")[-1],
|
||||||
|
videos_shows_ids["locations"],
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
show_found = True
|
||||||
|
for shows, episodes in videos.items():
|
||||||
|
show = {k: v for k, v in shows}
|
||||||
|
if (
|
||||||
|
contains_nested(
|
||||||
|
jellyfin_show["Path"].split("/")[-1],
|
||||||
|
show["locations"],
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
for episode in episodes:
|
||||||
|
episode_videos.append(episode)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
if generate_guids:
|
||||||
|
if not show_found:
|
||||||
|
for show_provider_source, show_provider_id in jellyfin_show[
|
||||||
|
"ProviderIds"
|
||||||
|
].items():
|
||||||
|
if show_provider_source.lower() in videos_shows_ids:
|
||||||
|
if (
|
||||||
|
show_provider_id.lower()
|
||||||
|
in videos_shows_ids[
|
||||||
|
show_provider_source.lower()
|
||||||
|
]
|
||||||
|
):
|
||||||
|
show_found = True
|
||||||
|
for show, episodes in videos.items():
|
||||||
|
show = {k: v for k, v in show}
|
||||||
|
if show_provider_id.lower() in show.get(
|
||||||
|
show_provider_source.lower(), []
|
||||||
|
):
|
||||||
|
for episode in episodes:
|
||||||
|
episode_videos.append(episode)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
if show_found:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Updating watched for {user_name} in library {library} for show {jellyfin_show.get('Name')}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
jellyfin_show_id = jellyfin_show["Id"]
|
||||||
|
jellyfin_episodes = self.query(
|
||||||
|
f"/Shows/{jellyfin_show_id}/Episodes"
|
||||||
|
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||||
|
"get",
|
||||||
|
)
|
||||||
|
|
||||||
|
for jellyfin_episode in jellyfin_episodes["Items"]:
|
||||||
|
episode_status = get_video_status(
|
||||||
|
jellyfin_episode, videos_episodes_ids, episode_videos
|
||||||
|
)
|
||||||
|
|
||||||
|
if episode_status:
|
||||||
|
jellyfin_episode_id = jellyfin_episode["Id"]
|
||||||
|
if episode_status["completed"]:
|
||||||
|
msg = (
|
||||||
|
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
|
||||||
|
+ f" as watched for {user_name} in {library}"
|
||||||
|
)
|
||||||
|
if not dryrun:
|
||||||
|
logger(msg, 5)
|
||||||
|
self.query(
|
||||||
|
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
|
||||||
|
"post",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
self.server_type,
|
||||||
|
self.server_name,
|
||||||
|
user_name,
|
||||||
|
library,
|
||||||
|
jellyfin_episode.get("SeriesName"),
|
||||||
|
jellyfin_episode.get("Name"),
|
||||||
|
)
|
||||||
|
elif update_partial:
|
||||||
|
msg = (
|
||||||
|
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
|
||||||
|
+ f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not dryrun:
|
||||||
|
logger(msg, 5)
|
||||||
|
playback_position_payload = {
|
||||||
|
"PlaybackPositionTicks": episode_status[
|
||||||
|
"time"
|
||||||
|
]
|
||||||
|
* 10_000,
|
||||||
|
}
|
||||||
|
self.query(
|
||||||
|
f"/Users/{user_id}/Items/{jellyfin_episode_id}/UserData",
|
||||||
|
"post",
|
||||||
|
json=playback_position_payload,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
self.server_type,
|
||||||
|
self.server_name,
|
||||||
|
user_name,
|
||||||
|
library,
|
||||||
|
jellyfin_episode.get("SeriesName"),
|
||||||
|
jellyfin_episode.get("Name"),
|
||||||
|
duration=floor(episode_status["time"] / 60_000),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Error updating watched for {user_name} in library {library}, {e}",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
logger(traceback.format_exc(), 2)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def update_watched(
|
||||||
|
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
server_version = self.get_server_version()
|
||||||
|
update_partial = self.is_partial_update_supported(server_version)
|
||||||
|
|
||||||
|
if not update_partial:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
for user, libraries in watched_list.items():
|
||||||
|
logger(f"{self.server_type}: Updating for entry {user}, {libraries}", 1)
|
||||||
|
user_other = None
|
||||||
|
user_name = None
|
||||||
|
if user_mapping:
|
||||||
|
if user in user_mapping.keys():
|
||||||
|
user_other = user_mapping[user]
|
||||||
|
elif user in user_mapping.values():
|
||||||
|
user_other = search_mapping(user_mapping, user)
|
||||||
|
|
||||||
|
user_id = None
|
||||||
|
for key in self.users:
|
||||||
|
if user.lower() == key.lower():
|
||||||
|
user_id = self.users[key]
|
||||||
|
user_name = key
|
||||||
|
break
|
||||||
|
elif user_other and user_other.lower() == key.lower():
|
||||||
|
user_id = self.users[key]
|
||||||
|
user_name = key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
logger(f"{user} {user_other} not found in Jellyfin", 2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
jellyfin_libraries = self.query(
|
||||||
|
f"/Users/{user_id}/Views",
|
||||||
|
"get",
|
||||||
|
)
|
||||||
|
jellyfin_libraries = [x for x in jellyfin_libraries["Items"]]
|
||||||
|
|
||||||
|
for library, videos in libraries.items():
|
||||||
|
library_other = None
|
||||||
|
if library_mapping:
|
||||||
|
if library in library_mapping.keys():
|
||||||
|
library_other = library_mapping[library]
|
||||||
|
elif library in library_mapping.values():
|
||||||
|
library_other = search_mapping(library_mapping, library)
|
||||||
|
|
||||||
|
if library.lower() not in [
|
||||||
|
x["Name"].lower() for x in jellyfin_libraries
|
||||||
|
]:
|
||||||
|
if library_other:
|
||||||
|
if library_other.lower() in [
|
||||||
|
x["Name"].lower() for x in jellyfin_libraries
|
||||||
|
]:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Library {library} not found, but {library_other} found, using {library_other}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
library = library_other
|
||||||
|
else:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Library {library} or {library_other} not found in library list",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger(
|
||||||
|
f"{self.server_type}: Library {library} not found in library list",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
library_id = None
|
||||||
|
for jellyfin_library in jellyfin_libraries:
|
||||||
|
if jellyfin_library["Name"] == library:
|
||||||
|
library_id = jellyfin_library["Id"]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if library_id:
|
||||||
|
self.update_user_watched(
|
||||||
|
user_name,
|
||||||
|
user_id,
|
||||||
|
library,
|
||||||
|
library_id,
|
||||||
|
videos,
|
||||||
|
update_partial,
|
||||||
|
dryrun,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"{self.server_type}: Error updating watched, {e}", 2)
|
||||||
|
raise Exception(e)
|
||||||
153
src/library.py
153
src/library.py
@@ -1,5 +1,6 @@
|
|||||||
from src.functions import (
|
from src.functions import (
|
||||||
logger,
|
logger,
|
||||||
|
match_list,
|
||||||
search_mapping,
|
search_mapping,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,9 +130,82 @@ def check_whitelist_logic(
|
|||||||
return skip_reason
|
return skip_reason
|
||||||
|
|
||||||
|
|
||||||
|
def filter_libaries(
|
||||||
|
server_libraries,
|
||||||
|
blacklist_library,
|
||||||
|
blacklist_library_type,
|
||||||
|
whitelist_library,
|
||||||
|
whitelist_library_type,
|
||||||
|
library_mapping=None,
|
||||||
|
):
|
||||||
|
filtered_libaries = []
|
||||||
|
for library in server_libraries:
|
||||||
|
skip_reason = check_skip_logic(
|
||||||
|
library,
|
||||||
|
server_libraries[library],
|
||||||
|
blacklist_library,
|
||||||
|
whitelist_library,
|
||||||
|
blacklist_library_type,
|
||||||
|
whitelist_library_type,
|
||||||
|
library_mapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
if skip_reason:
|
||||||
|
logger(f"Skipping library {library}: {skip_reason}", 1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered_libaries.append(library)
|
||||||
|
|
||||||
|
return filtered_libaries
|
||||||
|
|
||||||
|
|
||||||
|
def setup_libraries(
|
||||||
|
server_1,
|
||||||
|
server_2,
|
||||||
|
blacklist_library,
|
||||||
|
blacklist_library_type,
|
||||||
|
whitelist_library,
|
||||||
|
whitelist_library_type,
|
||||||
|
library_mapping=None,
|
||||||
|
):
|
||||||
|
server_1_libraries = server_1.get_libraries()
|
||||||
|
server_2_libraries = server_2.get_libraries()
|
||||||
|
logger(f"Server 1 libraries: {server_1_libraries}", 1)
|
||||||
|
logger(f"Server 2 libraries: {server_2_libraries}", 1)
|
||||||
|
|
||||||
|
# Filter out all blacklist, whitelist libaries
|
||||||
|
filtered_server_1_libraries = filter_libaries(
|
||||||
|
server_1_libraries,
|
||||||
|
blacklist_library,
|
||||||
|
blacklist_library_type,
|
||||||
|
whitelist_library,
|
||||||
|
whitelist_library_type,
|
||||||
|
library_mapping,
|
||||||
|
)
|
||||||
|
filtered_server_2_libraries = filter_libaries(
|
||||||
|
server_2_libraries,
|
||||||
|
blacklist_library,
|
||||||
|
blacklist_library_type,
|
||||||
|
whitelist_library,
|
||||||
|
whitelist_library_type,
|
||||||
|
library_mapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_server_1_libaries = match_list(
|
||||||
|
filtered_server_1_libraries, filtered_server_2_libraries, library_mapping
|
||||||
|
)
|
||||||
|
output_server_2_libaries = match_list(
|
||||||
|
filtered_server_2_libraries, filtered_server_1_libraries, library_mapping
|
||||||
|
)
|
||||||
|
|
||||||
|
return output_server_1_libaries, output_server_2_libaries
|
||||||
|
|
||||||
|
|
||||||
def show_title_dict(user_list: dict):
|
def show_title_dict(user_list: dict):
|
||||||
try:
|
try:
|
||||||
show_output_dict = {}
|
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 = user_list.keys()
|
||||||
show_output_keys = [dict(x) for x in list(show_output_keys)]
|
show_output_keys = [dict(x) for x in list(show_output_keys)]
|
||||||
@@ -141,62 +215,113 @@ def show_title_dict(user_list: dict):
|
|||||||
if provider_key.lower() == "title":
|
if provider_key.lower() == "title":
|
||||||
continue
|
continue
|
||||||
if provider_key.lower() not in show_output_dict:
|
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":
|
if provider_key.lower() == "locations":
|
||||||
for show_location in provider_value:
|
show_output_dict[provider_key.lower()].append(provider_value)
|
||||||
show_output_dict[provider_key.lower()].append(show_location)
|
|
||||||
else:
|
else:
|
||||||
show_output_dict[provider_key.lower()].append(
|
show_output_dict[provider_key.lower()].append(
|
||||||
provider_value.lower()
|
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
|
return show_output_dict
|
||||||
except Exception:
|
except Exception:
|
||||||
logger("Generating show_output_dict failed, skipping", 1)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def episode_title_dict(user_list: dict):
|
def episode_title_dict(user_list: dict):
|
||||||
try:
|
try:
|
||||||
episode_output_dict = {}
|
episode_output_dict = {}
|
||||||
|
episode_output_dict["completed"] = []
|
||||||
|
episode_output_dict["time"] = []
|
||||||
|
episode_output_dict["locations"] = []
|
||||||
|
episode_output_dict["show"] = []
|
||||||
|
episode_counter = 0 # Initialize a counter for the current episode position
|
||||||
|
|
||||||
|
# Iterate through the shows and episodes in user_list
|
||||||
for show in user_list:
|
for show in user_list:
|
||||||
for season in user_list[show]:
|
|
||||||
for episode in user_list[show][season]:
|
for episode in user_list[show]:
|
||||||
|
# Add the show title to the episode_output_dict if it doesn't exist
|
||||||
|
if "show" not in episode_output_dict:
|
||||||
|
episode_output_dict["show"] = [None] * episode_counter
|
||||||
|
|
||||||
|
# Add the show title to the episode_output_dict
|
||||||
|
episode_output_dict["show"].append(dict(show))
|
||||||
|
|
||||||
|
# Iterate through the keys and values in each episode
|
||||||
for episode_key, episode_value in episode.items():
|
for episode_key, episode_value in episode.items():
|
||||||
|
# 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:
|
if episode_key.lower() not in episode_output_dict:
|
||||||
episode_output_dict[episode_key.lower()] = []
|
# 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":
|
if episode_key == "locations":
|
||||||
for episode_location in episode_value:
|
episode_output_dict[episode_key.lower()].append(episode_value)
|
||||||
episode_output_dict[episode_key.lower()].append(
|
|
||||||
episode_location
|
# 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:
|
else:
|
||||||
episode_output_dict[episode_key.lower()].append(
|
episode_output_dict[episode_key.lower()].append(
|
||||||
episode_value.lower()
|
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
|
return episode_output_dict
|
||||||
except Exception:
|
except Exception:
|
||||||
logger("Generating episode_output_dict failed, skipping", 1)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def movies_title_dict(user_list: dict):
|
def movies_title_dict(user_list: dict):
|
||||||
try:
|
try:
|
||||||
movies_output_dict = {}
|
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 in user_list:
|
||||||
for movie_key, movie_value in movie.items():
|
for movie_key, movie_value in movie.items():
|
||||||
|
if movie_key != "status":
|
||||||
if movie_key.lower() not in movies_output_dict:
|
if movie_key.lower() not in movies_output_dict:
|
||||||
movies_output_dict[movie_key.lower()] = []
|
movies_output_dict[movie_key.lower()] = []
|
||||||
|
|
||||||
if movie_key == "locations":
|
if movie_key == "locations":
|
||||||
for movie_location in movie_value:
|
movies_output_dict[movie_key.lower()].append(movie_value)
|
||||||
movies_output_dict[movie_key.lower()].append(movie_location)
|
elif movie_key == "status":
|
||||||
|
movies_output_dict["completed"].append(movie_value["completed"])
|
||||||
|
movies_output_dict["time"].append(movie_value["time"])
|
||||||
else:
|
else:
|
||||||
movies_output_dict[movie_key.lower()].append(movie_value.lower())
|
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
|
return movies_output_dict
|
||||||
except Exception:
|
except Exception:
|
||||||
logger("Generating movies_output_dict failed, skipping", 1)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
315
src/main.py
315
src/main.py
@@ -1,249 +1,92 @@
|
|||||||
import os, traceback, json, asyncio
|
import os, traceback, json
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from time import sleep, perf_counter
|
from time import sleep, perf_counter
|
||||||
|
|
||||||
|
from src.library import setup_libraries
|
||||||
from src.functions import (
|
from src.functions import (
|
||||||
logger,
|
logger,
|
||||||
str_to_bool,
|
str_to_bool,
|
||||||
)
|
)
|
||||||
from src.users import (
|
from src.users import setup_users
|
||||||
generate_user_list,
|
|
||||||
combine_user_lists,
|
|
||||||
filter_user_lists,
|
|
||||||
generate_server_users,
|
|
||||||
)
|
|
||||||
from src.watched import (
|
from src.watched import (
|
||||||
cleanup_watched,
|
cleanup_watched,
|
||||||
)
|
)
|
||||||
from src.black_white import setup_black_white_lists
|
from src.black_white import setup_black_white_lists
|
||||||
|
from src.connection import generate_server_connections
|
||||||
from src.plex import Plex
|
|
||||||
from src.jellyfin import Jellyfin
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
def setup_users(
|
|
||||||
server_1, server_2, blacklist_users, whitelist_users, user_mapping=None
|
|
||||||
):
|
|
||||||
server_1_users = generate_user_list(server_1)
|
|
||||||
server_2_users = generate_user_list(server_2)
|
|
||||||
|
|
||||||
users = combine_user_lists(server_1_users, server_2_users, user_mapping)
|
|
||||||
logger(f"User list that exist on both servers {users}", 1)
|
|
||||||
|
|
||||||
users_filtered = filter_user_lists(users, blacklist_users, whitelist_users)
|
|
||||||
logger(f"Filtered user list {users_filtered}", 1)
|
|
||||||
|
|
||||||
output_server_1_users = generate_server_users(server_1, users_filtered)
|
|
||||||
output_server_2_users = generate_server_users(server_2, users_filtered)
|
|
||||||
|
|
||||||
# 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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
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"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):
|
|
||||||
baseurl = baseurl.strip()
|
|
||||||
if baseurl[-1] == "/":
|
|
||||||
baseurl = baseurl[:-1]
|
|
||||||
servers.append(
|
|
||||||
(
|
|
||||||
"jellyfin",
|
|
||||||
Jellyfin(baseurl=baseurl, token=jellyfin_token[i].strip()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return servers
|
|
||||||
|
|
||||||
|
|
||||||
def get_server_watched(
|
|
||||||
server_connection: list,
|
|
||||||
users: dict,
|
|
||||||
blacklist_library: list,
|
|
||||||
whitelist_library: list,
|
|
||||||
blacklist_library_type: list,
|
|
||||||
whitelist_library_type: list,
|
|
||||||
library_mapping: dict,
|
|
||||||
):
|
|
||||||
if server_connection[0] == "plex":
|
|
||||||
return server_connection[1].get_watched(
|
|
||||||
users,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
)
|
|
||||||
elif server_connection[0] == "jellyfin":
|
|
||||||
return asyncio.run(
|
|
||||||
server_connection[1].get_watched(
|
|
||||||
users,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_server_watched(
|
|
||||||
server_connection: list,
|
|
||||||
server_watched_filtered: dict,
|
|
||||||
user_mapping: dict,
|
|
||||||
library_mapping: dict,
|
|
||||||
dryrun: bool,
|
|
||||||
):
|
|
||||||
if server_connection[0] == "plex":
|
|
||||||
server_connection[1].update_watched(
|
|
||||||
server_watched_filtered, user_mapping, library_mapping, dryrun
|
|
||||||
)
|
|
||||||
elif server_connection[0] == "jellyfin":
|
|
||||||
asyncio.run(
|
|
||||||
server_connection[1].update_watched(
|
|
||||||
server_watched_filtered, user_mapping, library_mapping, dryrun
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def should_sync_server(server_1_type, server_2_type):
|
def should_sync_server(server_1_type, server_2_type):
|
||||||
sync_from_plex_to_jellyfin = str_to_bool(
|
sync_from_plex_to_jellyfin = str_to_bool(
|
||||||
os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True")
|
os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True")
|
||||||
)
|
)
|
||||||
|
sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True"))
|
||||||
|
sync_from_plex_to_emby = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_EMBY", "True"))
|
||||||
|
|
||||||
sync_from_jelly_to_plex = str_to_bool(
|
sync_from_jelly_to_plex = str_to_bool(
|
||||||
os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True")
|
os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True")
|
||||||
)
|
)
|
||||||
sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True"))
|
|
||||||
sync_from_jelly_to_jellyfin = str_to_bool(
|
sync_from_jelly_to_jellyfin = str_to_bool(
|
||||||
os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True")
|
os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True")
|
||||||
)
|
)
|
||||||
|
sync_from_jelly_to_emby = str_to_bool(
|
||||||
|
os.getenv("SYNC_FROM_JELLYFIN_TO_EMBY", "True")
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
sync_from_emby_to_plex = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_PLEX", "True"))
|
||||||
server_1_type == "plex"
|
sync_from_emby_to_jellyfin = str_to_bool(
|
||||||
and server_2_type == "plex"
|
os.getenv("SYNC_FROM_EMBY_TO_JELLYFIN", "True")
|
||||||
and not sync_from_plex_to_plex
|
)
|
||||||
):
|
sync_from_emby_to_emby = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_EMBY", "True"))
|
||||||
logger("Sync between plex and plex is disabled", 1)
|
|
||||||
|
if server_1_type == "plex":
|
||||||
|
if server_2_type == "jellyfin" and not sync_from_plex_to_jellyfin:
|
||||||
|
logger("Sync from plex -> jellyfin is disabled", 1)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if (
|
if server_2_type == "emby" and not sync_from_plex_to_emby:
|
||||||
server_1_type == "plex"
|
logger("Sync from plex -> emby is disabled", 1)
|
||||||
and server_2_type == "jellyfin"
|
|
||||||
and not sync_from_jelly_to_plex
|
|
||||||
):
|
|
||||||
logger("Sync from jellyfin to plex disabled", 1)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if (
|
if server_2_type == "plex" and not sync_from_plex_to_plex:
|
||||||
server_1_type == "jellyfin"
|
logger("Sync from plex -> plex is disabled", 1)
|
||||||
and server_2_type == "jellyfin"
|
|
||||||
and not sync_from_jelly_to_jellyfin
|
|
||||||
):
|
|
||||||
logger("Sync between jellyfin and jellyfin is disabled", 1)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if (
|
if server_1_type == "jellyfin":
|
||||||
server_1_type == "jellyfin"
|
if server_2_type == "plex" and not sync_from_jelly_to_plex:
|
||||||
and server_2_type == "plex"
|
logger("Sync from jellyfin -> plex is disabled", 1)
|
||||||
and not sync_from_plex_to_jellyfin
|
return False
|
||||||
):
|
|
||||||
logger("Sync from plex to jellyfin is disabled", 1)
|
if server_2_type == "jellyfin" and not sync_from_jelly_to_jellyfin:
|
||||||
|
logger("Sync from jellyfin -> jellyfin is disabled", 1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if server_2_type == "emby" and not sync_from_jelly_to_emby:
|
||||||
|
logger("Sync from jellyfin -> emby is disabled", 1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if server_1_type == "emby":
|
||||||
|
if server_2_type == "plex" and not sync_from_emby_to_plex:
|
||||||
|
logger("Sync from emby -> plex is disabled", 1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if server_2_type == "jellyfin" and not sync_from_emby_to_jellyfin:
|
||||||
|
logger("Sync from emby -> jellyfin is disabled", 1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if server_2_type == "emby" and not sync_from_emby_to_emby:
|
||||||
|
logger("Sync from emby -> emby is disabled", 1)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main_loop():
|
def main_loop():
|
||||||
logfile = os.getenv("LOGFILE", "log.log")
|
log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
|
||||||
# Delete logfile if it exists
|
# Delete log_file if it exists
|
||||||
if os.path.exists(logfile):
|
if os.path.exists(log_file):
|
||||||
os.remove(logfile)
|
os.remove(log_file)
|
||||||
|
|
||||||
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
|
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
|
||||||
logger(f"Dryrun: {dryrun}", 1)
|
logger(f"Dryrun: {dryrun}", 1)
|
||||||
@@ -296,33 +139,42 @@ def main_loop():
|
|||||||
|
|
||||||
# Start server_2 at the next server in the list
|
# Start server_2 at the next server in the list
|
||||||
for server_2 in servers[servers.index(server_1) + 1 :]:
|
for server_2 in servers[servers.index(server_1) + 1 :]:
|
||||||
|
# Check if server 1 and server 2 are going to be synced in either direction, skip if not
|
||||||
|
if not should_sync_server(
|
||||||
|
server_1[0], server_2[0]
|
||||||
|
) and not should_sync_server(server_2[0], server_1[0]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger(f"Server 1: {server_1[0].capitalize()}: {server_1[1].info()}", 0)
|
||||||
|
logger(f"Server 2: {server_2[0].capitalize()}: {server_2[1].info()}", 0)
|
||||||
|
|
||||||
# Create users list
|
# Create users list
|
||||||
logger("Creating users list", 1)
|
logger("Creating users list", 1)
|
||||||
server_1_users, server_2_users = setup_users(
|
server_1_users, server_2_users = setup_users(
|
||||||
server_1, server_2, blacklist_users, whitelist_users, user_mapping
|
server_1, server_2, blacklist_users, whitelist_users, user_mapping
|
||||||
)
|
)
|
||||||
|
|
||||||
logger("Creating watched lists", 1)
|
server_1_libraries, server_2_libraries = setup_libraries(
|
||||||
server_1_watched = get_server_watched(
|
server_1[1],
|
||||||
server_1,
|
server_2[1],
|
||||||
server_1_users,
|
|
||||||
blacklist_library,
|
blacklist_library,
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
blacklist_library_type,
|
||||||
|
whitelist_library,
|
||||||
whitelist_library_type,
|
whitelist_library_type,
|
||||||
library_mapping,
|
library_mapping,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger("Creating watched lists", 1)
|
||||||
|
server_1_watched = server_1[1].get_watched(
|
||||||
|
server_1_users, server_1_libraries
|
||||||
|
)
|
||||||
logger("Finished creating watched list server 1", 1)
|
logger("Finished creating watched list server 1", 1)
|
||||||
server_2_watched = get_server_watched(
|
|
||||||
server_2,
|
server_2_watched = server_2[1].get_watched(
|
||||||
server_2_users,
|
server_2_users, server_2_libraries
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
)
|
)
|
||||||
logger("Finished creating watched list server 2", 1)
|
logger("Finished creating watched list server 2", 1)
|
||||||
|
|
||||||
logger(f"Server 1 watched: {server_1_watched}", 3)
|
logger(f"Server 1 watched: {server_1_watched}", 3)
|
||||||
logger(f"Server 2 watched: {server_2_watched}", 3)
|
logger(f"Server 2 watched: {server_2_watched}", 3)
|
||||||
|
|
||||||
@@ -345,18 +197,18 @@ def main_loop():
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
if should_sync_server(server_1[0], server_2[0]):
|
if should_sync_server(server_2[0], server_1[0]):
|
||||||
update_server_watched(
|
logger(f"Syncing {server_2[1].info()} -> {server_1[1].info()}", 0)
|
||||||
server_1,
|
server_1[1].update_watched(
|
||||||
server_2_watched_filtered,
|
server_2_watched_filtered,
|
||||||
user_mapping,
|
user_mapping,
|
||||||
library_mapping,
|
library_mapping,
|
||||||
dryrun,
|
dryrun,
|
||||||
)
|
)
|
||||||
|
|
||||||
if should_sync_server(server_2[0], server_1[0]):
|
if should_sync_server(server_1[0], server_2[0]):
|
||||||
update_server_watched(
|
logger(f"Syncing {server_1[1].info()} -> {server_2[1].info()}", 0)
|
||||||
server_2,
|
server_2[1].update_watched(
|
||||||
server_1_watched_filtered,
|
server_1_watched_filtered,
|
||||||
user_mapping,
|
user_mapping,
|
||||||
library_mapping,
|
library_mapping,
|
||||||
@@ -365,6 +217,7 @@ def main_loop():
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False"))
|
||||||
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
|
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
|
||||||
times = []
|
times = []
|
||||||
while True:
|
while True:
|
||||||
@@ -377,6 +230,9 @@ def main():
|
|||||||
if len(times) > 0:
|
if len(times) > 0:
|
||||||
logger(f"Average time: {sum(times) / len(times)}", 0)
|
logger(f"Average time: {sum(times) / len(times)}", 0)
|
||||||
|
|
||||||
|
if run_only_once:
|
||||||
|
break
|
||||||
|
|
||||||
logger(f"Looping in {sleep_duration}")
|
logger(f"Looping in {sleep_duration}")
|
||||||
sleep(sleep_duration)
|
sleep(sleep_duration)
|
||||||
|
|
||||||
@@ -389,9 +245,14 @@ def main():
|
|||||||
|
|
||||||
logger(traceback.format_exc(), 2)
|
logger(traceback.format_exc(), 2)
|
||||||
|
|
||||||
|
if run_only_once:
|
||||||
|
break
|
||||||
|
|
||||||
logger(f"Retrying in {sleep_duration}", log_type=0)
|
logger(f"Retrying in {sleep_duration}", log_type=0)
|
||||||
sleep(sleep_duration)
|
sleep(sleep_duration)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
if len(times) > 0:
|
||||||
|
logger(f"Average time: {sum(times) / len(times)}", 0)
|
||||||
logger("Exiting", log_type=0)
|
logger("Exiting", log_type=0)
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|||||||
482
src/plex.py
482
src/plex.py
@@ -1,6 +1,13 @@
|
|||||||
import re, requests, os, traceback
|
import os, requests, traceback
|
||||||
from urllib3.poolmanager import PoolManager
|
from dotenv import load_dotenv
|
||||||
|
from typing import Dict, Union, FrozenSet
|
||||||
|
|
||||||
|
from urllib3.poolmanager import PoolManager
|
||||||
|
from math import floor
|
||||||
|
|
||||||
|
from requests.adapters import HTTPAdapter as RequestsHTTPAdapter
|
||||||
|
|
||||||
|
from plexapi.video import Show, Episode, Movie
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
from plexapi.myplex import MyPlexAccount
|
from plexapi.myplex import MyPlexAccount
|
||||||
|
|
||||||
@@ -8,15 +15,21 @@ from src.functions import (
|
|||||||
logger,
|
logger,
|
||||||
search_mapping,
|
search_mapping,
|
||||||
future_thread_executor,
|
future_thread_executor,
|
||||||
|
contains_nested,
|
||||||
|
log_marked,
|
||||||
|
str_to_bool,
|
||||||
)
|
)
|
||||||
from src.library import (
|
from src.library import generate_library_guids_dict
|
||||||
check_skip_logic,
|
|
||||||
generate_library_guids_dict,
|
|
||||||
)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
|
||||||
|
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
|
||||||
|
|
||||||
|
|
||||||
# Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
|
# Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
|
||||||
class HostNameIgnoringAdapter(requests.adapters.HTTPAdapter):
|
class HostNameIgnoringAdapter(RequestsHTTPAdapter):
|
||||||
def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs):
|
def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs):
|
||||||
self.poolmanager = PoolManager(
|
self.poolmanager = PoolManager(
|
||||||
num_pools=connections,
|
num_pools=connections,
|
||||||
@@ -27,58 +40,92 @@ class HostNameIgnoringAdapter(requests.adapters.HTTPAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_user_library_watched_show(show):
|
def extract_guids_from_item(item: Union[Movie, Show, Episode]) -> Dict[str, str]:
|
||||||
try:
|
# If GENERATE_GUIDS is set to False, then return an empty dict
|
||||||
show_guids = {}
|
if not generate_guids:
|
||||||
for show_guid in show.guids:
|
return {}
|
||||||
# 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
|
|
||||||
|
|
||||||
show_guids["title"] = show.title
|
guids: Dict[str, str] = dict(
|
||||||
show_guids["locations"] = tuple([x.split("/")[-1] for x in show.locations])
|
guid.id.split("://")
|
||||||
show_guids = frozenset(show_guids.items())
|
for guid in item.guids
|
||||||
|
if guid.id is not None and len(guid.id.strip()) > 0
|
||||||
|
)
|
||||||
|
|
||||||
# Get all watched episodes for show
|
if len(guids) == 0:
|
||||||
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(
|
logger(
|
||||||
f"Plex: Failed to get guids for {episode.title} in {show.title}, Using location only",
|
f"Plex: Failed to get any guids for {item.title}",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
episode_guids_temp["locations"] = tuple(
|
return guids
|
||||||
[x.split("/")[-1] for x in episode.locations]
|
|
||||||
|
|
||||||
|
def get_guids(item: Union[Movie, Episode], completed=True):
|
||||||
|
if not item.locations:
|
||||||
|
logger(
|
||||||
|
f"Plex: {item.title} has no locations",
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
if episode.parentTitle not in episode_guids:
|
if not item.guids:
|
||||||
episode_guids[episode.parentTitle] = []
|
logger(
|
||||||
|
f"Plex: {item.title} has no guids",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
episode_guids[episode.parentTitle].append(episode_guids_temp)
|
return {
|
||||||
|
"title": item.title,
|
||||||
|
"locations": (
|
||||||
|
tuple([location.split("/")[-1] for location in item.locations])
|
||||||
|
if generate_locations
|
||||||
|
else tuple()
|
||||||
|
),
|
||||||
|
"status": {
|
||||||
|
"completed": completed,
|
||||||
|
"time": item.viewOffset,
|
||||||
|
},
|
||||||
|
} | extract_guids_from_item(
|
||||||
|
item
|
||||||
|
) # Merge the metadata and guid dictionaries
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_library_watched_show(show, process_episodes, threads=None):
|
||||||
|
try:
|
||||||
|
show_guids: FrozenSet = frozenset(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"title": show.title,
|
||||||
|
"locations": (
|
||||||
|
tuple([location.split("/")[-1] for location in show.locations])
|
||||||
|
if generate_locations
|
||||||
|
else tuple()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
| extract_guids_from_item(show)
|
||||||
|
).items() # Merge the metadata and guid dictionaries
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_guids_args = []
|
||||||
|
|
||||||
|
for episode in process_episodes:
|
||||||
|
episode_guids_args.append([get_guids, episode, episode.isWatched])
|
||||||
|
|
||||||
|
episode_guids_results = future_thread_executor(
|
||||||
|
episode_guids_args, threads=threads
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_guids = []
|
||||||
|
for index, episode in enumerate(process_episodes):
|
||||||
|
episode_guids.append(episode_guids_results[index])
|
||||||
|
|
||||||
return show_guids, episode_guids
|
return show_guids, episode_guids
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}, {}
|
return {}, {}
|
||||||
|
|
||||||
|
|
||||||
def get_user_library_watched(user, user_plex, library):
|
def get_user_library_watched(user, user_plex, library):
|
||||||
|
user_name: str = user.username.lower() if user.username else user.title.lower()
|
||||||
try:
|
try:
|
||||||
user_name = user.title.lower()
|
|
||||||
user_watched = {}
|
|
||||||
user_watched[user_name] = {}
|
|
||||||
|
|
||||||
logger(
|
logger(
|
||||||
f"Plex: Generating watched for {user_name} in library {library.title}",
|
f"Plex: Generating watched for {user_name} in library {library.title}",
|
||||||
0,
|
0,
|
||||||
@@ -87,55 +134,66 @@ def get_user_library_watched(user, user_plex, library):
|
|||||||
library_videos = user_plex.library.section(library.title)
|
library_videos = user_plex.library.section(library.title)
|
||||||
|
|
||||||
if library.type == "movie":
|
if library.type == "movie":
|
||||||
user_watched[user_name][library.title] = []
|
watched = []
|
||||||
|
|
||||||
for video in library_videos.search(unwatched=False):
|
args = [
|
||||||
logger(f"Plex: Adding {video.title} to {user_name} watched list", 3)
|
[get_guids, video, video.isWatched]
|
||||||
logger(f"Plex: {video.title} {video.guids} {video.locations}", 3)
|
for video in library_videos.search(unwatched=False)
|
||||||
|
+ library_videos.search(inProgress=True)
|
||||||
|
if video.isWatched or video.viewOffset >= 60000
|
||||||
|
]
|
||||||
|
|
||||||
movie_guids = {}
|
for guid in future_thread_executor(args, threads=len(args)):
|
||||||
for guid in video.guids:
|
logger(f"Plex: Adding {guid['title']} to {user_name} watched list", 3)
|
||||||
# Extract source and id from guid.id
|
watched.append(guid)
|
||||||
m = re.match(r"(.*)://(.*)", guid.id)
|
elif library.type == "show":
|
||||||
guid_source, guid_id = m.group(1).lower(), m.group(2)
|
watched = {}
|
||||||
movie_guids[guid_source] = guid_id
|
|
||||||
|
|
||||||
movie_guids["title"] = video.title
|
# Get all watched shows and partially watched shows
|
||||||
movie_guids["locations"] = tuple(
|
parallel_show_task = []
|
||||||
[x.split("/")[-1] for x in video.locations]
|
parallel_episodes_task = []
|
||||||
|
|
||||||
|
for show in library_videos.search(unwatched=False) + library_videos.search(
|
||||||
|
inProgress=True
|
||||||
|
):
|
||||||
|
process_episodes = []
|
||||||
|
for episode in show.episodes():
|
||||||
|
if episode.isWatched or episode.viewOffset >= 60000:
|
||||||
|
process_episodes.append(episode)
|
||||||
|
|
||||||
|
# Shows with more than 24 episodes has its episodes processed in parallel
|
||||||
|
# Shows with less than 24 episodes has its episodes processed in serial but the shows are processed in parallel
|
||||||
|
if len(process_episodes) >= 24:
|
||||||
|
parallel_episodes_task.append(
|
||||||
|
[
|
||||||
|
get_user_library_watched_show,
|
||||||
|
show,
|
||||||
|
process_episodes,
|
||||||
|
len(process_episodes),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parallel_show_task.append(
|
||||||
|
[get_user_library_watched_show, show, process_episodes, 1]
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
|
||||||
args.append([get_user_library_watched_show, show])
|
|
||||||
|
|
||||||
for show_guids, episode_guids in future_thread_executor(
|
for show_guids, episode_guids in future_thread_executor(
|
||||||
args, workers=min(os.cpu_count(), 4)
|
parallel_show_task, threads=len(parallel_show_task)
|
||||||
):
|
) + future_thread_executor(parallel_episodes_task, threads=1):
|
||||||
if show_guids and episode_guids:
|
if show_guids and episode_guids:
|
||||||
# append show, season, episode
|
watched[show_guids] = episode_guids
|
||||||
if show_guids not in user_watched[user_name][library.title]:
|
|
||||||
user_watched[user_name][library.title][show_guids] = {}
|
|
||||||
|
|
||||||
user_watched[user_name][library.title][show_guids] = episode_guids
|
|
||||||
logger(
|
logger(
|
||||||
f"Plex: Added {episode_guids} to {user_name} {show_guids} watched list",
|
f"Plex: Added {episode_guids} to {user_name} watched list",
|
||||||
3,
|
3,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger(f"Plex: Got watched for {user_name} in library {library.title}", 1)
|
else:
|
||||||
if library.title in user_watched[user_name]:
|
watched = None
|
||||||
logger(f"Plex: {user_watched[user_name][library.title]}", 3)
|
|
||||||
|
|
||||||
return user_watched
|
logger(f"Plex: Got watched for {user_name} in library {library.title}", 1)
|
||||||
|
logger(f"Plex: {watched}", 3)
|
||||||
|
|
||||||
|
return {user_name: {library.title: watched} if watched is not None else {}}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger(
|
logger(
|
||||||
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
|
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
|
||||||
@@ -144,24 +202,88 @@ def get_user_library_watched(user, user_plex, library):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def find_video(plex_search, video_ids):
|
def find_video(plex_search, video_ids, videos=None):
|
||||||
try:
|
try:
|
||||||
for location in plex_search.locations:
|
if not generate_guids and not generate_locations:
|
||||||
if location.split("/")[-1] in video_ids["locations"]:
|
return False, []
|
||||||
return True
|
|
||||||
|
|
||||||
|
if generate_locations:
|
||||||
|
for location in plex_search.locations:
|
||||||
|
if (
|
||||||
|
contains_nested(location.split("/")[-1], video_ids["locations"])
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
episode_videos = []
|
||||||
|
if videos:
|
||||||
|
for show, episodes in videos.items():
|
||||||
|
show = {k: v for k, v in show}
|
||||||
|
if (
|
||||||
|
contains_nested(
|
||||||
|
location.split("/")[-1], show["locations"]
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
for episode in episodes:
|
||||||
|
episode_videos.append(episode)
|
||||||
|
|
||||||
|
return True, episode_videos
|
||||||
|
|
||||||
|
if generate_guids:
|
||||||
for guid in plex_search.guids:
|
for guid in plex_search.guids:
|
||||||
guid_source = re.search(r"(.*)://", guid.id).group(1).lower()
|
guid_source, guid_id = guid.id.split("://")
|
||||||
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 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_source in video_ids.keys():
|
||||||
if guid_id in video_ids[guid_source]:
|
if guid_id in video_ids[guid_source]:
|
||||||
return True
|
episode_videos = []
|
||||||
|
if videos:
|
||||||
|
for show, episodes in videos.items():
|
||||||
|
show = {k: v for k, v in show}
|
||||||
|
if guid_source in show.keys():
|
||||||
|
if guid_id == show[guid_source]:
|
||||||
|
for episode in episodes:
|
||||||
|
episode_videos.append(episode)
|
||||||
|
|
||||||
return False
|
return True, episode_videos
|
||||||
|
|
||||||
|
return False, []
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False, []
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_status(plex_search, video_ids, videos):
|
||||||
|
try:
|
||||||
|
if not generate_guids and not generate_locations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if generate_locations:
|
||||||
|
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"]
|
||||||
|
|
||||||
|
if generate_guids:
|
||||||
|
for guid in plex_search.guids:
|
||||||
|
guid_source, guid_id = guid.id.split("://")
|
||||||
|
|
||||||
|
# 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.keys():
|
||||||
|
if guid_id == video[guid_source]:
|
||||||
|
return video["status"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def update_user_watched(user, user_plex, library, videos, dryrun):
|
def update_user_watched(user, user_plex, library, videos, dryrun):
|
||||||
@@ -180,13 +302,43 @@ def update_user_watched(user, user_plex, library, videos, dryrun):
|
|||||||
library_videos = user_plex.library.section(library)
|
library_videos = user_plex.library.section(library)
|
||||||
if videos_movies_ids:
|
if videos_movies_ids:
|
||||||
for movies_search in library_videos.search(unwatched=True):
|
for movies_search in library_videos.search(unwatched=True):
|
||||||
if find_video(movies_search, videos_movies_ids):
|
video_status = get_video_status(
|
||||||
msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex"
|
movies_search, videos_movies_ids, videos
|
||||||
|
)
|
||||||
|
if video_status:
|
||||||
|
if video_status["completed"]:
|
||||||
|
msg = f"Plex: {movies_search.title} as watched for {user.title} in {library}"
|
||||||
if not dryrun:
|
if not dryrun:
|
||||||
logger(f"Marked {msg}", 0)
|
logger(msg, 5)
|
||||||
movies_search.markWatched()
|
movies_search.markWatched()
|
||||||
else:
|
else:
|
||||||
logger(f"Dryrun {msg}", 0)
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
"Plex",
|
||||||
|
user_plex.friendlyName,
|
||||||
|
user.title,
|
||||||
|
library,
|
||||||
|
movies_search.title,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
elif video_status["time"] > 60_000:
|
||||||
|
msg = f"Plex: {movies_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library}"
|
||||||
|
if not dryrun:
|
||||||
|
logger(msg, 5)
|
||||||
|
movies_search.updateTimeline(video_status["time"])
|
||||||
|
else:
|
||||||
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
"Plex",
|
||||||
|
user_plex.friendlyName,
|
||||||
|
user.title,
|
||||||
|
library,
|
||||||
|
movies_search.title,
|
||||||
|
duration=video_status["time"],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger(
|
logger(
|
||||||
f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}",
|
f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}",
|
||||||
@@ -195,15 +347,48 @@ def update_user_watched(user, user_plex, library, videos, dryrun):
|
|||||||
|
|
||||||
if videos_shows_ids and videos_episodes_ids:
|
if videos_shows_ids and videos_episodes_ids:
|
||||||
for show_search in library_videos.search(unwatched=True):
|
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():
|
for episode_search in show_search.episodes():
|
||||||
if find_video(episode_search, videos_episodes_ids):
|
video_status = get_video_status(
|
||||||
msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex"
|
episode_search, videos_episodes_ids, episode_videos
|
||||||
|
)
|
||||||
|
if video_status:
|
||||||
|
if video_status["completed"]:
|
||||||
|
msg = f"Plex: {show_search.title} {episode_search.title} as watched for {user.title} in {library}"
|
||||||
if not dryrun:
|
if not dryrun:
|
||||||
logger(f"Marked {msg}", 0)
|
logger(msg, 5)
|
||||||
episode_search.markWatched()
|
episode_search.markWatched()
|
||||||
else:
|
else:
|
||||||
logger(f"Dryrun {msg}", 0)
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
"Plex",
|
||||||
|
user_plex.friendlyName,
|
||||||
|
user.title,
|
||||||
|
library,
|
||||||
|
show_search.title,
|
||||||
|
episode_search.title,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = f"Plex: {show_search.title} {episode_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library}"
|
||||||
|
if not dryrun:
|
||||||
|
logger(msg, 5)
|
||||||
|
episode_search.updateTimeline(video_status["time"])
|
||||||
|
else:
|
||||||
|
logger(msg, 6)
|
||||||
|
|
||||||
|
log_marked(
|
||||||
|
"Plex",
|
||||||
|
user_plex.friendlyName,
|
||||||
|
user.title,
|
||||||
|
library,
|
||||||
|
show_search.title,
|
||||||
|
episode_search.title,
|
||||||
|
video_status["time"],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger(
|
logger(
|
||||||
f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}",
|
f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}",
|
||||||
@@ -277,6 +462,9 @@ class Plex:
|
|||||||
logger(f"Plex: Failed to login, Error: {e}", 2)
|
logger(f"Plex: Failed to login, Error: {e}", 2)
|
||||||
raise Exception(e)
|
raise Exception(e)
|
||||||
|
|
||||||
|
def info(self) -> str:
|
||||||
|
return f"Plex {self.plex.friendlyName}: {self.plex.version}"
|
||||||
|
|
||||||
def get_users(self):
|
def get_users(self):
|
||||||
try:
|
try:
|
||||||
users = self.plex.myPlexAccount().users()
|
users = self.plex.myPlexAccount().users()
|
||||||
@@ -289,58 +477,58 @@ class Plex:
|
|||||||
logger(f"Plex: Failed to get users, Error: {e}", 2)
|
logger(f"Plex: Failed to get users, Error: {e}", 2)
|
||||||
raise Exception(e)
|
raise Exception(e)
|
||||||
|
|
||||||
def get_watched(
|
def get_libraries(self):
|
||||||
self,
|
|
||||||
users,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
# Get all libraries
|
output = {}
|
||||||
users_watched = {}
|
|
||||||
args = []
|
|
||||||
|
|
||||||
for user in users:
|
libraries = self.plex.library.sections()
|
||||||
if self.admin_user == user:
|
|
||||||
user_plex = self.plex
|
|
||||||
else:
|
|
||||||
user_plex = self.login(
|
|
||||||
self.plex._baseurl,
|
|
||||||
user.get_token(self.plex.machineIdentifier),
|
|
||||||
)
|
|
||||||
|
|
||||||
libraries = user_plex.library.sections()
|
|
||||||
|
|
||||||
for library in libraries:
|
for library in libraries:
|
||||||
library_title = library.title
|
library_title = library.title
|
||||||
library_type = library.type
|
library_type = library.type
|
||||||
|
|
||||||
skip_reason = check_skip_logic(
|
output[library_title] = library_type
|
||||||
library_title,
|
|
||||||
library_type,
|
|
||||||
blacklist_library,
|
|
||||||
whitelist_library,
|
|
||||||
blacklist_library_type,
|
|
||||||
whitelist_library_type,
|
|
||||||
library_mapping,
|
|
||||||
)
|
|
||||||
|
|
||||||
if skip_reason:
|
return output
|
||||||
logger(
|
except Exception as e:
|
||||||
f"Plex: Skipping library {library_title}: {skip_reason}", 1
|
logger(f"Plex: Failed to get libraries, Error: {e}", 2)
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def get_watched(self, users, sync_libraries):
|
||||||
|
try:
|
||||||
|
# Get all libraries
|
||||||
|
users_watched = {}
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if self.admin_user == user:
|
||||||
|
user_plex = self.plex
|
||||||
|
else:
|
||||||
|
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
|
continue
|
||||||
|
|
||||||
args.append([get_user_library_watched, user, user_plex, library])
|
libraries = user_plex.library.sections()
|
||||||
|
|
||||||
for user_watched in future_thread_executor(args):
|
for library in libraries:
|
||||||
for user, user_watched_temp in user_watched.items():
|
if library.title not in sync_libraries:
|
||||||
if user not in users_watched:
|
continue
|
||||||
users_watched[user] = {}
|
|
||||||
users_watched[user].update(user_watched_temp)
|
user_watched = get_user_library_watched(user, user_plex, library)
|
||||||
|
|
||||||
|
for user_watched, user_watched_temp in user_watched.items():
|
||||||
|
if user_watched not in users_watched:
|
||||||
|
users_watched[user_watched] = {}
|
||||||
|
users_watched[user_watched].update(user_watched_temp)
|
||||||
|
|
||||||
return users_watched
|
return users_watched
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -363,10 +551,16 @@ class Plex:
|
|||||||
user_other = search_mapping(user_mapping, user)
|
user_other = search_mapping(user_mapping, user)
|
||||||
|
|
||||||
for index, value in enumerate(self.users):
|
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]
|
user = self.users[index]
|
||||||
break
|
break
|
||||||
elif user_other and user_other.lower() == value.title.lower():
|
elif user_other and user_other.lower() == username_title:
|
||||||
user = self.users[index]
|
user = self.users[index]
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -380,11 +574,19 @@ class Plex:
|
|||||||
)
|
)
|
||||||
user = self.plex.myPlexAccount().user(user)
|
user = self.plex.myPlexAccount().user(user)
|
||||||
|
|
||||||
|
token = user.get_token(self.plex.machineIdentifier)
|
||||||
|
if token:
|
||||||
user_plex = PlexServer(
|
user_plex = PlexServer(
|
||||||
self.plex._baseurl,
|
self.plex._baseurl,
|
||||||
user.get_token(self.plex.machineIdentifier),
|
token,
|
||||||
session=self.session,
|
session=self.session,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger(
|
||||||
|
f"Plex: Failed to get token for {user.title}, skipping",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
for library, videos in libraries.items():
|
for library, videos in libraries.items():
|
||||||
library_other = None
|
library_other = None
|
||||||
|
|||||||
60
src/users.py
60
src/users.py
@@ -11,8 +11,12 @@ def generate_user_list(server):
|
|||||||
|
|
||||||
server_users = []
|
server_users = []
|
||||||
if server_type == "plex":
|
if server_type == "plex":
|
||||||
server_users = [x.title.lower() for x in server_connection.users]
|
for user in server_connection.users:
|
||||||
elif server_type == "jellyfin":
|
server_users.append(
|
||||||
|
user.username.lower() if user.username else user.title.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
elif server_type in ["jellyfin", "emby"]:
|
||||||
server_users = [key.lower() for key in server_connection.users.keys()]
|
server_users = [key.lower() for key in server_connection.users.keys()]
|
||||||
|
|
||||||
return server_users
|
return server_users
|
||||||
@@ -66,12 +70,16 @@ def generate_server_users(server, users):
|
|||||||
if server[0] == "plex":
|
if server[0] == "plex":
|
||||||
server_users = []
|
server_users = []
|
||||||
for plex_user in server[1].users:
|
for plex_user in server[1].users:
|
||||||
|
username_title = (
|
||||||
|
plex_user.username if plex_user.username else plex_user.title
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
plex_user.title.lower() in users.keys()
|
username_title.lower() in users.keys()
|
||||||
or plex_user.title.lower() in users.values()
|
or username_title.lower() in users.values()
|
||||||
):
|
):
|
||||||
server_users.append(plex_user)
|
server_users.append(plex_user)
|
||||||
elif server[0] == "jellyfin":
|
elif server[0] in ["jellyfin", "emby"]:
|
||||||
server_users = {}
|
server_users = {}
|
||||||
for jellyfin_user, jellyfin_id in server[1].users.items():
|
for jellyfin_user, jellyfin_id in server[1].users.items():
|
||||||
if (
|
if (
|
||||||
@@ -81,3 +89,45 @@ def generate_server_users(server, users):
|
|||||||
server_users[jellyfin_user] = jellyfin_id
|
server_users[jellyfin_user] = jellyfin_id
|
||||||
|
|
||||||
return server_users
|
return server_users
|
||||||
|
|
||||||
|
|
||||||
|
def setup_users(
|
||||||
|
server_1, server_2, blacklist_users, whitelist_users, user_mapping=None
|
||||||
|
):
|
||||||
|
server_1_users = generate_user_list(server_1)
|
||||||
|
server_2_users = generate_user_list(server_2)
|
||||||
|
logger(f"Server 1 users: {server_1_users}", 1)
|
||||||
|
logger(f"Server 2 users: {server_2_users}", 1)
|
||||||
|
|
||||||
|
users = combine_user_lists(server_1_users, server_2_users, user_mapping)
|
||||||
|
logger(f"User list that exist on both servers {users}", 1)
|
||||||
|
|
||||||
|
users_filtered = filter_user_lists(users, blacklist_users, whitelist_users)
|
||||||
|
logger(f"Filtered user list {users_filtered}", 1)
|
||||||
|
|
||||||
|
output_server_1_users = generate_server_users(server_1, users_filtered)
|
||||||
|
output_server_2_users = generate_server_users(server_2, users_filtered)
|
||||||
|
|
||||||
|
# Check if users is none or empty
|
||||||
|
if output_server_1_users is None or len(output_server_1_users) == 0:
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
return output_server_1_users, output_server_2_users
|
||||||
|
|||||||
221
src/watched.py
221
src/watched.py
@@ -1,32 +1,50 @@
|
|||||||
import copy
|
import copy
|
||||||
|
|
||||||
from src.functions import (
|
from src.functions import logger, search_mapping, contains_nested
|
||||||
logger,
|
|
||||||
search_mapping,
|
|
||||||
)
|
|
||||||
|
|
||||||
from src.library import generate_library_guids_dict
|
from src.library import generate_library_guids_dict
|
||||||
|
|
||||||
|
|
||||||
def combine_watched_dicts(dicts: list):
|
def check_remove_entry(video, library, video_index, library_watched_list_2):
|
||||||
combined_dict = {}
|
if video_index is not None:
|
||||||
for single_dict in dicts:
|
if (
|
||||||
for key, value in single_dict.items():
|
library_watched_list_2["completed"][video_index]
|
||||||
if key not in combined_dict:
|
== video["status"]["completed"]
|
||||||
combined_dict[key] = {}
|
) and (library_watched_list_2["time"][video_index] == video["status"]["time"]):
|
||||||
for subkey, subvalue in value.items():
|
logger(
|
||||||
if subkey in combined_dict[key]:
|
f"Removing {video['title']} from {library} due to exact match",
|
||||||
# If the subkey already exists in the combined dictionary,
|
3,
|
||||||
# check if the values are different and raise an exception if they are
|
|
||||||
if combined_dict[key][subkey] != subvalue:
|
|
||||||
raise ValueError(
|
|
||||||
f"Conflicting values for subkey '{subkey}' under key '{key}'"
|
|
||||||
)
|
)
|
||||||
else:
|
return True
|
||||||
# If the subkey does not exist in the combined dictionary, add it
|
elif (
|
||||||
combined_dict[key][subkey] = subvalue
|
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 combined_dict
|
return False
|
||||||
|
|
||||||
|
|
||||||
def cleanup_watched(
|
def cleanup_watched(
|
||||||
@@ -60,57 +78,44 @@ def cleanup_watched(
|
|||||||
# Movies
|
# Movies
|
||||||
if isinstance(watched_list_1[user_1][library_1], list):
|
if isinstance(watched_list_1[user_1][library_1], list):
|
||||||
for movie in watched_list_1[user_1][library_1]:
|
for movie in watched_list_1[user_1][library_1]:
|
||||||
if is_movie_in_dict(movie, movies_watched_list_2_keys_dict):
|
movie_index = get_movie_index_in_dict(
|
||||||
logger(f"Removing {movie} from {library_1}", 3)
|
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)
|
modified_watched_list_1[user_1][library_1].remove(movie)
|
||||||
|
|
||||||
# TV Shows
|
# TV Shows
|
||||||
elif isinstance(watched_list_1[user_1][library_1], dict):
|
elif isinstance(watched_list_1[user_1][library_1], dict):
|
||||||
for show_key_1 in watched_list_1[user_1][library_1].keys():
|
for show_key_1 in watched_list_1[user_1][library_1].keys():
|
||||||
show_key_dict = dict(show_key_1)
|
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][
|
# Filter the episode_watched_list_2_keys_dict dictionary to handle cases
|
||||||
season
|
# where episode location names are not unique such as S01E01.mkv
|
||||||
]:
|
filtered_episode_watched_list_2_keys_dict = (
|
||||||
if is_episode_in_dict(
|
filter_episode_watched_list_2_keys_dict(
|
||||||
episode, episode_watched_list_2_keys_dict
|
episode_watched_list_2_keys_dict, show_key_dict
|
||||||
):
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
for episode in watched_list_1[user_1][library_1][show_key_1]:
|
||||||
|
episode_index = get_episode_index_in_dict(
|
||||||
|
episode, filtered_episode_watched_list_2_keys_dict
|
||||||
|
)
|
||||||
|
if episode_index is not None:
|
||||||
|
if check_remove_entry(
|
||||||
|
episode,
|
||||||
|
library_1,
|
||||||
|
episode_index,
|
||||||
|
episode_watched_list_2_keys_dict,
|
||||||
|
):
|
||||||
modified_watched_list_1[user_1][library_1][
|
modified_watched_list_1[user_1][library_1][
|
||||||
show_key_1
|
show_key_1
|
||||||
][season].remove(episode)
|
].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 {show_key_dict['title']} because it is empty",
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
del modified_watched_list_1[user_1][library_1][
|
|
||||||
show_key_1
|
|
||||||
][season]
|
|
||||||
|
|
||||||
# Remove empty shows
|
# Remove empty shows
|
||||||
if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0:
|
if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0:
|
||||||
@@ -148,7 +153,7 @@ def get_other(watched_list, object_1, object_2):
|
|||||||
return None
|
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
|
# Iterate through the keys and values of the movie dictionary
|
||||||
for movie_key, movie_value in movie.items():
|
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
|
# If the key is "locations", check if the "locations" key is present in the movies_watched_list_2_keys_dict dictionary
|
||||||
@@ -156,37 +161,95 @@ def is_movie_in_dict(movie, movies_watched_list_2_keys_dict):
|
|||||||
if "locations" in movies_watched_list_2_keys_dict.keys():
|
if "locations" in movies_watched_list_2_keys_dict.keys():
|
||||||
# Iterate through the locations in the movie dictionary
|
# Iterate through the locations in the movie dictionary
|
||||||
for location in movie_value:
|
for location in movie_value:
|
||||||
# If the location is in the movies_watched_list_2_keys_dict dictionary, return True
|
# If the location is in the movies_watched_list_2_keys_dict dictionary, return index of the key
|
||||||
if location in movies_watched_list_2_keys_dict["locations"]:
|
return contains_nested(
|
||||||
return True
|
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
|
# If the key is not "locations", check if the movie_key is present in the movies_watched_list_2_keys_dict dictionary
|
||||||
else:
|
else:
|
||||||
if movie_key in movies_watched_list_2_keys_dict.keys():
|
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 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]:
|
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
|
# 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 filter_episode_watched_list_2_keys_dict(
|
||||||
|
episode_watched_list_2_keys_dict, show_key_dict
|
||||||
|
):
|
||||||
|
# If the episode_watched_list_2_keys_dict dictionary is empty, missing show then return an empty dictionary
|
||||||
|
if (
|
||||||
|
len(episode_watched_list_2_keys_dict) == 0
|
||||||
|
or "show" not in episode_watched_list_2_keys_dict.keys()
|
||||||
|
):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Filter the episode_watched_list_2_keys_dict dictionary to only include values for the correct show
|
||||||
|
filtered_episode_watched_list_2_keys_dict = {}
|
||||||
|
show_indecies = []
|
||||||
|
|
||||||
|
# Iterate through episode_watched_list_2_keys_dict["show"] and find the indecies that match show_key_dict
|
||||||
|
for show_index, show_value in enumerate(episode_watched_list_2_keys_dict["show"]):
|
||||||
|
# Iterate through the keys and values of the show_value dictionary and check if they match show_key_dict
|
||||||
|
for show_key, show_key_value in show_value.items():
|
||||||
|
if show_key == "locations":
|
||||||
|
# Iterate through the locations in the show_value dictionary
|
||||||
|
for location in show_key_value:
|
||||||
|
# If the location is in the episode_watched_list_2_keys_dict dictionary, return index of the key
|
||||||
|
if (
|
||||||
|
contains_nested(location, show_key_dict["locations"])
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
show_indecies.append(show_index)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if show_key in show_key_dict.keys():
|
||||||
|
if show_key_value == show_key_dict[show_key]:
|
||||||
|
show_indecies.append(show_index)
|
||||||
|
break
|
||||||
|
|
||||||
|
# lists
|
||||||
|
indecies = list(set(show_indecies))
|
||||||
|
|
||||||
|
# If there are no indecies that match the show, return an empty dictionary
|
||||||
|
if len(indecies) == 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Create a copy of the dictionary with indecies that match the show and none that don't
|
||||||
|
for key, value in episode_watched_list_2_keys_dict.items():
|
||||||
|
if key not in filtered_episode_watched_list_2_keys_dict:
|
||||||
|
filtered_episode_watched_list_2_keys_dict[key] = []
|
||||||
|
|
||||||
|
for index, _ in enumerate(value):
|
||||||
|
if index in indecies:
|
||||||
|
filtered_episode_watched_list_2_keys_dict[key].append(value[index])
|
||||||
|
else:
|
||||||
|
filtered_episode_watched_list_2_keys_dict[key].append(None)
|
||||||
|
|
||||||
|
return filtered_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
|
# Iterate through the keys and values of the episode dictionary
|
||||||
for episode_key, episode_value in episode.items():
|
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 in episode_watched_list_2_keys_dict.keys():
|
||||||
if episode_key == "locations":
|
if episode_key == "locations":
|
||||||
if "locations" in episode_watched_list_2_keys_dict.keys():
|
|
||||||
# Iterate through the locations in the episode dictionary
|
# Iterate through the locations in the episode dictionary
|
||||||
for location in episode_value:
|
for location in episode_value:
|
||||||
# If the location is in the episode_watched_list_2_keys_dict dictionary, return True
|
# If the location is in the episode_watched_list_2_keys_dict dictionary, return index of the key
|
||||||
if location in episode_watched_list_2_keys_dict["locations"]:
|
return contains_nested(
|
||||||
return True
|
location, episode_watched_list_2_keys_dict["locations"]
|
||||||
# If the key is not "locations", check if the episode_key is present in the episode_watched_list_2_keys_dict dictionary
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if episode_key in episode_watched_list_2_keys_dict.keys():
|
|
||||||
# If the episode_value is in the episode_watched_list_2_keys_dict dictionary, return True
|
# 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]:
|
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
|
# If the loop completes without finding a match, return False
|
||||||
return False
|
return None
|
||||||
|
|||||||
116
test/ci_emby.env
Normal file
116
test/ci_emby.env
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 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 = "True"
|
||||||
|
|
||||||
|
## Debugging level, "info" is default, "debug" is more verbose
|
||||||
|
DEBUG_LEVEL = "debug"
|
||||||
|
|
||||||
|
## If set to true then the script will only run once and then exit
|
||||||
|
RUN_ONLY_ONCE = "True"
|
||||||
|
|
||||||
|
## How often to run the script in seconds
|
||||||
|
SLEEP_DURATION = 10
|
||||||
|
|
||||||
|
## Log file where all output will be written to
|
||||||
|
LOG_FILE = "log.log"
|
||||||
|
|
||||||
|
## Mark file where all shows/movies that have been marked as played will be written to
|
||||||
|
MARK_FILE = "mark.log"
|
||||||
|
|
||||||
|
## Timeout for requests for jellyfin
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
## Max threads for processing
|
||||||
|
MAX_THREADS = 2
|
||||||
|
|
||||||
|
## Generate guids
|
||||||
|
## Generating guids is a slow process, so this is a way to speed up the process
|
||||||
|
# by using the location only, useful when using same files on multiple servers
|
||||||
|
GENERATE_GUIDS = "True"
|
||||||
|
|
||||||
|
## Generate locations
|
||||||
|
## Generating locations is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the guid only, useful when using different files on multiple servers
|
||||||
|
GENERATE_LOCATIONS = "True"
|
||||||
|
|
||||||
|
## Map usernames between servers in the event that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
USER_MAPPING = {"JellyUser":"jellyplex_watched"}
|
||||||
|
|
||||||
|
## Map libraries between servers in the even that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
LIBRARY_MAPPING = { "Shows": "TV Shows" }
|
||||||
|
|
||||||
|
|
||||||
|
## 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 seperated for multiple options
|
||||||
|
#BLACKLIST_LIBRARY = ""
|
||||||
|
#WHITELIST_LIBRARY = "Movies"
|
||||||
|
#BLACKLIST_LIBRARY_TYPE = "Series"
|
||||||
|
#WHITELIST_LIBRARY_TYPE = "Movies, movie"
|
||||||
|
#BLACKLIST_USERS = ""
|
||||||
|
WHITELIST_USERS = "jellyplex_watched"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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 seperated list for multiple servers
|
||||||
|
PLEX_BASEURL = "http://localhost:32400"
|
||||||
|
|
||||||
|
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
|
||||||
|
|
||||||
|
## If not using plex token then use username and password of the server admin along with the servername
|
||||||
|
## Comma seperated 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 = "True"
|
||||||
|
|
||||||
|
# Jellyfin
|
||||||
|
|
||||||
|
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
JELLYFIN_BASEURL = "http://localhost:8096"
|
||||||
|
|
||||||
|
## 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 = "d773c4db3ecc4b028fc0904d9694804c"
|
||||||
|
|
||||||
|
# Emby
|
||||||
|
|
||||||
|
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_BASEURL = "http://localhost:8097"
|
||||||
|
|
||||||
|
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
|
||||||
|
|
||||||
|
|
||||||
|
# Syncing Options
|
||||||
|
|
||||||
|
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
||||||
|
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
||||||
|
SYNC_FROM_PLEX_TO_JELLYFIN = "False"
|
||||||
|
SYNC_FROM_PLEX_TO_PLEX = "False"
|
||||||
|
SYNC_FROM_PLEX_TO_EMBY = "False"
|
||||||
|
|
||||||
|
SYNC_FROM_JELLYFIN_TO_PLEX = "False"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_EMBY = "False"
|
||||||
|
|
||||||
|
SYNC_FROM_EMBY_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_EMBY = "True"
|
||||||
116
test/ci_guids.env
Normal file
116
test/ci_guids.env
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 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 = "True"
|
||||||
|
|
||||||
|
## Debugging level, "info" is default, "debug" is more verbose
|
||||||
|
DEBUG_LEVEL = "debug"
|
||||||
|
|
||||||
|
## If set to true then the script will only run once and then exit
|
||||||
|
RUN_ONLY_ONCE = "True"
|
||||||
|
|
||||||
|
## How often to run the script in seconds
|
||||||
|
SLEEP_DURATION = 10
|
||||||
|
|
||||||
|
## Log file where all output will be written to
|
||||||
|
LOG_FILE = "log.log"
|
||||||
|
|
||||||
|
## Mark file where all shows/movies that have been marked as played will be written to
|
||||||
|
MARK_FILE = "mark.log"
|
||||||
|
|
||||||
|
## Timeout for requests for jellyfin
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
## Max threads for processing
|
||||||
|
MAX_THREADS = 2
|
||||||
|
|
||||||
|
## Generate guids
|
||||||
|
## Generating guids is a slow process, so this is a way to speed up the process
|
||||||
|
# by using the location only, useful when using same files on multiple servers
|
||||||
|
GENERATE_GUIDS = "True"
|
||||||
|
|
||||||
|
## Generate locations
|
||||||
|
## Generating locations is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the guid only, useful when using different files on multiple servers
|
||||||
|
GENERATE_LOCATIONS = "False"
|
||||||
|
|
||||||
|
## Map usernames between servers in the event that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
USER_MAPPING = {"JellyUser":"jellyplex_watched"}
|
||||||
|
|
||||||
|
## Map libraries between servers in the even that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
LIBRARY_MAPPING = { "Shows": "TV Shows" }
|
||||||
|
|
||||||
|
|
||||||
|
## 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 seperated for multiple options
|
||||||
|
#BLACKLIST_LIBRARY = ""
|
||||||
|
#WHITELIST_LIBRARY = "Movies"
|
||||||
|
#BLACKLIST_LIBRARY_TYPE = "Series"
|
||||||
|
#WHITELIST_LIBRARY_TYPE = "Movies, movie"
|
||||||
|
#BLACKLIST_USERS = ""
|
||||||
|
WHITELIST_USERS = "jellyplex_watched"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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 seperated list for multiple servers
|
||||||
|
PLEX_BASEURL = "http://localhost:32400"
|
||||||
|
|
||||||
|
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
|
||||||
|
|
||||||
|
## If not using plex token then use username and password of the server admin along with the servername
|
||||||
|
## Comma seperated 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 = "True"
|
||||||
|
|
||||||
|
# Jellyfin
|
||||||
|
|
||||||
|
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
JELLYFIN_BASEURL = "http://localhost:8096"
|
||||||
|
|
||||||
|
## 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 = "d773c4db3ecc4b028fc0904d9694804c"
|
||||||
|
|
||||||
|
# Emby
|
||||||
|
|
||||||
|
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_BASEURL = "http://localhost:8097"
|
||||||
|
|
||||||
|
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
|
||||||
|
|
||||||
|
|
||||||
|
# Syncing Options
|
||||||
|
|
||||||
|
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
||||||
|
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
||||||
|
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_EMBY_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_EMBY = "True"
|
||||||
116
test/ci_jellyfin.env
Normal file
116
test/ci_jellyfin.env
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 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 = "True"
|
||||||
|
|
||||||
|
## Debugging level, "info" is default, "debug" is more verbose
|
||||||
|
DEBUG_LEVEL = "debug"
|
||||||
|
|
||||||
|
## If set to true then the script will only run once and then exit
|
||||||
|
RUN_ONLY_ONCE = "True"
|
||||||
|
|
||||||
|
## How often to run the script in seconds
|
||||||
|
SLEEP_DURATION = 10
|
||||||
|
|
||||||
|
## Log file where all output will be written to
|
||||||
|
LOG_FILE = "log.log"
|
||||||
|
|
||||||
|
## Mark file where all shows/movies that have been marked as played will be written to
|
||||||
|
MARK_FILE = "mark.log"
|
||||||
|
|
||||||
|
## Timeout for requests for jellyfin
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
## Max threads for processing
|
||||||
|
MAX_THREADS = 2
|
||||||
|
|
||||||
|
## Generate guids
|
||||||
|
## Generating guids is a slow process, so this is a way to speed up the process
|
||||||
|
# by using the location only, useful when using same files on multiple servers
|
||||||
|
GENERATE_GUIDS = "True"
|
||||||
|
|
||||||
|
## Generate locations
|
||||||
|
## Generating locations is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the guid only, useful when using different files on multiple servers
|
||||||
|
GENERATE_LOCATIONS = "True"
|
||||||
|
|
||||||
|
## Map usernames between servers in the event that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
USER_MAPPING = {"JellyUser":"jellyplex_watched"}
|
||||||
|
|
||||||
|
## Map libraries between servers in the even that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
LIBRARY_MAPPING = { "Shows": "TV Shows" }
|
||||||
|
|
||||||
|
|
||||||
|
## 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 seperated for multiple options
|
||||||
|
#BLACKLIST_LIBRARY = ""
|
||||||
|
#WHITELIST_LIBRARY = "Movies"
|
||||||
|
#BLACKLIST_LIBRARY_TYPE = "Series"
|
||||||
|
#WHITELIST_LIBRARY_TYPE = "Movies, movie"
|
||||||
|
#BLACKLIST_USERS = ""
|
||||||
|
WHITELIST_USERS = "jellyplex_watched"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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 seperated list for multiple servers
|
||||||
|
PLEX_BASEURL = "http://localhost:32400"
|
||||||
|
|
||||||
|
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
|
||||||
|
|
||||||
|
## If not using plex token then use username and password of the server admin along with the servername
|
||||||
|
## Comma seperated 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 = "True"
|
||||||
|
|
||||||
|
# Jellyfin
|
||||||
|
|
||||||
|
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
JELLYFIN_BASEURL = "http://localhost:8096"
|
||||||
|
|
||||||
|
## 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 = "d773c4db3ecc4b028fc0904d9694804c"
|
||||||
|
|
||||||
|
# Emby
|
||||||
|
|
||||||
|
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_BASEURL = "http://localhost:8097"
|
||||||
|
|
||||||
|
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
|
||||||
|
|
||||||
|
|
||||||
|
# Syncing Options
|
||||||
|
|
||||||
|
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
||||||
|
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
||||||
|
SYNC_FROM_PLEX_TO_JELLYFIN = "False"
|
||||||
|
SYNC_FROM_PLEX_TO_PLEX = "False"
|
||||||
|
SYNC_FROM_PLEX_TO_EMBY = "False"
|
||||||
|
|
||||||
|
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_EMBY_TO_PLEX = "False"
|
||||||
|
SYNC_FROM_EMBY_TO_JELLYFIN = "False"
|
||||||
|
SYNC_FROM_EMBY_TO_EMBY = "False"
|
||||||
116
test/ci_locations.env
Normal file
116
test/ci_locations.env
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 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 = "True"
|
||||||
|
|
||||||
|
## Debugging level, "info" is default, "debug" is more verbose
|
||||||
|
DEBUG_LEVEL = "debug"
|
||||||
|
|
||||||
|
## If set to true then the script will only run once and then exit
|
||||||
|
RUN_ONLY_ONCE = "True"
|
||||||
|
|
||||||
|
## How often to run the script in seconds
|
||||||
|
SLEEP_DURATION = 10
|
||||||
|
|
||||||
|
## Log file where all output will be written to
|
||||||
|
LOG_FILE = "log.log"
|
||||||
|
|
||||||
|
## Mark file where all shows/movies that have been marked as played will be written to
|
||||||
|
MARK_FILE = "mark.log"
|
||||||
|
|
||||||
|
## Timeout for requests for jellyfin
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
## Max threads for processing
|
||||||
|
MAX_THREADS = 2
|
||||||
|
|
||||||
|
## Generate guids
|
||||||
|
## Generating guids is a slow process, so this is a way to speed up the process
|
||||||
|
# by using the location only, useful when using same files on multiple servers
|
||||||
|
GENERATE_GUIDS = "False"
|
||||||
|
|
||||||
|
## Generate locations
|
||||||
|
## Generating locations is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the guid only, useful when using different files on multiple servers
|
||||||
|
GENERATE_LOCATIONS = "True"
|
||||||
|
|
||||||
|
## Map usernames between servers in the event that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
USER_MAPPING = {"JellyUser":"jellyplex_watched"}
|
||||||
|
|
||||||
|
## Map libraries between servers in the even that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
LIBRARY_MAPPING = { "Shows": "TV Shows" }
|
||||||
|
|
||||||
|
|
||||||
|
## 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 seperated for multiple options
|
||||||
|
#BLACKLIST_LIBRARY = ""
|
||||||
|
#WHITELIST_LIBRARY = "Movies"
|
||||||
|
#BLACKLIST_LIBRARY_TYPE = "Series"
|
||||||
|
#WHITELIST_LIBRARY_TYPE = "Movies, movie"
|
||||||
|
#BLACKLIST_USERS = ""
|
||||||
|
WHITELIST_USERS = "jellyplex_watched"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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 seperated list for multiple servers
|
||||||
|
PLEX_BASEURL = "http://localhost:32400"
|
||||||
|
|
||||||
|
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
|
||||||
|
|
||||||
|
## If not using plex token then use username and password of the server admin along with the servername
|
||||||
|
## Comma seperated 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 = "True"
|
||||||
|
|
||||||
|
# Jellyfin
|
||||||
|
|
||||||
|
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
JELLYFIN_BASEURL = "http://localhost:8096"
|
||||||
|
|
||||||
|
## 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 = "d773c4db3ecc4b028fc0904d9694804c"
|
||||||
|
|
||||||
|
# Emby
|
||||||
|
|
||||||
|
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_BASEURL = "http://localhost:8097"
|
||||||
|
|
||||||
|
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
|
||||||
|
|
||||||
|
|
||||||
|
# Syncing Options
|
||||||
|
|
||||||
|
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
||||||
|
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
||||||
|
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_EMBY_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_EMBY = "True"
|
||||||
116
test/ci_plex.env
Normal file
116
test/ci_plex.env
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 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 = "True"
|
||||||
|
|
||||||
|
## Debugging level, "info" is default, "debug" is more verbose
|
||||||
|
DEBUG_LEVEL = "debug"
|
||||||
|
|
||||||
|
## If set to true then the script will only run once and then exit
|
||||||
|
RUN_ONLY_ONCE = "True"
|
||||||
|
|
||||||
|
## How often to run the script in seconds
|
||||||
|
SLEEP_DURATION = 10
|
||||||
|
|
||||||
|
## Log file where all output will be written to
|
||||||
|
LOG_FILE = "log.log"
|
||||||
|
|
||||||
|
## Mark file where all shows/movies that have been marked as played will be written to
|
||||||
|
MARK_FILE = "mark.log"
|
||||||
|
|
||||||
|
## Timeout for requests for jellyfin
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
## Max threads for processing
|
||||||
|
MAX_THREADS = 2
|
||||||
|
|
||||||
|
## Generate guids
|
||||||
|
## Generating guids is a slow process, so this is a way to speed up the process
|
||||||
|
# by using the location only, useful when using same files on multiple servers
|
||||||
|
GENERATE_GUIDS = "True"
|
||||||
|
|
||||||
|
## Generate locations
|
||||||
|
## Generating locations is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the guid only, useful when using different files on multiple servers
|
||||||
|
GENERATE_LOCATIONS = "True"
|
||||||
|
|
||||||
|
## Map usernames between servers in the event that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
USER_MAPPING = {"JellyUser":"jellyplex_watched"}
|
||||||
|
|
||||||
|
## Map libraries between servers in the even that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
LIBRARY_MAPPING = { "Shows": "TV Shows" }
|
||||||
|
|
||||||
|
|
||||||
|
## 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 seperated for multiple options
|
||||||
|
#BLACKLIST_LIBRARY = ""
|
||||||
|
#WHITELIST_LIBRARY = "Movies"
|
||||||
|
#BLACKLIST_LIBRARY_TYPE = "Series"
|
||||||
|
#WHITELIST_LIBRARY_TYPE = "Movies, movie"
|
||||||
|
#BLACKLIST_USERS = ""
|
||||||
|
WHITELIST_USERS = "jellyplex_watched"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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 seperated list for multiple servers
|
||||||
|
PLEX_BASEURL = "http://localhost:32400"
|
||||||
|
|
||||||
|
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
|
||||||
|
|
||||||
|
## If not using plex token then use username and password of the server admin along with the servername
|
||||||
|
## Comma seperated 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 = "True"
|
||||||
|
|
||||||
|
# Jellyfin
|
||||||
|
|
||||||
|
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
JELLYFIN_BASEURL = "http://localhost:8096"
|
||||||
|
|
||||||
|
## 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 = "d773c4db3ecc4b028fc0904d9694804c"
|
||||||
|
|
||||||
|
# Emby
|
||||||
|
|
||||||
|
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_BASEURL = "http://localhost:8097"
|
||||||
|
|
||||||
|
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
|
||||||
|
|
||||||
|
|
||||||
|
# Syncing Options
|
||||||
|
|
||||||
|
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
||||||
|
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
||||||
|
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_JELLYFIN_TO_PLEX = "False"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_EMBY = "False"
|
||||||
|
|
||||||
|
SYNC_FROM_EMBY_TO_PLEX = "False"
|
||||||
|
SYNC_FROM_EMBY_TO_JELLYFIN = "False"
|
||||||
|
SYNC_FROM_EMBY_TO_EMBY = "False"
|
||||||
116
test/ci_write.env
Normal file
116
test/ci_write.env
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Global Settings
|
||||||
|
|
||||||
|
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||||
|
DRYRUN = "False"
|
||||||
|
|
||||||
|
## Additional logging information
|
||||||
|
DEBUG = "True"
|
||||||
|
|
||||||
|
## Debugging level, "info" is default, "debug" is more verbose
|
||||||
|
DEBUG_LEVEL = "debug"
|
||||||
|
|
||||||
|
## If set to true then the script will only run once and then exit
|
||||||
|
RUN_ONLY_ONCE = "True"
|
||||||
|
|
||||||
|
## How often to run the script in seconds
|
||||||
|
SLEEP_DURATION = 10
|
||||||
|
|
||||||
|
## Log file where all output will be written to
|
||||||
|
LOG_FILE = "log.log"
|
||||||
|
|
||||||
|
## Mark file where all shows/movies that have been marked as played will be written to
|
||||||
|
MARK_FILE = "mark.log"
|
||||||
|
|
||||||
|
## Timeout for requests for jellyfin
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
## Max threads for processing
|
||||||
|
MAX_THREADS = 2
|
||||||
|
|
||||||
|
## Generate guids
|
||||||
|
## Generating guids is a slow process, so this is a way to speed up the process
|
||||||
|
# by using the location only, useful when using same files on multiple servers
|
||||||
|
GENERATE_GUIDS = "True"
|
||||||
|
|
||||||
|
## Generate locations
|
||||||
|
## Generating locations is a slow process, so this is a way to speed up the process
|
||||||
|
## by using the guid only, useful when using different files on multiple servers
|
||||||
|
GENERATE_LOCATIONS = "True"
|
||||||
|
|
||||||
|
## Map usernames between servers in the event that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
USER_MAPPING = {"JellyUser":"jellyplex_watched"}
|
||||||
|
|
||||||
|
## Map libraries between servers in the even that they are different, order does not matter
|
||||||
|
## Comma seperated for multiple options
|
||||||
|
LIBRARY_MAPPING = { "Shows": "TV Shows" }
|
||||||
|
|
||||||
|
|
||||||
|
## 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 seperated for multiple options
|
||||||
|
#BLACKLIST_LIBRARY = ""
|
||||||
|
#WHITELIST_LIBRARY = "Movies"
|
||||||
|
#BLACKLIST_LIBRARY_TYPE = "Series"
|
||||||
|
#WHITELIST_LIBRARY_TYPE = "Movies, movie"
|
||||||
|
#BLACKLIST_USERS = ""
|
||||||
|
WHITELIST_USERS = "jellyplex_watched"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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 seperated list for multiple servers
|
||||||
|
PLEX_BASEURL = "http://localhost:32400"
|
||||||
|
|
||||||
|
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
|
||||||
|
|
||||||
|
## If not using plex token then use username and password of the server admin along with the servername
|
||||||
|
## Comma seperated 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 = "True"
|
||||||
|
|
||||||
|
# Jellyfin
|
||||||
|
|
||||||
|
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
JELLYFIN_BASEURL = "http://localhost:8096"
|
||||||
|
|
||||||
|
## 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 = "d773c4db3ecc4b028fc0904d9694804c"
|
||||||
|
|
||||||
|
# Emby
|
||||||
|
|
||||||
|
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_BASEURL = "http://localhost:8097"
|
||||||
|
|
||||||
|
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
|
||||||
|
## Comma seperated list for multiple servers
|
||||||
|
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
|
||||||
|
|
||||||
|
|
||||||
|
# Syncing Options
|
||||||
|
|
||||||
|
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
|
||||||
|
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
|
||||||
|
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_PLEX_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
|
||||||
|
|
||||||
|
SYNC_FROM_EMBY_TO_PLEX = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
|
||||||
|
SYNC_FROM_EMBY_TO_EMBY = "True"
|
||||||
@@ -1 +1 @@
|
|||||||
pytest
|
pytest==7.3.0
|
||||||
|
|||||||
@@ -42,47 +42,69 @@ show_list = {
|
|||||||
("tvdb", "392256"),
|
("tvdb", "392256"),
|
||||||
("title", "The Last of Us"),
|
("title", "The Last of Us"),
|
||||||
}
|
}
|
||||||
): {
|
): [
|
||||||
"Season 1": [
|
|
||||||
{
|
{
|
||||||
"imdb": "tt11957006",
|
"imdb": "tt11957006",
|
||||||
"tmdb": "2181581",
|
"tmdb": "2181581",
|
||||||
"tvdb": "8444132",
|
"tvdb": "8444132",
|
||||||
"locations": (
|
"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},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
movie_list = [
|
movie_list = [
|
||||||
{
|
{
|
||||||
"title": "Coco",
|
"title": "Coco",
|
||||||
"imdb": "tt2380307",
|
"imdb": "tt2380307",
|
||||||
"tmdb": "354912",
|
"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 = {
|
show_titles = {
|
||||||
"imdb": ["tt3581920"],
|
"imdb": ["tt3581920"],
|
||||||
"locations": ["The Last of Us"],
|
"locations": [("The Last of Us",)],
|
||||||
"tmdb": ["100088"],
|
"tmdb": ["100088"],
|
||||||
"tvdb": ["392256"],
|
"tvdb": ["392256"],
|
||||||
}
|
}
|
||||||
episode_titles = {
|
episode_titles = {
|
||||||
"imdb": ["tt11957006"],
|
"imdb": ["tt11957006"],
|
||||||
"locations": [
|
"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"],
|
"tmdb": ["2181581"],
|
||||||
"tvdb": ["8444132"],
|
"tvdb": ["8444132"],
|
||||||
|
"completed": [True],
|
||||||
|
"time": [0],
|
||||||
|
"show": [
|
||||||
|
{
|
||||||
|
"imdb": "tt3581920",
|
||||||
|
"locations": ("The Last of Us",),
|
||||||
|
"title": "The Last of Us",
|
||||||
|
"tmdb": "100088",
|
||||||
|
"tvdb": "392256",
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
movie_titles = {
|
movie_titles = {
|
||||||
"imdb": ["tt2380307"],
|
"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"],
|
"title": ["coco"],
|
||||||
"tmdb": ["354912"],
|
"tmdb": ["354912"],
|
||||||
|
"completed": [True],
|
||||||
|
"time": [0],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,7 +155,7 @@ def test_check_skip_logic():
|
|||||||
library_mapping,
|
library_mapping,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert skip_reason == None
|
assert skip_reason is None
|
||||||
|
|
||||||
|
|
||||||
def test_check_blacklist_logic():
|
def test_check_blacklist_logic():
|
||||||
@@ -182,7 +204,7 @@ def test_check_blacklist_logic():
|
|||||||
library_other,
|
library_other,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert skip_reason == None
|
assert skip_reason is None
|
||||||
|
|
||||||
library_title = "Movies"
|
library_title = "Movies"
|
||||||
library_type = "movies"
|
library_type = "movies"
|
||||||
@@ -195,7 +217,7 @@ def test_check_blacklist_logic():
|
|||||||
library_other,
|
library_other,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert skip_reason == None
|
assert skip_reason is None
|
||||||
|
|
||||||
|
|
||||||
def test_check_whitelist_logic():
|
def test_check_whitelist_logic():
|
||||||
@@ -244,7 +266,7 @@ def test_check_whitelist_logic():
|
|||||||
library_other,
|
library_other,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert skip_reason == None
|
assert skip_reason is None
|
||||||
|
|
||||||
library_title = "Movies"
|
library_title = "Movies"
|
||||||
library_type = "movies"
|
library_type = "movies"
|
||||||
@@ -257,7 +279,7 @@ def test_check_whitelist_logic():
|
|||||||
library_other,
|
library_other,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert skip_reason == None
|
assert skip_reason is None
|
||||||
|
|
||||||
|
|
||||||
def test_show_title_dict():
|
def test_show_title_dict():
|
||||||
|
|||||||
@@ -13,107 +13,423 @@ parent = os.path.dirname(current)
|
|||||||
# the sys.path.
|
# the sys.path.
|
||||||
sys.path.append(parent)
|
sys.path.append(parent)
|
||||||
|
|
||||||
from src.watched import cleanup_watched, combine_watched_dicts
|
from src.watched import cleanup_watched
|
||||||
|
|
||||||
tv_shows_watched_list_1 = {
|
tv_shows_watched_list_1 = {
|
||||||
frozenset(
|
frozenset(
|
||||||
{
|
{
|
||||||
("tvdb", "75710"),
|
("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
|
||||||
("title", "Criminal Minds"),
|
("imdb", "tt0436992"),
|
||||||
("imdb", "tt0452046"),
|
("tmdb", "57243"),
|
||||||
("locations", ("Criminal Minds",)),
|
("tvdb", "78804"),
|
||||||
("tmdb", "4057"),
|
("title", "Doctor Who (2005)"),
|
||||||
}
|
}
|
||||||
): {
|
): [
|
||||||
"Season 1": [
|
|
||||||
{
|
{
|
||||||
"imdb": "tt0550489",
|
"imdb": "tt0563001",
|
||||||
"tmdb": "282843",
|
"tmdb": "968589",
|
||||||
"tvdb": "176357",
|
"tvdb": "295296",
|
||||||
"locations": (
|
"title": "The Unquiet Dead",
|
||||||
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
|
"locations": ("S01E03.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt0562985",
|
||||||
|
"tmdb": "968590",
|
||||||
|
"tvdb": "295297",
|
||||||
|
"title": "Aliens of London (1)",
|
||||||
|
"locations": ("S01E04.mkv",),
|
||||||
|
"status": {"completed": False, "time": 240000},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt0563003",
|
||||||
|
"tmdb": "968592",
|
||||||
|
"tvdb": "295298",
|
||||||
|
"title": "World War Three (2)",
|
||||||
|
"locations": ("S01E05.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
("title", "Monarch: Legacy of Monsters"),
|
||||||
|
("imdb", "tt17220216"),
|
||||||
|
("tvdb", "422598"),
|
||||||
|
("tmdb", "202411"),
|
||||||
|
(
|
||||||
|
"locations",
|
||||||
|
("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||||
),
|
),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"imdb": "tt21255044",
|
||||||
|
"tmdb": "4661246",
|
||||||
|
"tvdb": "10009418",
|
||||||
|
"title": "Secrets and Lies",
|
||||||
|
"locations": ("S01E03.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"imdb": "tt0550487",
|
"imdb": "tt21255050",
|
||||||
"tmdb": "282861",
|
"tmdb": "4712059",
|
||||||
"tvdb": "300385",
|
"tvdb": "10009419",
|
||||||
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",),
|
"title": "Parallels and Interiors",
|
||||||
|
"locations": ("S01E04.mkv",),
|
||||||
|
"status": {"completed": False, "time": 240000},
|
||||||
},
|
},
|
||||||
]
|
{
|
||||||
|
"imdb": "tt23787572",
|
||||||
|
"tmdb": "4712061",
|
||||||
|
"tvdb": "10009420",
|
||||||
|
"title": "The Way Out",
|
||||||
|
"locations": ("S01E05.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
},
|
},
|
||||||
frozenset({("title", "Test"), ("locations", ("Test",))}): {
|
],
|
||||||
"Season 1": [
|
frozenset(
|
||||||
{"locations": ("Test S01E01.mkv",)},
|
{
|
||||||
{"locations": ("Test S01E02.mkv",)},
|
("tmdb", "125928"),
|
||||||
]
|
("imdb", "tt14681924"),
|
||||||
|
("tvdb", "403172"),
|
||||||
|
(
|
||||||
|
"locations",
|
||||||
|
("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",),
|
||||||
|
),
|
||||||
|
("title", "My Adventures with Superman"),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"imdb": "tt15699926",
|
||||||
|
"tmdb": "3070048",
|
||||||
|
"tvdb": "8438181",
|
||||||
|
"title": "Adventures of a Normal Man (1)",
|
||||||
|
"locations": ("S01E01.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt20413322",
|
||||||
|
"tmdb": "4568681",
|
||||||
|
"tvdb": "9829910",
|
||||||
|
"title": "Adventures of a Normal Man (2)",
|
||||||
|
"locations": ("S01E02.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt20413328",
|
||||||
|
"tmdb": "4497012",
|
||||||
|
"tvdb": "9870382",
|
||||||
|
"title": "My Interview with Superman",
|
||||||
|
"locations": ("S01E03.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
tv_shows_watched_list_2 = {
|
||||||
frozenset(
|
frozenset(
|
||||||
{
|
{
|
||||||
("tvdb", "75710"),
|
("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
|
||||||
("title", "Criminal Minds"),
|
("imdb", "tt0436992"),
|
||||||
("imdb", "tt0452046"),
|
("tmdb", "57243"),
|
||||||
("locations", ("Criminal Minds",)),
|
("title", "Doctor Who"),
|
||||||
("tmdb", "4057"),
|
("tvdb", "78804"),
|
||||||
|
("tvrage", "3332"),
|
||||||
}
|
}
|
||||||
): {
|
): [
|
||||||
"Season 1": [
|
|
||||||
{
|
{
|
||||||
"imdb": "tt0550487",
|
"tvdb": "295294",
|
||||||
"tmdb": "282861",
|
"imdb": "tt0562992",
|
||||||
"tvdb": "300385",
|
"title": "Rose",
|
||||||
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",),
|
"locations": ("S01E01.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"imdb": "tt0550498",
|
"tvdb": "295295",
|
||||||
"tmdb": "282865",
|
"imdb": "tt0562997",
|
||||||
"tvdb": "300474",
|
"title": "The End of the World",
|
||||||
"locations": (
|
"locations": ("S01E02.mkv",),
|
||||||
"Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",
|
"status": {"completed": False, "time": 300670},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tvdb": "295298",
|
||||||
|
"imdb": "tt0563003",
|
||||||
|
"title": "World War Three (2)",
|
||||||
|
"locations": ("S01E05.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
("title", "Monarch: Legacy of Monsters"),
|
||||||
|
("imdb", "tt17220216"),
|
||||||
|
("tvdb", "422598"),
|
||||||
|
("tmdb", "202411"),
|
||||||
|
(
|
||||||
|
"locations",
|
||||||
|
("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||||
),
|
),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"tvdb": "9959300",
|
||||||
|
"imdb": "tt20412166",
|
||||||
|
"title": "Aftermath",
|
||||||
|
"locations": ("S01E01.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
},
|
},
|
||||||
]
|
{
|
||||||
|
"tvdb": "10009417",
|
||||||
|
"imdb": "tt22866594",
|
||||||
|
"title": "Departure",
|
||||||
|
"locations": ("S01E02.mkv",),
|
||||||
|
"status": {"completed": False, "time": 300741},
|
||||||
},
|
},
|
||||||
frozenset({("title", "Test"), ("locations", ("Test",))}): {
|
{
|
||||||
"Season 1": [
|
"tvdb": "10009420",
|
||||||
{"locations": ("Test S01E02.mkv",)},
|
"imdb": "tt23787572",
|
||||||
{"locations": ("Test S01E03.mkv",)},
|
"title": "The Way Out",
|
||||||
]
|
"locations": ("S01E05.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
("tmdb", "125928"),
|
||||||
|
("imdb", "tt14681924"),
|
||||||
|
("tvdb", "403172"),
|
||||||
|
(
|
||||||
|
"locations",
|
||||||
|
("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",),
|
||||||
|
),
|
||||||
|
("title", "My Adventures with Superman"),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"tvdb": "8438181",
|
||||||
|
"imdb": "tt15699926",
|
||||||
|
"title": "Adventures of a Normal Man (1)",
|
||||||
|
"locations": ("S01E01.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tvdb": "9829910",
|
||||||
|
"imdb": "tt20413322",
|
||||||
|
"title": "Adventures of a Normal Man (2)",
|
||||||
|
"locations": ("S01E02.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tvdb": "9870382",
|
||||||
|
"imdb": "tt20413328",
|
||||||
|
"title": "My Interview with Superman",
|
||||||
|
"locations": ("S01E03.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expected_tv_show_watched_list_1 = {
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
|
||||||
|
("imdb", "tt0436992"),
|
||||||
|
("tmdb", "57243"),
|
||||||
|
("tvdb", "78804"),
|
||||||
|
("title", "Doctor Who (2005)"),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"imdb": "tt0563001",
|
||||||
|
"tmdb": "968589",
|
||||||
|
"tvdb": "295296",
|
||||||
|
"title": "The Unquiet Dead",
|
||||||
|
"locations": ("S01E03.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt0562985",
|
||||||
|
"tmdb": "968590",
|
||||||
|
"tvdb": "295297",
|
||||||
|
"title": "Aliens of London (1)",
|
||||||
|
"locations": ("S01E04.mkv",),
|
||||||
|
"status": {"completed": False, "time": 240000},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
("title", "Monarch: Legacy of Monsters"),
|
||||||
|
("imdb", "tt17220216"),
|
||||||
|
("tvdb", "422598"),
|
||||||
|
("tmdb", "202411"),
|
||||||
|
(
|
||||||
|
"locations",
|
||||||
|
("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"imdb": "tt21255044",
|
||||||
|
"tmdb": "4661246",
|
||||||
|
"tvdb": "10009418",
|
||||||
|
"title": "Secrets and Lies",
|
||||||
|
"locations": ("S01E03.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt21255050",
|
||||||
|
"tmdb": "4712059",
|
||||||
|
"tvdb": "10009419",
|
||||||
|
"title": "Parallels and Interiors",
|
||||||
|
"locations": ("S01E04.mkv",),
|
||||||
|
"status": {"completed": False, "time": 240000},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_tv_show_watched_list_2 = {
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
|
||||||
|
("imdb", "tt0436992"),
|
||||||
|
("tmdb", "57243"),
|
||||||
|
("title", "Doctor Who"),
|
||||||
|
("tvdb", "78804"),
|
||||||
|
("tvrage", "3332"),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"tvdb": "295294",
|
||||||
|
"imdb": "tt0562992",
|
||||||
|
"title": "Rose",
|
||||||
|
"locations": ("S01E01.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tvdb": "295295",
|
||||||
|
"imdb": "tt0562997",
|
||||||
|
"title": "The End of the World",
|
||||||
|
"locations": ("S01E02.mkv",),
|
||||||
|
"status": {"completed": False, "time": 300670},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
("title", "Monarch: Legacy of Monsters"),
|
||||||
|
("imdb", "tt17220216"),
|
||||||
|
("tvdb", "422598"),
|
||||||
|
("tmdb", "202411"),
|
||||||
|
(
|
||||||
|
"locations",
|
||||||
|
("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
): [
|
||||||
|
{
|
||||||
|
"tvdb": "9959300",
|
||||||
|
"imdb": "tt20412166",
|
||||||
|
"title": "Aftermath",
|
||||||
|
"locations": ("S01E01.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tvdb": "10009417",
|
||||||
|
"imdb": "tt22866594",
|
||||||
|
"title": "Departure",
|
||||||
|
"locations": ("S01E02.mkv",),
|
||||||
|
"status": {"completed": False, "time": 300741},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
movies_watched_list_1 = [
|
||||||
|
{
|
||||||
|
"imdb": "tt1254207",
|
||||||
|
"tmdb": "10378",
|
||||||
|
"tvdb": "12352",
|
||||||
|
"title": "Big Buck Bunny",
|
||||||
|
"locations": ("Big Buck Bunny.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt16431870",
|
||||||
|
"tmdb": "1029575",
|
||||||
|
"tvdb": "351194",
|
||||||
|
"title": "The Family Plan",
|
||||||
|
"locations": ("The Family Plan (2023).mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt5537002",
|
||||||
|
"tmdb": "466420",
|
||||||
|
"tvdb": "135852",
|
||||||
|
"title": "Killers of the Flower Moon",
|
||||||
|
"locations": ("Killers of the Flower Moon (2023).mkv",),
|
||||||
|
"status": {"completed": False, "time": 240000},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
movies_watched_list_2 = [
|
movies_watched_list_2 = [
|
||||||
{
|
{
|
||||||
"imdb": "tt2380307",
|
"imdb": "tt16431870",
|
||||||
"tmdb": "354912",
|
"tmdb": "1029575",
|
||||||
"title": "Coco",
|
"title": "The Family Plan",
|
||||||
"locations": ("Coco (2017) Remux-1080p.mkv",),
|
"locations": ("The Family Plan (2023).mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"imdb": "tt0384793",
|
"imdb": "tt4589218",
|
||||||
"tmdb": "9788",
|
"tmdb": "507089",
|
||||||
"tvdb": "9103",
|
"title": "Five Nights at Freddy's",
|
||||||
"title": "Accepted",
|
"locations": ("Five Nights at Freddy's (2023).mkv",),
|
||||||
"locations": ("Accepted (2006) Remux-1080p.mkv",),
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt10545296",
|
||||||
|
"tmdb": "695721",
|
||||||
|
"tmdbcollection": "131635",
|
||||||
|
"title": "The Hunger Games: The Ballad of Songbirds & Snakes",
|
||||||
|
"locations": ("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",),
|
||||||
|
"status": {"completed": False, "time": 301215},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
expected_movie_watched_list_1 = [
|
||||||
|
{
|
||||||
|
"imdb": "tt1254207",
|
||||||
|
"tmdb": "10378",
|
||||||
|
"tvdb": "12352",
|
||||||
|
"title": "Big Buck Bunny",
|
||||||
|
"locations": ("Big Buck Bunny.mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt5537002",
|
||||||
|
"tmdb": "466420",
|
||||||
|
"tvdb": "135852",
|
||||||
|
"title": "Killers of the Flower Moon",
|
||||||
|
"locations": ("Killers of the Flower Moon (2023).mkv",),
|
||||||
|
"status": {"completed": False, "time": 240000},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
expected_movie_watched_list_2 = [
|
||||||
|
{
|
||||||
|
"imdb": "tt4589218",
|
||||||
|
"tmdb": "507089",
|
||||||
|
"title": "Five Nights at Freddy's",
|
||||||
|
"locations": ("Five Nights at Freddy's (2023).mkv",),
|
||||||
|
"status": {"completed": True, "time": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imdb": "tt10545296",
|
||||||
|
"tmdb": "695721",
|
||||||
|
"tmdbcollection": "131635",
|
||||||
|
"title": "The Hunger Games: The Ballad of Songbirds & Snakes",
|
||||||
|
"locations": ("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",),
|
||||||
|
"status": {"completed": False, "time": 301215},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -127,92 +443,18 @@ tv_shows_2_watched_list_1 = {
|
|||||||
("locations", ("Criminal Minds",)),
|
("locations", ("Criminal Minds",)),
|
||||||
("tmdb", "4057"),
|
("tmdb", "4057"),
|
||||||
}
|
}
|
||||||
): {
|
): [
|
||||||
"Season 1": [
|
|
||||||
{
|
{
|
||||||
"imdb": "tt0550489",
|
"imdb": "tt0550489",
|
||||||
"tmdb": "282843",
|
"tmdb": "282843",
|
||||||
"tvdb": "176357",
|
"tvdb": "176357",
|
||||||
"locations": (
|
"title": "Extreme Aggressor",
|
||||||
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
|
"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():
|
def test_simple_cleanup_watched():
|
||||||
user_watched_list_1 = {
|
user_watched_list_1 = {
|
||||||
@@ -299,112 +541,3 @@ def test_mapping_cleanup_watched():
|
|||||||
|
|
||||||
assert return_watched_list_1 == expected_watched_list_1
|
assert return_watched_list_1 == expected_watched_list_1
|
||||||
assert return_watched_list_2 == expected_watched_list_2
|
assert return_watched_list_2 == expected_watched_list_2
|
||||||
|
|
||||||
|
|
||||||
def test_combine_watched_dicts():
|
|
||||||
input_watched = [
|
|
||||||
{
|
|
||||||
"test3": {
|
|
||||||
"Anime Movies": [
|
|
||||||
{
|
|
||||||
"title": "Ponyo",
|
|
||||||
"tmdb": "12429",
|
|
||||||
"imdb": "tt0876563",
|
|
||||||
"locations": ("Ponyo (2008) Bluray-1080p.mkv",),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Spirited Away",
|
|
||||||
"tmdb": "129",
|
|
||||||
"imdb": "tt0245429",
|
|
||||||
"locations": ("Spirited Away (2001) Bluray-1080p.mkv",),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Castle in the Sky",
|
|
||||||
"tmdb": "10515",
|
|
||||||
"imdb": "tt0092067",
|
|
||||||
"locations": ("Castle in the Sky (1986) Bluray-1080p.mkv",),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{"test3": {"Anime Shows": {}}},
|
|
||||||
{"test3": {"Cartoon Shows": {}}},
|
|
||||||
{
|
|
||||||
"test3": {
|
|
||||||
"Shows": {
|
|
||||||
frozenset(
|
|
||||||
{
|
|
||||||
("tmdb", "64464"),
|
|
||||||
("tvdb", "301824"),
|
|
||||||
("tvrage", "45210"),
|
|
||||||
("title", "11.22.63"),
|
|
||||||
("locations", ("11.22.63",)),
|
|
||||||
("imdb", "tt2879552"),
|
|
||||||
}
|
|
||||||
): {
|
|
||||||
"Season 1": [
|
|
||||||
{
|
|
||||||
"imdb": "tt4460418",
|
|
||||||
"title": "The Rabbit Hole",
|
|
||||||
"locations": (
|
|
||||||
"11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{"test3": {"Subbed Anime": {}}},
|
|
||||||
]
|
|
||||||
expected = {
|
|
||||||
"test3": {
|
|
||||||
"Anime Movies": [
|
|
||||||
{
|
|
||||||
"title": "Ponyo",
|
|
||||||
"tmdb": "12429",
|
|
||||||
"imdb": "tt0876563",
|
|
||||||
"locations": ("Ponyo (2008) Bluray-1080p.mkv",),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Spirited Away",
|
|
||||||
"tmdb": "129",
|
|
||||||
"imdb": "tt0245429",
|
|
||||||
"locations": ("Spirited Away (2001) Bluray-1080p.mkv",),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Castle in the Sky",
|
|
||||||
"tmdb": "10515",
|
|
||||||
"imdb": "tt0092067",
|
|
||||||
"locations": ("Castle in the Sky (1986) Bluray-1080p.mkv",),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"Anime Shows": {},
|
|
||||||
"Cartoon Shows": {},
|
|
||||||
"Shows": {
|
|
||||||
frozenset(
|
|
||||||
{
|
|
||||||
("tmdb", "64464"),
|
|
||||||
("tvdb", "301824"),
|
|
||||||
("tvrage", "45210"),
|
|
||||||
("title", "11.22.63"),
|
|
||||||
("locations", ("11.22.63",)),
|
|
||||||
("imdb", "tt2879552"),
|
|
||||||
}
|
|
||||||
): {
|
|
||||||
"Season 1": [
|
|
||||||
{
|
|
||||||
"imdb": "tt4460418",
|
|
||||||
"title": "The Rabbit Hole",
|
|
||||||
"locations": (
|
|
||||||
"11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Subbed Anime": {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert combine_watched_dicts(input_watched) == expected
|
|
||||||
|
|||||||
212
test/validate_ci_marklog.py
Normal file
212
test/validate_ci_marklog.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Check the mark.log file that is generated by the CI to make sure it contains the expected values
|
||||||
|
|
||||||
|
import os, argparse
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Check the mark.log file that is generated by the CI to make sure it contains the expected values"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--guids", action="store_true", help="Check the mark.log file for guids"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--locations", action="store_true", help="Check the mark.log file for locations"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--write", action="store_true", help="Check the mark.log file for write-run"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--plex", action="store_true", help="Check the mark.log file for Plex"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--jellyfin", action="store_true", help="Check the mark.log file for Jellyfin"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--emby", action="store_true", help="Check the mark.log file for Emby"
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def read_marklog():
|
||||||
|
marklog = os.path.join(os.getcwd(), "mark.log")
|
||||||
|
with open(marklog, "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def check_marklog(lines, expected_values):
|
||||||
|
try:
|
||||||
|
# Check to make sure the marklog contains all the expected values and nothing else
|
||||||
|
found_values = []
|
||||||
|
for line in lines:
|
||||||
|
# Remove the newline character
|
||||||
|
line = line.strip()
|
||||||
|
if line not in expected_values:
|
||||||
|
raise Exception("Line not found in marklog: " + line)
|
||||||
|
|
||||||
|
found_values.append(line)
|
||||||
|
|
||||||
|
# Check to make sure the marklog contains the same number of values as the expected values
|
||||||
|
if len(found_values) != len(expected_values):
|
||||||
|
raise Exception(
|
||||||
|
"Marklog did not contain the same number of values as the expected values, found "
|
||||||
|
+ str(len(found_values))
|
||||||
|
+ " values, expected "
|
||||||
|
+ str(len(expected_values))
|
||||||
|
+ " values\n"
|
||||||
|
+ "\n".join(found_values)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the two lists contain the same values
|
||||||
|
if sorted(found_values) != sorted(expected_values):
|
||||||
|
raise Exception(
|
||||||
|
"Marklog did not contain the same values as the expected values, found:\n"
|
||||||
|
+ "\n".join(sorted(found_values))
|
||||||
|
+ "\n\nExpected:\n"
|
||||||
|
+ "\n".join(sorted(expected_values))
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
expected_jellyfin = [
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Two (2021)",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 2",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Five Nights at Freddy's",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie Two",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E02",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/Five Nights at Freddy's",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
|
||||||
|
]
|
||||||
|
expected_emby = [
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Three (2022)",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 3",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Tears of Steel",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie Three (2022)",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E03",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Tears of Steel",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
|
||||||
|
]
|
||||||
|
expected_plex = [
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Big Buck Bunny",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Killers of the Flower Moon/4",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/The Unquiet Dead",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/Aliens of London (1)/4",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie One (2020)",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/Big Buck Bunny",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/Killers of the Flower Moon/4",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie One",
|
||||||
|
]
|
||||||
|
|
||||||
|
expected_locations = expected_emby + expected_plex + expected_jellyfin
|
||||||
|
# Remove Custom Movies/TV Shows as they should not have guids
|
||||||
|
expected_guids = [item for item in expected_locations if "Custom" not in item ]
|
||||||
|
|
||||||
|
|
||||||
|
expected_write = [
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Two (2021)",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 2",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Five Nights at Freddy's",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Big Buck Bunny",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Killers of the Flower Moon/4",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/The Unquiet Dead",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/Aliens of London (1)/4",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie One (2020)",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Three (2022)",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 3",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Tears of Steel",
|
||||||
|
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/Big Buck Bunny",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/Five Nights at Freddy's",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Movies/Killers of the Flower Moon/4",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E02",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie One",
|
||||||
|
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie Two",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie Three (2022)",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E03",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Tears of Steel",
|
||||||
|
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Expected values for the mark.log file, dry-run is slightly different than write-run
|
||||||
|
# due to some of the items being copied over from one server to another and now being there
|
||||||
|
# for the next server run.
|
||||||
|
if args.guids:
|
||||||
|
expected_values = expected_guids
|
||||||
|
elif args.locations:
|
||||||
|
expected_values = expected_locations
|
||||||
|
elif args.write:
|
||||||
|
expected_values = expected_write
|
||||||
|
elif args.plex:
|
||||||
|
expected_values = expected_plex
|
||||||
|
elif args.jellyfin:
|
||||||
|
expected_values = expected_jellyfin
|
||||||
|
elif args.emby:
|
||||||
|
expected_values = expected_emby
|
||||||
|
else:
|
||||||
|
print("No server specified")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
lines = read_marklog()
|
||||||
|
if not check_marklog(lines, expected_values):
|
||||||
|
print("Failed to validate marklog")
|
||||||
|
for line in lines:
|
||||||
|
# Remove the newline character
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print("Successfully validated marklog")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user