Compare commits
404 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f56769f9 | ||
|
|
29e4f224dc | ||
|
|
bdb58918e7 | ||
|
|
c3be980eea | ||
|
|
c1a26dd73b | ||
|
|
e5d5f11f33 | ||
|
|
616ca92d5e | ||
|
|
b2b214c987 | ||
|
|
07542b498e | ||
|
|
9e53c0f8e2 | ||
|
|
98266de678 | ||
|
|
9d4f3dd432 | ||
|
|
cc9b84fefa | ||
|
|
c76bb3b355 | ||
|
|
544649effd | ||
|
|
46b60bb866 | ||
|
|
5670c3ad97 | ||
|
|
7e0f4babda | ||
|
|
d5c36c61ec | ||
|
|
69cd73d965 | ||
|
|
229ab59b44 | ||
|
|
3e474a4593 | ||
|
|
69958a257b | ||
|
|
64c1823e5b | ||
|
|
446f6df470 | ||
|
|
91ea5d76f6 | ||
|
|
dc26b9a7b1 | ||
|
|
d98b7c3e09 | ||
|
|
93d9471333 | ||
|
|
e6fa8ae745 | ||
|
|
5b644a54a2 | ||
|
|
5a17c5f7a1 | ||
|
|
61e3dddd6b | ||
|
|
aaaa7eba70 | ||
|
|
991355716d | ||
|
|
54bd6e836f | ||
|
|
57c41f41bc | ||
|
|
ea85a31d9c | ||
|
|
80d5c9e54c | ||
|
|
5828701944 | ||
|
|
81ba9bd7f9 | ||
|
|
d15759570e | ||
|
|
1b88ecf2eb | ||
|
|
c62809c615 | ||
|
|
899a6b05a4 | ||
|
|
fcd6103e17 | ||
|
|
ac5be474f8 | ||
|
|
d15f29b772 | ||
|
|
c9944866f8 | ||
|
|
846e18fffe | ||
|
|
eb09de2bdf | ||
|
|
c0e207924c | ||
|
|
e48533dfbd | ||
|
|
8503b087b2 | ||
|
|
305fea8f9a | ||
|
|
588c23ce41 | ||
|
|
8f4a2e2690 | ||
|
|
38e65f5a17 | ||
|
|
de32d59aa1 | ||
|
|
998f2b1209 | ||
|
|
0b02f531c1 | ||
|
|
e589935b37 | ||
|
|
031d43e980 | ||
|
|
ba6cad13f6 | ||
|
|
f3801a0bd2 | ||
|
|
196a49fca4 | ||
|
|
4d0f1d303f | ||
|
|
ce5b810a5b | ||
|
|
a1e1ccde42 | ||
|
|
bf633c75d1 | ||
|
|
46fa5e7c9a | ||
|
|
170757aca1 | ||
|
|
9786e9e27d | ||
|
|
8b691b7bfa | ||
|
|
e1c65fc082 | ||
|
|
58749a4fb8 | ||
|
|
51ec69f651 | ||
|
|
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 | ||
|
|
fd64088bde | ||
|
|
7832e41a3b | ||
|
|
cadd65d69b | ||
|
|
9f004797fc | ||
|
|
9041fee7ad | ||
|
|
9af6c9057c | ||
|
|
757ce91138 | ||
|
|
98f96ed5c7 | ||
|
|
3e15120e2a | ||
|
|
5824e6c0cc | ||
|
|
7087d75efb | ||
|
|
b2a06b8fd3 | ||
|
|
1ee055faf5 | ||
|
|
404089dfca | ||
|
|
ed24948dee | ||
|
|
1f16fcb8eb | ||
|
|
03de3affd7 | ||
|
|
2bad887659 | ||
|
|
796be47a63 | ||
|
|
dc1fe11590 | ||
|
|
13b4ff3215 | ||
|
|
dca54cf4fb | ||
|
|
a4365e59f3 | ||
|
|
b960bccb86 | ||
|
|
218037200c | ||
|
|
4ac670e837 | ||
|
|
96eff65c3e | ||
|
|
45471607c8 | ||
|
|
14885744b1 | ||
|
|
d1fd61f1d1 | ||
|
|
6c1ee4a7dc | ||
|
|
9a8e799e68 | ||
|
|
ffec4e2f28 | ||
|
|
00102891a5 | ||
|
|
aa76b83428 | ||
|
|
a644189ea5 | ||
|
|
c5d987a8c9 | ||
|
|
bdd68ad68d | ||
|
|
2d86bca781 | ||
|
|
1b01ff6ec2 | ||
|
|
f08ec43507 | ||
|
|
7f9424260a | ||
|
|
5f21943353 | ||
|
|
a5a795f43c | ||
|
|
fcb6d7625f | ||
|
|
fd2179998f | ||
|
|
654e7f20e1 | ||
|
|
1eb92cf7c1 | ||
|
|
111e284cc8 | ||
|
|
1a4e3f4ec4 | ||
|
|
4066228e57 | ||
|
|
59c6d278e3 | ||
|
|
39b33f3d43 | ||
|
|
e8faf52b2b | ||
|
|
370e9bac63 | ||
|
|
d0746cec5a | ||
|
|
251937431b | ||
|
|
50faf061af | ||
|
|
9ffbc49ad3 | ||
|
|
644dc8e3af | ||
|
|
47bc4e94dc | ||
|
|
f17d39fe17 | ||
|
|
966dcacf8d |
@@ -1 +1,13 @@
|
||||
.env
|
||||
.venv
|
||||
.dockerignore
|
||||
.env
|
||||
.env.sample
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
Dockerfile*
|
||||
README.md
|
||||
test
|
||||
|
||||
153
.env.sample
153
.env.sample
@@ -1,42 +1,111 @@
|
||||
## 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 = "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 plex and jellyfin in the event that they are different, order does not matter
|
||||
#USER_MAPPING = { "testuser2": "testuser3" }
|
||||
## Map libraries between plex and jellyfin in the even that they are different, order does not matter
|
||||
#LIBRARY_MAPPING = { "Shows": "TV Shows" }
|
||||
|
||||
|
||||
## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers
|
||||
## 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/
|
||||
PLEX_TOKEN = "SuperSecretToken"
|
||||
## If not using plex token then use username and password of the server admin along with the servername
|
||||
#PLEX_USERNAME = ""
|
||||
#PLEX_PASSWORD = ""
|
||||
#PLEX_SERVERNAME = "Plex Server"
|
||||
|
||||
|
||||
## 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
|
||||
JELLYFIN_TOKEN = "SuperSecretToken"
|
||||
|
||||
|
||||
## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded.
|
||||
#BLACKLIST_LIBRARY = ""
|
||||
#WHITELIST_LIBRARY = ""
|
||||
#BLACKLIST_LIBRARY_TYPE = ""
|
||||
#WHITELIST_LIBRARY_TYPE = ""
|
||||
#BLACKLIST_USERS = ""
|
||||
WHITELIST_USERS = "testuser1,testuser2"
|
||||
# Global Settings
|
||||
|
||||
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||
DRYRUN = "True"
|
||||
|
||||
## Debugging level, "info" is default, "debug" is more verbose
|
||||
DEBUG_LEVEL = "INFO"
|
||||
|
||||
## If set to true then the script will only run once and then exit
|
||||
RUN_ONLY_ONCE = "False"
|
||||
|
||||
## How often to run the script in seconds
|
||||
SLEEP_DURATION = "3600"
|
||||
|
||||
## Log file where all output will be written to
|
||||
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 = 1
|
||||
|
||||
## Generate guids/locations
|
||||
## These are slow processes, so this is a way to speed things up
|
||||
## If media servers are using the same files then you can enable only generate locations
|
||||
## If media servers are using different files then you can enable only generate guids
|
||||
## Default is to generate both
|
||||
GENERATE_GUIDS = "True"
|
||||
GENERATE_LOCATIONS = "True"
|
||||
|
||||
## Map usernames between servers in the event that they are different, order does not matter
|
||||
## Comma separated for multiple options
|
||||
USER_MAPPING = { "Username": "User", "Second User": "User Dos" }
|
||||
|
||||
## Map libraries between servers in the event 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 = ""
|
||||
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
# 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 = "SuperSecretToken"
|
||||
|
||||
|
||||
# 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']
|
||||
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Logs**
|
||||
If applicable, add logs to help explain your problem ideally with DEBUG set to true, be sure to remove sensitive information
|
||||
|
||||
**Type:**
|
||||
- [ ] Docker Compose
|
||||
- [ ] Docker
|
||||
- [ ] Unraid
|
||||
- [ ] Native
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature Request]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
197
.github/workflows/ci.yml
vendored
197
.github/workflows/ci.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths-ignore:
|
||||
- .gitignore
|
||||
@@ -8,75 +9,191 @@ on:
|
||||
paths-ignore:
|
||||
- .gitignore
|
||||
- "*.md"
|
||||
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.13'
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: pip install -r requirements.txt && pip install -r test/requirements.txt
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: "Run tests"
|
||||
run: pytest -vvv
|
||||
run: uv run pytest -vvv
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: |
|
||||
uv sync --frozen
|
||||
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: |
|
||||
ENV_FILE="test/ci_plex.env" uv run main.py
|
||||
uv run test/validate_ci_marklog.py --plex
|
||||
|
||||
rm mark.log
|
||||
|
||||
- name: "Test Jellyfin"
|
||||
run: |
|
||||
ENV_FILE="test/ci_jellyfin.env" uv run main.py
|
||||
uv run test/validate_ci_marklog.py --jellyfin
|
||||
|
||||
rm mark.log
|
||||
|
||||
- name: "Test Emby"
|
||||
run: |
|
||||
ENV_FILE="test/ci_emby.env" uv run main.py
|
||||
uv run test/validate_ci_marklog.py --emby
|
||||
|
||||
rm mark.log
|
||||
|
||||
- name: "Test Guids"
|
||||
run: |
|
||||
ENV_FILE="test/ci_guids.env" uv run main.py
|
||||
uv run test/validate_ci_marklog.py --guids
|
||||
|
||||
rm mark.log
|
||||
|
||||
- name: "Test Locations"
|
||||
run: |
|
||||
ENV_FILE="test/ci_locations.env" uv run main.py
|
||||
uv run test/validate_ci_marklog.py --locations
|
||||
|
||||
rm mark.log
|
||||
|
||||
- name: "Test writing to the servers"
|
||||
run: |
|
||||
# Test writing to the servers
|
||||
ENV_FILE="test/ci_write.env" uv run main.py
|
||||
|
||||
# Test again to test if it can handle existing data
|
||||
ENV_FILE="test/ci_write.env" uv run main.py
|
||||
|
||||
uv run test/validate_ci_marklog.py --write
|
||||
|
||||
rm mark.log
|
||||
|
||||
docker:
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 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:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
if: "${{ env.DOCKER_USERNAME != '' }}"
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched # list of Docker images to use as base name for tags
|
||||
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@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: "${{ steps.docker_meta.outcome == 'success' }}"
|
||||
uses: docker/login-action@v1
|
||||
id: docker_login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
if: "${{ steps.docker_meta.outcome == 'skipped' }}"
|
||||
uses: docker/build-push-action@v2
|
||||
- name: Login to GitHub Container Registry
|
||||
if: "${{ steps.docker_meta.outcome == 'success' }}"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
tags: jellyplex-watched:action
|
||||
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Push
|
||||
id: build_push
|
||||
if: "${{ steps.docker_meta.outcome == 'success' }}"
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
push: ${{ steps.docker_login.outcome == 'success' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
|
||||
|
||||
41
.github/workflows/codeql.yml
vendored
Normal file
41
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: "23 20 * * 6"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ python ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
.env
|
||||
**.env*
|
||||
*.prof
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
@@ -84,9 +84,6 @@ target/
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
@@ -6,11 +6,22 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Main",
|
||||
"type": "python",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "main.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "Pytest",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"args": [
|
||||
"-vv"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"[python]" : {
|
||||
"editor.formatOnSave": true,
|
||||
},
|
||||
"python.formatting.provider": "black",
|
||||
|
||||
}
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,35 +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 '{ "User Test": "User Test2" }'
|
||||
ENV LIBRARY_MAPPING '{ "Shows Test": "TV Shows Test" }'
|
||||
|
||||
ENV PLEX_BASEURL 'http://localhost:32400'
|
||||
ENV PLEX_TOKEN ''
|
||||
ENV PLEX_USERNAME ''
|
||||
ENV PLEX_PASSWORD ''
|
||||
ENV PLEX_SERVERNAME ''
|
||||
|
||||
ENV JELLYFIN_BASEURL 'http://localhost:8096'
|
||||
ENV JELLYFIN_TOKEN ''
|
||||
|
||||
ENV BLACKLIST_LIBRARY ''
|
||||
ENV WHITELIST_LIBRARY ''
|
||||
ENV BLACKLIST_LIBRARY_TYPE ''
|
||||
ENV WHITELIST_LIBRARY_TYPE ''
|
||||
ENV BLACKLIST_USERS ''
|
||||
ENV WHITELIST_USERS ''
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "-u", "main.py"]
|
||||
107
Dockerfile.alpine
Normal file
107
Dockerfile.alpine
Normal file
@@ -0,0 +1,107 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.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
|
||||
|
||||
# Enable bytecode compilation
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Set the cache directory to /tmp instead of root
|
||||
ENV UV_CACHE_DIR=/tmp/.cache/uv
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
RUN --mount=type=cache,target=/tmp/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Then, add the rest of the project source code and install it
|
||||
# Installing separately from its dependencies allows optimal layer caching
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/tmp/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN chmod +x *.sh && \
|
||||
dos2unix *.sh
|
||||
|
||||
# Set default values to prevent issues
|
||||
ENV DRYRUN="True"
|
||||
ENV DEBUG_LEVEL="INFO"
|
||||
ENV RUN_ONLY_ONCE="False"
|
||||
ENV SLEEP_DURATION=3600
|
||||
ENV LOG_FILE="log.log"
|
||||
ENV MARK_FILE="mark.log"
|
||||
ENV REQUEST_TIME=300
|
||||
ENV GENERATE_GUIDS="True"
|
||||
ENV GENERATE_LOCATIONS="True"
|
||||
ENV MAX_THREADS=1
|
||||
ENV USER_MAPPING=""
|
||||
ENV LIBRARY_MAPPING=""
|
||||
ENV BLACKLIST_LIBRARY=""
|
||||
ENV WHITELIST_LIBRARY=""
|
||||
ENV BLACKLIST_LIBRARY_TYPE=""
|
||||
ENV WHITELIST_LIBRARY_TYPE=""
|
||||
ENV BLACKLIST_USERS=""
|
||||
ENV WHITELIST_USERS=""
|
||||
ENV PLEX_BASEURL=""
|
||||
ENV PLEX_TOKEN=""
|
||||
ENV PLEX_USERNAME=""
|
||||
ENV PLEX_PASSWORD=""
|
||||
ENV PLEX_SERVERNAME=""
|
||||
ENV SSL_BYPASS="False"
|
||||
ENV JELLYFIN_BASEURL=""
|
||||
ENV JELLYFIN_TOKEN=""
|
||||
ENV EMBY_BASEURL=""
|
||||
ENV EMBY_TOKEN=""
|
||||
ENV SYNC_FROM_PLEX_TO_JELLYFIN="True"
|
||||
ENV SYNC_FROM_PLEX_TO_PLEX="True"
|
||||
ENV SYNC_FROM_PLEX_TO_EMBY="True"
|
||||
ENV SYNC_FROM_JELLYFIN_TO_PLEX="True"
|
||||
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN="True"
|
||||
ENV SYNC_FROM_JELLYFIN_TO_EMBY="True"
|
||||
ENV SYNC_FROM_EMBY_TO_PLEX="True"
|
||||
ENV SYNC_FROM_EMBY_TO_JELLYFIN="True"
|
||||
ENV SYNC_FROM_EMBY_TO_EMBY="True"
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||
CMD ["python", "-u", "main.py"]
|
||||
81
Dockerfile.slim
Normal file
81
Dockerfile.slim
Normal file
@@ -0,0 +1,81 @@
|
||||
FROM ghcr.io/astral-sh/uv:bookworm-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
|
||||
|
||||
# Enable bytecode compilation
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Set the cache directory to /tmp instead of root
|
||||
ENV UV_CACHE_DIR=/tmp/.cache/uv
|
||||
|
||||
ENV UV_PYTHON_INSTALL_DIR=/app/.bin
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
RUN --mount=type=cache,target=/tmp/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Then, add the rest of the project source code and install it
|
||||
# Installing separately from its dependencies allows optimal layer caching
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/tmp/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
RUN chmod +x *.sh && \
|
||||
dos2unix *.sh
|
||||
|
||||
# Set default values to prevent issues
|
||||
ENV DRYRUN="True"
|
||||
ENV DEBUG_LEVEL="INFO"
|
||||
ENV RUN_ONLY_ONCE="False"
|
||||
ENV SLEEP_DURATION=3600
|
||||
ENV LOG_FILE="log.log"
|
||||
ENV MARK_FILE="mark.log"
|
||||
ENV REQUEST_TIME=300
|
||||
ENV GENERATE_GUIDS="True"
|
||||
ENV GENERATE_LOCATIONS="True"
|
||||
ENV MAX_THREADS=1
|
||||
ENV USER_MAPPING=""
|
||||
ENV LIBRARY_MAPPING=""
|
||||
ENV BLACKLIST_LIBRARY=""
|
||||
ENV WHITELIST_LIBRARY=""
|
||||
ENV BLACKLIST_LIBRARY_TYPE=""
|
||||
ENV WHITELIST_LIBRARY_TYPE=""
|
||||
ENV BLACKLIST_USERS=""
|
||||
ENV WHITELIST_USERS=""
|
||||
ENV PLEX_BASEURL=""
|
||||
ENV PLEX_TOKEN=""
|
||||
ENV PLEX_USERNAME=""
|
||||
ENV PLEX_PASSWORD=""
|
||||
ENV PLEX_SERVERNAME=""
|
||||
ENV SSL_BYPASS="False"
|
||||
ENV JELLYFIN_BASEURL=""
|
||||
ENV JELLYFIN_TOKEN=""
|
||||
ENV EMBY_BASEURL=""
|
||||
ENV EMBY_TOKEN=""
|
||||
ENV SYNC_FROM_PLEX_TO_JELLYFIN="True"
|
||||
ENV SYNC_FROM_PLEX_TO_PLEX="True"
|
||||
ENV SYNC_FROM_PLEX_TO_EMBY="True"
|
||||
ENV SYNC_FROM_JELLYFIN_TO_PLEX="True"
|
||||
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN="True"
|
||||
ENV SYNC_FROM_JELLYFIN_TO_EMBY="True"
|
||||
ENV SYNC_FROM_EMBY_TO_PLEX="True"
|
||||
ENV SYNC_FROM_EMBY_TO_JELLYFIN="True"
|
||||
ENV SYNC_FROM_EMBY_TO_EMBY="True"
|
||||
|
||||
ENTRYPOINT ["/bin/tini", "--", "/app/entrypoint.sh"]
|
||||
CMD ["python", "-u", "main.py"]
|
||||
186
README.md
186
README.md
@@ -1,73 +1,113 @@
|
||||
# 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)
|
||||
|
||||
Sync watched between jellyfin and plex
|
||||
|
||||
## Description
|
||||
|
||||
Keep in sync all your users watched history between jellyfin and plex servers locally. This uses the imdb ids and any other matching id to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by enterying multiple options in the .env plex/jellyfin section seperated by commas.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
### Baremetal
|
||||
|
||||
- Setup virtualenv of your choice
|
||||
|
||||
- Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens
|
||||
|
||||
- Run
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
- Build docker image
|
||||
|
||||
```bash
|
||||
docker build -t jellyplex-watched .
|
||||
```
|
||||
|
||||
- or use pre-built image
|
||||
|
||||
```bash
|
||||
docker pull luigi311/jellyplex-watched:latest
|
||||
```
|
||||
|
||||
#### With variables
|
||||
|
||||
- Run
|
||||
|
||||
```bash
|
||||
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
|
||||
```
|
||||
|
||||
#### With .env
|
||||
|
||||
- Create a .env file similar to .env.sample and set the variables to match your setup
|
||||
|
||||
- Run
|
||||
|
||||
```bash
|
||||
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
I am open to recieving pull requests. If you are submitting a pull request, please make sure run it locally for a day or two to make sure it is working as expected and stable. Make all pull requests against the dev branch and nothing will be merged into the main without going through the lower branches.
|
||||
|
||||
## License
|
||||
|
||||
This is currently under the GNU General Public License v3.0.
|
||||
# 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)
|
||||
|
||||
Sync watched between jellyfin, plex and emby locally
|
||||
|
||||
## Description
|
||||
|
||||
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
|
||||
|
||||
Full list of configuration options can be found in the [.env.sample](.env.sample)
|
||||
|
||||
## Installation
|
||||
|
||||
### Baremetal
|
||||
|
||||
- [Install uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
|
||||
- Create a .env file similar to .env.sample; fill in baseurls and tokens, **remember to uncomment anything you wish to use** (e.g., user mapping, library mapping, black/whitelist, etc.). If you want to store your .env file anywhere else or under a different name you can use ENV_FILE variable to specify the location.
|
||||
|
||||
- Run
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
```bash
|
||||
ENV_FILE="Test.env" uv run main.py
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
- Build docker image
|
||||
|
||||
```bash
|
||||
docker build -t jellyplex-watched .
|
||||
```
|
||||
|
||||
- or use pre-built image
|
||||
|
||||
```bash
|
||||
docker pull luigi311/jellyplex-watched:latest
|
||||
```
|
||||
|
||||
#### With variables
|
||||
|
||||
- Run
|
||||
|
||||
```bash
|
||||
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
|
||||
```
|
||||
|
||||
#### With .env
|
||||
|
||||
- Create a .env file similar to .env.sample and set the variables to match your setup
|
||||
|
||||
- Run
|
||||
|
||||
```bash
|
||||
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
|
||||
```
|
||||
|
||||
## 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
|
||||
- If you are not running all 3 supported servers, that is, Plex, Jellyfin, and Emby simultaneously, make sure to comment out the server url and token of the server you aren't using.
|
||||
|
||||
## 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.
|
||||
|
||||
## License
|
||||
|
||||
This is currently under the GNU General Public License v3.0.
|
||||
|
||||
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"
|
||||
62
entrypoint.sh
Normal file
62
entrypoint.sh
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/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" /app/.venv
|
||||
chown -R "$PUID:$PGID" "$LOG_DIR"
|
||||
chown -R "$PUID:$PGID" "$MARK_DIR"
|
||||
|
||||
# Run the application as the created user
|
||||
exec gosu "$PUID:$PGID" "$@"
|
||||
else
|
||||
# Run the application as the current user
|
||||
exec "$@"
|
||||
fi
|
||||
6
main.py
6
main.py
@@ -1,9 +1,9 @@
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Check python version 3.6 or higher
|
||||
if not (3, 6) <= tuple(map(int, sys.version_info[:2])):
|
||||
print("This script requires Python 3.6 or higher")
|
||||
# Check python version 3.12 or higher
|
||||
if not (3, 12) <= tuple(map(int, sys.version_info[:2])):
|
||||
print("This script requires Python 3.12 or higher")
|
||||
sys.exit(1)
|
||||
|
||||
from src.main import main
|
||||
|
||||
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[project]
|
||||
name = "jellyplex-watched"
|
||||
version = "8.0.0"
|
||||
description = "Sync watched between media servers locally"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"loguru>=0.7.3",
|
||||
"packaging==25.0",
|
||||
"plexapi==4.17.0",
|
||||
"pydantic==2.11.7",
|
||||
"python-dotenv==1.1.1",
|
||||
"requests==2.32.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
lint = [
|
||||
"ruff>=0.12.3",
|
||||
]
|
||||
dev = [
|
||||
"mypy>=1.16.1",
|
||||
"pytest>=8.4.1",
|
||||
"types-requests>=2.32.0.20250611",
|
||||
]
|
||||
@@ -1,4 +0,0 @@
|
||||
plexapi
|
||||
requests
|
||||
python-dotenv
|
||||
aiohttp
|
||||
85
src/black_white.py
Normal file
85
src/black_white.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from loguru import logger
|
||||
|
||||
from src.functions import search_mapping
|
||||
|
||||
|
||||
def setup_black_white_lists(
|
||||
blacklist_library: list[str] | None,
|
||||
whitelist_library: list[str] | None,
|
||||
blacklist_library_type: list[str] | None,
|
||||
whitelist_library_type: list[str] | None,
|
||||
blacklist_users: list[str] | None,
|
||||
whitelist_users: list[str] | None,
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
user_mapping: dict[str, str] | None = None,
|
||||
) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str]]:
|
||||
blacklist_library, blacklist_library_type, blacklist_users = setup_x_lists(
|
||||
blacklist_library,
|
||||
blacklist_library_type,
|
||||
blacklist_users,
|
||||
"Black",
|
||||
library_mapping,
|
||||
user_mapping,
|
||||
)
|
||||
|
||||
whitelist_library, whitelist_library_type, whitelist_users = setup_x_lists(
|
||||
whitelist_library,
|
||||
whitelist_library_type,
|
||||
whitelist_users,
|
||||
"White",
|
||||
library_mapping,
|
||||
user_mapping,
|
||||
)
|
||||
|
||||
return (
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
blacklist_users,
|
||||
whitelist_users,
|
||||
)
|
||||
|
||||
|
||||
def setup_x_lists(
|
||||
xlist_library: list[str] | None,
|
||||
xlist_library_type: list[str] | None,
|
||||
xlist_users: list[str] | None,
|
||||
xlist_type: str | None,
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
user_mapping: dict[str, str] | None = None,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
out_library: list[str] = []
|
||||
if xlist_library:
|
||||
out_library = [x.strip() for x in xlist_library]
|
||||
if library_mapping:
|
||||
temp_library: list[str] = []
|
||||
for library in xlist_library:
|
||||
library_other = search_mapping(library_mapping, library)
|
||||
if library_other:
|
||||
temp_library.append(library_other)
|
||||
|
||||
out_library = out_library + temp_library
|
||||
logger.info(f"{xlist_type}list Library: {xlist_library}")
|
||||
|
||||
out_library_type: list[str] = []
|
||||
if xlist_library_type:
|
||||
out_library_type = [x.lower().strip() for x in xlist_library_type]
|
||||
|
||||
logger.info(f"{xlist_type}list Library Type: {out_library_type}")
|
||||
|
||||
out_users: list[str] = []
|
||||
if xlist_users:
|
||||
out_users = [x.lower().strip() for x in xlist_users]
|
||||
if user_mapping:
|
||||
temp_users: list[str] = []
|
||||
for user in out_users:
|
||||
user_other = search_mapping(user_mapping, user)
|
||||
if user_other:
|
||||
temp_users.append(user_other)
|
||||
|
||||
out_users = out_users + temp_users
|
||||
|
||||
logger.info(f"{xlist_type}list Users: {out_users}")
|
||||
|
||||
return out_library, out_library_type, out_users
|
||||
125
src/connection.py
Normal file
125
src/connection.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from typing import Literal
|
||||
from loguru import logger
|
||||
|
||||
from src.functions import str_to_bool, get_env_value
|
||||
from src.plex import Plex
|
||||
from src.jellyfin import Jellyfin
|
||||
from src.emby import Emby
|
||||
|
||||
|
||||
def jellyfin_emby_server_connection(
|
||||
env,
|
||||
server_baseurl: str,
|
||||
server_token: str,
|
||||
server_type: Literal["jellyfin", "emby"],
|
||||
) -> list[Jellyfin | Emby]:
|
||||
servers: list[Jellyfin | Emby] = []
|
||||
server: Jellyfin | Emby
|
||||
|
||||
server_baseurls = server_baseurl.split(",")
|
||||
server_tokens = server_token.split(",")
|
||||
|
||||
if len(server_baseurls) != len(server_tokens):
|
||||
raise Exception(
|
||||
f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries"
|
||||
)
|
||||
|
||||
for i, base_url in enumerate(server_baseurls):
|
||||
base_url = base_url.strip()
|
||||
if base_url[-1] == "/":
|
||||
base_url = base_url[:-1]
|
||||
|
||||
if server_type == "jellyfin":
|
||||
server = Jellyfin(
|
||||
env=env, base_url=base_url, token=server_tokens[i].strip()
|
||||
)
|
||||
servers.append(server)
|
||||
|
||||
elif server_type == "emby":
|
||||
server = Emby(env=env, base_url=base_url, token=server_tokens[i].strip())
|
||||
servers.append(server)
|
||||
else:
|
||||
raise Exception("Unknown server type")
|
||||
|
||||
logger.debug(f"{server_type} Server {i} info: {server.info()}")
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def generate_server_connections(env) -> list[Plex | Jellyfin | Emby]:
|
||||
servers: list[Plex | Jellyfin | Emby] = []
|
||||
|
||||
plex_baseurl_str: str | None = get_env_value(env, "PLEX_BASEURL", None)
|
||||
plex_token_str: str | None = get_env_value(env, "PLEX_TOKEN", None)
|
||||
plex_username_str: str | None = get_env_value(env, "PLEX_USERNAME", None)
|
||||
plex_password_str: str | None = get_env_value(env, "PLEX_PASSWORD", None)
|
||||
plex_servername_str: str | None = get_env_value(env, "PLEX_SERVERNAME", None)
|
||||
ssl_bypass = str_to_bool(get_env_value(env, "SSL_BYPASS", "False"))
|
||||
|
||||
if plex_baseurl_str and plex_token_str:
|
||||
plex_baseurl = plex_baseurl_str.split(",")
|
||||
plex_token = plex_token_str.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(
|
||||
env,
|
||||
base_url=url.strip(),
|
||||
token=plex_token[i].strip(),
|
||||
user_name=None,
|
||||
password=None,
|
||||
server_name=None,
|
||||
ssl_bypass=ssl_bypass,
|
||||
)
|
||||
|
||||
logger.debug(f"Plex Server {i} info: {server.info()}")
|
||||
|
||||
servers.append(server)
|
||||
|
||||
if plex_username_str and plex_password_str and plex_servername_str:
|
||||
plex_username = plex_username_str.split(",")
|
||||
plex_password = plex_password_str.split(",")
|
||||
plex_servername = plex_servername_str.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(
|
||||
env,
|
||||
base_url=None,
|
||||
token=None,
|
||||
user_name=username.strip(),
|
||||
password=plex_password[i].strip(),
|
||||
server_name=plex_servername[i].strip(),
|
||||
ssl_bypass=ssl_bypass,
|
||||
)
|
||||
|
||||
logger.debug(f"Plex Server {i} info: {server.info()}")
|
||||
servers.append(server)
|
||||
|
||||
jellyfin_baseurl = get_env_value(env, "JELLYFIN_BASEURL", None)
|
||||
jellyfin_token = get_env_value(env, "JELLYFIN_TOKEN", None)
|
||||
if jellyfin_baseurl and jellyfin_token:
|
||||
servers.extend(
|
||||
jellyfin_emby_server_connection(
|
||||
env, jellyfin_baseurl, jellyfin_token, "jellyfin"
|
||||
)
|
||||
)
|
||||
|
||||
emby_baseurl = get_env_value(env, "EMBY_BASEURL", None)
|
||||
emby_token = get_env_value(env, "EMBY_TOKEN", None)
|
||||
if emby_baseurl and emby_token:
|
||||
servers.extend(
|
||||
jellyfin_emby_server_connection(env, emby_baseurl, emby_token, "emby")
|
||||
)
|
||||
|
||||
return servers
|
||||
32
src/emby.py
Normal file
32
src/emby.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from src.jellyfin_emby import JellyfinEmby
|
||||
from packaging.version import parse, Version
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class Emby(JellyfinEmby):
|
||||
def __init__(self, env, base_url: str, token: str) -> None:
|
||||
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__(
|
||||
env, server_type="Emby", base_url=base_url, token=token, headers=headers
|
||||
)
|
||||
|
||||
def is_partial_update_supported(self, server_version: Version) -> bool:
|
||||
if not server_version >= parse("4.4"):
|
||||
logger.info(
|
||||
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
211
src/functions.py
211
src/functions.py
@@ -1,46 +1,52 @@
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from typing import Any, Callable
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
logfile = os.getenv("LOGFILE", "log.log")
|
||||
|
||||
def log_marked(
|
||||
server_type: str,
|
||||
server_name: str,
|
||||
username: str,
|
||||
library: str,
|
||||
movie_show: str,
|
||||
episode: str | None = None,
|
||||
duration: float | None = None,
|
||||
mark_file: str = "mark.log",
|
||||
) -> None:
|
||||
output = f"{server_type}/{server_name}/{username}/{library}/{movie_show}"
|
||||
|
||||
def logger(message: str, log_type=0):
|
||||
debug = str_to_bool(os.getenv("DEBUG", "True"))
|
||||
debug_level = os.getenv("DEBUG_LEVEL", "info").lower()
|
||||
if episode:
|
||||
output += f"/{episode}"
|
||||
|
||||
output = str(message)
|
||||
if log_type == 0:
|
||||
pass
|
||||
elif log_type == 1 and (debug and debug_level == "info"):
|
||||
output = f"[INFO]: {output}"
|
||||
elif log_type == 2:
|
||||
output = f"[ERROR]: {output}"
|
||||
elif log_type == 3 and (debug and debug_level == "debug"):
|
||||
output = f"[DEBUG]: {output}"
|
||||
elif log_type == 4:
|
||||
output = f"[WARNING]: {output}"
|
||||
else:
|
||||
output = None
|
||||
if duration:
|
||||
output += f"/{duration}"
|
||||
|
||||
if output is not None:
|
||||
print(output)
|
||||
file = open(logfile, "a", encoding="utf-8")
|
||||
with open(mark_file, "a", encoding="utf-8") as file:
|
||||
file.write(output + "\n")
|
||||
|
||||
|
||||
def get_env_value(env, key: str, default: Any = None):
|
||||
if env and key in env:
|
||||
return env[key]
|
||||
elif os.getenv(key):
|
||||
return os.getenv(key)
|
||||
else:
|
||||
return default
|
||||
|
||||
|
||||
# Reimplementation of distutils.util.strtobool due to it being deprecated
|
||||
# Source: https://github.com/PostHog/posthog/blob/01e184c29d2c10c43166f1d40a334abbc3f99d8a/posthog/utils.py#L668
|
||||
def str_to_bool(value: any) -> bool:
|
||||
def str_to_bool(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
return str(value).lower() in ("y", "yes", "t", "true", "on", "1")
|
||||
|
||||
|
||||
# Get mapped value
|
||||
def search_mapping(dictionary: dict, key_value: str):
|
||||
def search_mapping(dictionary: dict[str, str], key_value: str) -> str | None:
|
||||
if key_value in dictionary.keys():
|
||||
return dictionary[key_value]
|
||||
elif key_value.lower() in dictionary.keys():
|
||||
@@ -55,135 +61,66 @@ def search_mapping(dictionary: dict, key_value: str):
|
||||
return None
|
||||
|
||||
|
||||
def check_skip_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
):
|
||||
skip_reason = None
|
||||
# Return list of objects that exist in both lists including mappings
|
||||
def match_list(
|
||||
list1: list[str], list2: list[str], list_mapping: dict[str, str] | None = None
|
||||
) -> list[str]:
|
||||
output: list[str] = []
|
||||
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)
|
||||
|
||||
if library_type.lower() in blacklist_library_type:
|
||||
skip_reason = "is blacklist_library_type"
|
||||
|
||||
if library_title.lower() in [x.lower() for x in blacklist_library]:
|
||||
skip_reason = "is blacklist_library"
|
||||
|
||||
library_other = None
|
||||
if library_mapping:
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
if library_other:
|
||||
if library_other.lower() in [x.lower() for x in blacklist_library]:
|
||||
skip_reason = "is blacklist_library"
|
||||
|
||||
if len(whitelist_library_type) > 0:
|
||||
if library_type.lower() not in whitelist_library_type:
|
||||
skip_reason = "is not whitelist_library_type"
|
||||
|
||||
# if whitelist is not empty and library is not in whitelist
|
||||
if len(whitelist_library) > 0:
|
||||
if library_title.lower() not in [x.lower() for x in whitelist_library]:
|
||||
skip_reason = "is not whitelist_library"
|
||||
|
||||
if library_other:
|
||||
if library_other.lower() not in [x.lower() for x in whitelist_library]:
|
||||
skip_reason = "is not whitelist_library"
|
||||
|
||||
return skip_reason
|
||||
return output
|
||||
|
||||
|
||||
def generate_library_guids_dict(user_list: dict):
|
||||
show_output_dict = {}
|
||||
episode_output_dict = {}
|
||||
movies_output_dict = {}
|
||||
def future_thread_executor(
|
||||
args: list[tuple[Callable[..., Any], ...]],
|
||||
threads: int | None = None,
|
||||
override_threads: bool = False,
|
||||
max_threads: int | None = None,
|
||||
) -> list[Any]:
|
||||
results: list[Any] = []
|
||||
|
||||
try:
|
||||
show_output_keys = user_list.keys()
|
||||
show_output_keys = [dict(x) for x in list(show_output_keys)]
|
||||
for show_key in show_output_keys:
|
||||
for provider_key, provider_value in show_key.items():
|
||||
# Skip title
|
||||
if provider_key.lower() == "title":
|
||||
continue
|
||||
if provider_key.lower() not in show_output_dict:
|
||||
show_output_dict[provider_key.lower()] = []
|
||||
if provider_key.lower() == "locations":
|
||||
for show_location in provider_value:
|
||||
show_output_dict[provider_key.lower()].append(show_location)
|
||||
else:
|
||||
show_output_dict[provider_key.lower()].append(
|
||||
provider_value.lower()
|
||||
)
|
||||
except Exception:
|
||||
logger("Generating show_output_dict failed, skipping", 1)
|
||||
# Determine the number of workers, defaulting to 1 if os.cpu_count() returns None
|
||||
cpu_threads: int = os.cpu_count() or 1 # Default to 1 if os.cpu_count() is None
|
||||
workers: int = min(max_threads, cpu_threads * 2) if max_threads else cpu_threads * 2
|
||||
|
||||
try:
|
||||
for show in user_list:
|
||||
for season in user_list[show]:
|
||||
for episode in user_list[show][season]:
|
||||
for episode_key, episode_value in episode.items():
|
||||
if episode_key.lower() not in episode_output_dict:
|
||||
episode_output_dict[episode_key.lower()] = []
|
||||
if episode_key == "locations":
|
||||
for episode_location in episode_value:
|
||||
episode_output_dict[episode_key.lower()].append(
|
||||
episode_location
|
||||
)
|
||||
else:
|
||||
episode_output_dict[episode_key.lower()].append(
|
||||
episode_value.lower()
|
||||
)
|
||||
except Exception:
|
||||
logger("Generating episode_output_dict failed, skipping", 1)
|
||||
# Adjust workers based on threads parameter and override_threads flag
|
||||
if threads is not None:
|
||||
workers = min(threads, workers)
|
||||
if override_threads:
|
||||
workers = threads if threads is not None else workers
|
||||
|
||||
try:
|
||||
for movie in user_list:
|
||||
for movie_key, movie_value in movie.items():
|
||||
if movie_key.lower() not in movies_output_dict:
|
||||
movies_output_dict[movie_key.lower()] = []
|
||||
if movie_key == "locations":
|
||||
for movie_location in movie_value:
|
||||
movies_output_dict[movie_key.lower()].append(movie_location)
|
||||
else:
|
||||
movies_output_dict[movie_key.lower()].append(movie_value.lower())
|
||||
except Exception:
|
||||
logger("Generating movies_output_dict failed, skipping", 1)
|
||||
|
||||
return show_output_dict, episode_output_dict, movies_output_dict
|
||||
|
||||
|
||||
def combine_watched_dicts(dicts: list):
|
||||
combined_dict = {}
|
||||
for single_dict in dicts:
|
||||
for key, value in single_dict.items():
|
||||
if key not in combined_dict:
|
||||
combined_dict[key] = {}
|
||||
for subkey, subvalue in value.items():
|
||||
combined_dict[key][subkey] = subvalue
|
||||
|
||||
return combined_dict
|
||||
|
||||
|
||||
def future_thread_executor(args: list, workers: int = -1):
|
||||
futures_list = []
|
||||
results = []
|
||||
|
||||
if workers == -1:
|
||||
workers = min(32, os.cpu_count() * 1.25)
|
||||
# If only one worker, run in main thread to avoid overhead
|
||||
if workers == 1:
|
||||
for arg in args:
|
||||
results.append(arg[0](*arg[1:]))
|
||||
return results
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures_list: list[Future[Any]] = []
|
||||
|
||||
for arg in args:
|
||||
# * arg unpacks the list into actual arguments
|
||||
futures_list.append(executor.submit(*arg))
|
||||
|
||||
for future in futures_list:
|
||||
for out in futures_list:
|
||||
try:
|
||||
result = future.result()
|
||||
result = out.result()
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
raise Exception(e)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def parse_string_to_list(string: str | None) -> list[str]:
|
||||
output: list[str] = []
|
||||
if string and len(string) > 0:
|
||||
output = string.split(",")
|
||||
|
||||
return output
|
||||
|
||||
678
src/jellyfin.py
678
src/jellyfin.py
@@ -1,646 +1,32 @@
|
||||
import asyncio, aiohttp
|
||||
from src.functions import (
|
||||
logger,
|
||||
search_mapping,
|
||||
check_skip_logic,
|
||||
generate_library_guids_dict,
|
||||
combine_watched_dicts,
|
||||
)
|
||||
|
||||
|
||||
class Jellyfin:
|
||||
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 = (
|
||||
"MediaBrowser , "
|
||||
'Client="other", '
|
||||
'Device="script", '
|
||||
'DeviceId="script", '
|
||||
'Version="0.0.0"'
|
||||
)
|
||||
headers["X-Emby-Authorization"] = authorization
|
||||
|
||||
if query_type == "get":
|
||||
async with session.get(
|
||||
self.baseurl + query, headers=headers
|
||||
) as response:
|
||||
results = await response.json()
|
||||
|
||||
elif query_type == "post":
|
||||
async with session.post(
|
||||
self.baseurl + query, headers=headers
|
||||
) as response:
|
||||
results = await response.json()
|
||||
|
||||
# append identifiers to results
|
||||
if identifiers:
|
||||
results["Identifiers"] = identifiers
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger(f"Jellyfin: Query failed {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 reponse 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_watched(
|
||||
self, user_name, user_id, library_type, library_id, library_title
|
||||
):
|
||||
try:
|
||||
user_name = user_name.lower()
|
||||
user_watched = {}
|
||||
user_watched[user_name] = {}
|
||||
|
||||
logger(
|
||||
f"Jellyfin: Generating watched for {user_name} in library {library_title}",
|
||||
0,
|
||||
)
|
||||
# Movies
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||
"get",
|
||||
session,
|
||||
)
|
||||
for movie in watched["Items"]:
|
||||
if movie["UserData"]["Played"] is True:
|
||||
movie_guids = {}
|
||||
movie_guids["title"] = movie["Name"]
|
||||
if "ProviderIds" in movie:
|
||||
# Lowercase movie["ProviderIds"] keys
|
||||
movie_guids = {
|
||||
k.lower(): v
|
||||
for k, v in movie["ProviderIds"].items()
|
||||
}
|
||||
if "MediaSources" in movie:
|
||||
movie_guids["locations"] = tuple(
|
||||
[
|
||||
x["Path"].split("/")[-1]
|
||||
for x in movie["MediaSources"]
|
||||
]
|
||||
)
|
||||
user_watched[user_name][library_title].append(movie_guids)
|
||||
|
||||
# TV Shows
|
||||
if library_type == "Series":
|
||||
user_watched[user_name][library_title] = {}
|
||||
watched_shows = await self.query(
|
||||
f"/Users/{user_id}/Items"
|
||||
+ f"?ParentId={library_id}&isPlaceHolder=false&Fields=ProviderIds,Path,RecursiveItemCount",
|
||||
"get",
|
||||
session,
|
||||
)
|
||||
watched_shows_filtered = []
|
||||
for show in watched_shows["Items"]:
|
||||
if "PlayedPercentage" in show["UserData"]:
|
||||
if show["UserData"]["PlayedPercentage"] > 0:
|
||||
watched_shows_filtered.append(show)
|
||||
seasons_tasks = []
|
||||
for show in watched_shows_filtered:
|
||||
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())
|
||||
identifiers = {"show_guids": show_guids, "show_id": show["Id"]}
|
||||
task = asyncio.ensure_future(
|
||||
self.query(
|
||||
f"/Shows/{show['Id']}/Seasons"
|
||||
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount",
|
||||
"get",
|
||||
session,
|
||||
frozenset(identifiers.items()),
|
||||
)
|
||||
)
|
||||
seasons_tasks.append(task)
|
||||
|
||||
seasons_watched = await asyncio.gather(*seasons_tasks)
|
||||
seasons_watched_filtered = []
|
||||
|
||||
for seasons in seasons_watched:
|
||||
seasons_watched_filtered_dict = {}
|
||||
seasons_watched_filtered_dict["Identifiers"] = seasons[
|
||||
"Identifiers"
|
||||
]
|
||||
seasons_watched_filtered_dict["Items"] = []
|
||||
for season in seasons["Items"]:
|
||||
if "PlayedPercentage" in season["UserData"]:
|
||||
if season["UserData"]["PlayedPercentage"] > 0:
|
||||
seasons_watched_filtered_dict["Items"].append(
|
||||
season
|
||||
)
|
||||
|
||||
if seasons_watched_filtered_dict["Items"]:
|
||||
seasons_watched_filtered.append(
|
||||
seasons_watched_filtered_dict
|
||||
)
|
||||
|
||||
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"]
|
||||
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(task)
|
||||
|
||||
watched_episodes = await asyncio.gather(*episodes_tasks)
|
||||
for episodes in watched_episodes:
|
||||
if len(episodes["Items"]) > 0:
|
||||
for episode in episodes["Items"]:
|
||||
if episode["UserData"]["Played"] is True:
|
||||
if (
|
||||
"ProviderIds" in episode
|
||||
or "MediaSources" in episode
|
||||
):
|
||||
episode_identifiers = dict(
|
||||
episodes["Identifiers"]
|
||||
)
|
||||
show_guids = episode_identifiers["show_guids"]
|
||||
if (
|
||||
show_guids
|
||||
not in user_watched[user_name][
|
||||
library_title
|
||||
]
|
||||
):
|
||||
user_watched[user_name][library_title][
|
||||
show_guids
|
||||
] = {}
|
||||
if (
|
||||
episode_identifiers["season_name"]
|
||||
not in user_watched[user_name][
|
||||
library_title
|
||||
][show_guids]
|
||||
):
|
||||
user_watched[user_name][library_title][
|
||||
show_guids
|
||||
][episode_identifiers["season_name"]] = []
|
||||
|
||||
episode_guids = {}
|
||||
if "ProviderIds" in episode:
|
||||
episode_guids = {
|
||||
k.lower(): v
|
||||
for k, v in episode[
|
||||
"ProviderIds"
|
||||
].items()
|
||||
}
|
||||
if "MediaSources" in episode:
|
||||
episode_guids["locations"] = tuple(
|
||||
[
|
||||
x["Path"].split("/")[-1]
|
||||
for x in episode["MediaSources"]
|
||||
]
|
||||
)
|
||||
user_watched[user_name][library_title][
|
||||
show_guids
|
||||
][episode_identifiers["season_name"]].append(
|
||||
episode_guids
|
||||
)
|
||||
|
||||
return user_watched
|
||||
except Exception as e:
|
||||
logger(
|
||||
f"Jellyfin: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
|
||||
2,
|
||||
)
|
||||
raise Exception(e)
|
||||
|
||||
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&limit=1",
|
||||
"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"]
|
||||
library_type = watched["Items"][0]["Type"]
|
||||
|
||||
skip_reason = check_skip_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
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
|
||||
|
||||
# Get watched for user
|
||||
task = asyncio.ensure_future(
|
||||
self.get_user_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(
|
||||
await self.get_users_watched(
|
||||
user_name,
|
||||
user_id,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
)
|
||||
)
|
||||
|
||||
for user_watched in watched:
|
||||
user_watched_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=false&ParentId={library_id}"
|
||||
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||
"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=false&ParentId={library_id}"
|
||||
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,Path",
|
||||
"get",
|
||||
session,
|
||||
)
|
||||
jellyfin_shows = [x for x in jellyfin_search["Items"]]
|
||||
|
||||
for jellyfin_show in jellyfin_shows:
|
||||
show_found = False
|
||||
|
||||
if "Path" in jellyfin_show:
|
||||
if (
|
||||
jellyfin_show["Path"].split("/")[-1]
|
||||
in videos_shows_ids["locations"]
|
||||
):
|
||||
show_found = True
|
||||
|
||||
if not show_found:
|
||||
for show_provider_source, show_provider_id in jellyfin_show[
|
||||
"ProviderIds"
|
||||
].items():
|
||||
if show_provider_source.lower() in videos_shows_ids:
|
||||
if (
|
||||
show_provider_id.lower()
|
||||
in videos_shows_ids[
|
||||
show_provider_source.lower()
|
||||
]
|
||||
):
|
||||
show_found = True
|
||||
break
|
||||
|
||||
if show_found:
|
||||
logger(
|
||||
f"Jellyfin: Updating watched for {user_name} in library {library} for show {jellyfin_show['Name']}",
|
||||
1,
|
||||
)
|
||||
jellyfin_show_id = jellyfin_show["Id"]
|
||||
jellyfin_episodes = await self.query(
|
||||
f"/Shows/{jellyfin_show_id}/Episodes"
|
||||
+ 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}",
|
||||
1,
|
||||
)
|
||||
else:
|
||||
logger(
|
||||
f"Jellyfin: Skipping show {jellyfin_show['Name']} as it is not in mark list for {user_name}",
|
||||
1,
|
||||
)
|
||||
|
||||
if (
|
||||
not videos_movies_ids
|
||||
and not videos_shows_ids
|
||||
and not videos_episodes_ids
|
||||
):
|
||||
logger(
|
||||
f"Jellyfin: No videos to mark as watched for {user_name} in library {library}",
|
||||
1,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger(
|
||||
f"Jellyfin: Error updating watched for {user_name} in library {library}, {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",
|
||||
2,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger(
|
||||
f"Jellyfin: Library {library} not found in library list",
|
||||
2,
|
||||
)
|
||||
continue
|
||||
|
||||
library_id = None
|
||||
for jellyfin_library in jellyfin_libraries:
|
||||
if jellyfin_library["Name"] == library:
|
||||
library_id = jellyfin_library["Id"]
|
||||
continue
|
||||
|
||||
if library_id:
|
||||
task = self.update_user_watched(
|
||||
user_name, user_id, library, library_id, videos, dryrun
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger(f"Jellyfin: Error updating watched, {e}", 2)
|
||||
raise Exception(e)
|
||||
from src.jellyfin_emby import JellyfinEmby
|
||||
from packaging.version import parse, Version
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class Jellyfin(JellyfinEmby):
|
||||
def __init__(self, env, base_url: str, token: str) -> None:
|
||||
authorization = (
|
||||
"MediaBrowser , "
|
||||
'Client="JellyPlex-Watched", '
|
||||
'Device="script", '
|
||||
'DeviceId="script", '
|
||||
'Version="6.0.2", '
|
||||
f'Token="{token}"'
|
||||
)
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": authorization,
|
||||
}
|
||||
|
||||
super().__init__(
|
||||
env, server_type="Jellyfin", base_url=base_url, token=token, headers=headers
|
||||
)
|
||||
|
||||
def is_partial_update_supported(self, server_version: Version) -> bool:
|
||||
if not server_version >= parse("10.9.0"):
|
||||
logger.info(
|
||||
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
841
src/jellyfin_emby.py
Normal file
841
src/jellyfin_emby.py
Normal file
@@ -0,0 +1,841 @@
|
||||
# Functions for Jellyfin and Emby
|
||||
|
||||
import requests
|
||||
import traceback
|
||||
import os
|
||||
from math import floor
|
||||
from typing import Any, Literal
|
||||
from packaging.version import parse, Version
|
||||
from loguru import logger
|
||||
|
||||
from src.functions import (
|
||||
search_mapping,
|
||||
log_marked,
|
||||
str_to_bool,
|
||||
get_env_value,
|
||||
)
|
||||
from src.watched import (
|
||||
LibraryData,
|
||||
MediaIdentifiers,
|
||||
MediaItem,
|
||||
WatchedStatus,
|
||||
Series,
|
||||
UserData,
|
||||
check_same_identifiers,
|
||||
)
|
||||
|
||||
|
||||
def extract_identifiers_from_item(
|
||||
server_type: str,
|
||||
item: dict[str, Any],
|
||||
generate_guids: bool,
|
||||
generate_locations: bool,
|
||||
) -> MediaIdentifiers:
|
||||
title = item.get("Name")
|
||||
id = None
|
||||
if not title:
|
||||
id = item.get("Id")
|
||||
logger.debug(f"{server_type}: Name not found for {id}")
|
||||
|
||||
guids = {}
|
||||
if generate_guids:
|
||||
guids = {k.lower(): v for k, v in item.get("ProviderIds", {}).items()}
|
||||
if not guids:
|
||||
logger.debug(
|
||||
f"{server_type}: {title if title else id} has no guids",
|
||||
)
|
||||
|
||||
locations: tuple[str, ...] = tuple()
|
||||
if generate_locations:
|
||||
if item.get("Path"):
|
||||
locations = tuple([item["Path"].split("/")[-1]])
|
||||
elif item.get("MediaSources"):
|
||||
locations = tuple(
|
||||
[
|
||||
x["Path"].split("/")[-1]
|
||||
for x in item["MediaSources"]
|
||||
if x.get("Path")
|
||||
]
|
||||
)
|
||||
|
||||
if not locations:
|
||||
logger.debug(f"{server_type}: {title if title else id} has no locations")
|
||||
|
||||
return MediaIdentifiers(
|
||||
title=title,
|
||||
locations=locations,
|
||||
imdb_id=guids.get("imdb"),
|
||||
tvdb_id=guids.get("tvdb"),
|
||||
tmdb_id=guids.get("tmdb"),
|
||||
)
|
||||
|
||||
|
||||
def get_mediaitem(
|
||||
server_type: str,
|
||||
item: dict[str, Any],
|
||||
generate_guids: bool,
|
||||
generate_locations: bool,
|
||||
) -> MediaItem:
|
||||
return MediaItem(
|
||||
identifiers=extract_identifiers_from_item(
|
||||
server_type, item, generate_guids, generate_locations
|
||||
),
|
||||
status=WatchedStatus(
|
||||
completed=item.get("UserData", {}).get("Played"),
|
||||
time=floor(
|
||||
item.get("UserData", {}).get("PlaybackPositionTicks", 0) / 10000
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class JellyfinEmby:
|
||||
def __init__(
|
||||
self,
|
||||
env,
|
||||
server_type: Literal["Jellyfin", "Emby"],
|
||||
base_url: str,
|
||||
token: str,
|
||||
headers: dict[str, str],
|
||||
) -> None:
|
||||
self.env = env
|
||||
|
||||
if server_type not in ["Jellyfin", "Emby"]:
|
||||
raise Exception(f"Server type {server_type} not supported")
|
||||
self.server_type: str = server_type
|
||||
self.base_url: str = base_url
|
||||
self.token: str = token
|
||||
self.headers: dict[str, str] = headers
|
||||
self.timeout: int = int(get_env_value(self.env, "REQUEST_TIMEOUT", 300))
|
||||
|
||||
if not self.base_url:
|
||||
raise Exception(f"{self.server_type} base_url not set")
|
||||
|
||||
if not self.token:
|
||||
raise Exception(f"{self.server_type} token not set")
|
||||
|
||||
self.session = requests.Session()
|
||||
self.users: dict[str, str] = self.get_users()
|
||||
self.server_name: str = self.info(name_only=True)
|
||||
self.server_version: Version = self.info(version_only=True)
|
||||
self.update_partial: bool = self.is_partial_update_supported(
|
||||
self.server_version
|
||||
)
|
||||
self.generate_guids: bool = str_to_bool(
|
||||
get_env_value(self.env, "GENERATE_GUIDS", "True")
|
||||
)
|
||||
self.generate_locations: bool = str_to_bool(
|
||||
get_env_value(self.env, "GENERATE_LOCATIONS", "True")
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
query: str,
|
||||
query_type: Literal["get", "post"],
|
||||
identifiers: dict[str, str] | None = None,
|
||||
json: dict[str, float] | None = None,
|
||||
) -> list[dict[str, Any]] | dict[str, Any] | None:
|
||||
try:
|
||||
results = None
|
||||
|
||||
if query_type == "get":
|
||||
response = self.session.get(
|
||||
self.base_url + 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.base_url + 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:
|
||||
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 and isinstance(results, dict):
|
||||
results["Identifiers"] = identifiers
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}",
|
||||
)
|
||||
raise Exception(e)
|
||||
|
||||
def info(
|
||||
self, name_only: bool = False, version_only: bool = False
|
||||
) -> str | Version | None:
|
||||
try:
|
||||
query_string = "/System/Info/Public"
|
||||
|
||||
response = self.query(query_string, "get")
|
||||
|
||||
if response and isinstance(response, dict):
|
||||
if name_only:
|
||||
return response.get("ServerName")
|
||||
elif version_only:
|
||||
return parse(response.get("Version", ""))
|
||||
|
||||
return f"{self.server_type} {response.get('ServerName')}: {response.get('Version')}"
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{self.server_type}: Get server name failed {e}")
|
||||
raise Exception(e)
|
||||
|
||||
def get_users(self) -> dict[str, str]:
|
||||
try:
|
||||
users: dict[str, str] = {}
|
||||
|
||||
query_string = "/Users"
|
||||
response = self.query(query_string, "get")
|
||||
|
||||
if response and isinstance(response, list):
|
||||
for user in response:
|
||||
users[user["Name"]] = user["Id"]
|
||||
|
||||
return users
|
||||
except Exception as e:
|
||||
logger.error(f"{self.server_type}: Get users failed {e}")
|
||||
raise Exception(e)
|
||||
|
||||
def get_libraries(self) -> dict[str, str]:
|
||||
try:
|
||||
libraries: dict[str, str] = {}
|
||||
|
||||
# Theres no way to get all libraries so individually get list of libraries from all users
|
||||
users = self.get_users()
|
||||
|
||||
for user_name, user_id in users.items():
|
||||
user_libraries = self.query(f"/Users/{user_id}/Views", "get")
|
||||
|
||||
if not user_libraries or not isinstance(user_libraries, dict):
|
||||
logger.error(
|
||||
f"{self.server_type}: Failed to get libraries for {user_name}"
|
||||
)
|
||||
return libraries
|
||||
|
||||
logger.debug(
|
||||
f"{self.server_type}: All Libraries for {user_name} {[library.get('Name') for library in user_libraries.get('Items', [])]}"
|
||||
)
|
||||
|
||||
for library in user_libraries.get("Items", []):
|
||||
library_title = library.get("Name")
|
||||
library_type = library.get("CollectionType")
|
||||
|
||||
# If collection type is not set, fallback based on media files
|
||||
if not library_type:
|
||||
library_id = library.get("Id")
|
||||
# Get first 100 items in library
|
||||
library_items = self.query(
|
||||
f"/Users/{user_id}/Items"
|
||||
+ f"?ParentId={library_id}&Recursive=True&excludeItemTypes=Folder&limit=100",
|
||||
"get",
|
||||
)
|
||||
|
||||
if not library_items or not isinstance(library_items, dict):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get library items for {user_name} {library_title}"
|
||||
)
|
||||
continue
|
||||
|
||||
all_types = set(
|
||||
[x.get("Type") for x in library_items.get("Items", [])]
|
||||
)
|
||||
types = set([x for x in all_types if x in ["Movie", "Episode"]])
|
||||
|
||||
if not len(types) == 1:
|
||||
logger.debug(
|
||||
f"{self.server_type}: Skipping Library {library_title} didn't find just a single type, found {all_types}",
|
||||
)
|
||||
continue
|
||||
|
||||
library_type = types.pop()
|
||||
|
||||
library_type = (
|
||||
"movies" if library_type == "Movie" else "tvshows"
|
||||
)
|
||||
|
||||
if library_type not in ["movies", "tvshows"]:
|
||||
logger.debug(
|
||||
f"{self.server_type}: Skipping Library {library_title} found type {library_type}",
|
||||
)
|
||||
continue
|
||||
|
||||
libraries[library_title] = library_type
|
||||
|
||||
return libraries
|
||||
except Exception as e:
|
||||
logger.error(f"{self.server_type}: Get libraries failed {e}")
|
||||
raise Exception(e)
|
||||
|
||||
def get_user_library_watched(
|
||||
self,
|
||||
user_name: str,
|
||||
user_id: str,
|
||||
library_type: Literal["movies", "tvshows"],
|
||||
library_id: str,
|
||||
library_title: str,
|
||||
) -> LibraryData:
|
||||
user_name = user_name.lower()
|
||||
try:
|
||||
logger.info(
|
||||
f"{self.server_type}: Generating watched for {user_name} in library {library_title}",
|
||||
)
|
||||
watched = LibraryData(title=library_title)
|
||||
|
||||
# Movies
|
||||
if library_type == "movies":
|
||||
movie_items = []
|
||||
watched_items = self.query(
|
||||
f"/Users/{user_id}/Items"
|
||||
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||
"get",
|
||||
)
|
||||
|
||||
if watched_items and isinstance(watched_items, dict):
|
||||
movie_items += watched_items.get("Items", [])
|
||||
|
||||
in_progress_items = self.query(
|
||||
f"/Users/{user_id}/Items"
|
||||
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||
"get",
|
||||
)
|
||||
|
||||
if in_progress_items and isinstance(in_progress_items, dict):
|
||||
movie_items += in_progress_items.get("Items", [])
|
||||
|
||||
for movie in movie_items:
|
||||
# Skip if theres no user data which means the movie has not been watched
|
||||
if not movie.get("UserData"):
|
||||
continue
|
||||
|
||||
# Skip if theres no media tied to the movie
|
||||
if not movie.get("MediaSources"):
|
||||
continue
|
||||
|
||||
# Skip if not watched or watched less than a minute
|
||||
if (
|
||||
movie["UserData"].get("Played")
|
||||
or movie["UserData"].get("PlaybackPositionTicks", 0) > 600000000
|
||||
):
|
||||
watched.movies.append(
|
||||
get_mediaitem(
|
||||
self.server_type,
|
||||
movie,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
)
|
||||
|
||||
# TV Shows
|
||||
if library_type == "tvshows":
|
||||
# Retrieve a list of watched TV shows
|
||||
all_shows = self.query(
|
||||
f"/Users/{user_id}/Items"
|
||||
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
|
||||
"get",
|
||||
)
|
||||
|
||||
if not all_shows or not isinstance(all_shows, dict):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get shows for {user_name} in {library_title}"
|
||||
)
|
||||
return watched
|
||||
|
||||
# Filter the list of shows to only include those that have been partially or fully watched
|
||||
watched_shows_filtered = []
|
||||
for show in all_shows.get("Items", []):
|
||||
if not show.get("UserData"):
|
||||
continue
|
||||
|
||||
if show["UserData"].get("PlayedPercentage", 0) > 0:
|
||||
watched_shows_filtered.append(show)
|
||||
|
||||
# Retrieve the watched/partially watched list of episodes of each watched show
|
||||
for show in watched_shows_filtered:
|
||||
show_name = show.get("Name")
|
||||
show_guids = {
|
||||
k.lower(): v for k, v in show.get("ProviderIds", {}).items()
|
||||
}
|
||||
show_locations = (
|
||||
tuple([show["Path"].split("/")[-1]])
|
||||
if show.get("Path")
|
||||
else tuple()
|
||||
)
|
||||
|
||||
show_episodes = self.query(
|
||||
f"/Shows/{show.get('Id')}/Episodes"
|
||||
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources",
|
||||
"get",
|
||||
)
|
||||
|
||||
if not show_episodes or not isinstance(show_episodes, dict):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get episodes for {user_name} {library_title} {show_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Iterate through the episodes
|
||||
# Create a list to store the episodes
|
||||
episode_mediaitem = []
|
||||
for episode in show_episodes.get("Items", []):
|
||||
if not episode.get("UserData"):
|
||||
continue
|
||||
|
||||
if not episode.get("MediaSources"):
|
||||
continue
|
||||
|
||||
# If watched or watched more than a minute
|
||||
if (
|
||||
episode["UserData"].get("Played")
|
||||
or episode["UserData"].get("PlaybackPositionTicks", 0)
|
||||
> 600000000
|
||||
):
|
||||
episode_mediaitem.append(
|
||||
get_mediaitem(
|
||||
self.server_type,
|
||||
episode,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
)
|
||||
|
||||
if episode_mediaitem:
|
||||
watched.series.append(
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title=show.get("Name"),
|
||||
locations=show_locations,
|
||||
imdb_id=show_guids.get("imdb"),
|
||||
tvdb_id=show_guids.get("tvdb"),
|
||||
tmdb_id=show_guids.get("tmdb"),
|
||||
),
|
||||
episodes=episode_mediaitem,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"{self.server_type}: Finished getting watched for {user_name} in library {library_title}",
|
||||
)
|
||||
|
||||
return watched
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
|
||||
)
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return LibraryData(title=library_title)
|
||||
|
||||
def get_watched(
|
||||
self,
|
||||
users: dict[str, str],
|
||||
sync_libraries: list[str],
|
||||
users_watched: dict[str, UserData] = None,
|
||||
) -> dict[str, UserData]:
|
||||
try:
|
||||
if not users_watched:
|
||||
users_watched: dict[str, UserData] = {}
|
||||
|
||||
for user_name, user_id in users.items():
|
||||
if user_name.lower() not in users_watched:
|
||||
users_watched[user_name.lower()] = UserData()
|
||||
|
||||
all_libraries = self.query(f"/Users/{user_id}/Views", "get")
|
||||
if not all_libraries or not isinstance(all_libraries, dict):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get all libraries for {user_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
for library in all_libraries.get("Items", []):
|
||||
library_id = library.get("Id")
|
||||
library_title = library.get("Name")
|
||||
library_type = library.get("CollectionType")
|
||||
|
||||
if not library_id or not library_title or not library_type:
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get library data for {user_name} {library_title}"
|
||||
)
|
||||
continue
|
||||
|
||||
if library_title not in sync_libraries:
|
||||
continue
|
||||
|
||||
if library_title in users_watched:
|
||||
logger.info(
|
||||
f"{self.server_type}: {user_name} {library_title} watched history has already been gathered, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get watched for user
|
||||
library_data = 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()] = UserData()
|
||||
|
||||
users_watched[user_name.lower()].libraries[library_title] = (
|
||||
library_data
|
||||
)
|
||||
|
||||
return users_watched
|
||||
except Exception as e:
|
||||
logger.error(f"{self.server_type}: Failed to get watched, Error: {e}")
|
||||
return {}
|
||||
|
||||
def update_user_watched(
|
||||
self,
|
||||
user_name: str,
|
||||
user_id: str,
|
||||
library_data: LibraryData,
|
||||
library_name: str,
|
||||
library_id: str,
|
||||
dryrun: bool,
|
||||
) -> None:
|
||||
try:
|
||||
# If there are no movies or shows to update, exit early.
|
||||
if not library_data.series and not library_data.movies:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"{self.server_type}: Updating watched for {user_name} in library {library_name}",
|
||||
)
|
||||
|
||||
# Update movies.
|
||||
if library_data.movies:
|
||||
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",
|
||||
)
|
||||
|
||||
if not jellyfin_search or not isinstance(jellyfin_search, dict):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get movies for {user_name} {library_name}"
|
||||
)
|
||||
return
|
||||
|
||||
for jellyfin_video in jellyfin_search.get("Items", []):
|
||||
jelly_identifiers = extract_identifiers_from_item(
|
||||
self.server_type,
|
||||
jellyfin_video,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
# Check each stored movie for a match.
|
||||
for stored_movie in library_data.movies:
|
||||
if check_same_identifiers(
|
||||
jelly_identifiers, stored_movie.identifiers
|
||||
):
|
||||
jellyfin_video_id = jellyfin_video.get("Id")
|
||||
if stored_movie.status.completed:
|
||||
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library_name}"
|
||||
if not dryrun:
|
||||
self.query(
|
||||
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
|
||||
"post",
|
||||
)
|
||||
|
||||
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
|
||||
log_marked(
|
||||
self.server_type,
|
||||
self.server_name,
|
||||
user_name,
|
||||
library_name,
|
||||
jellyfin_video.get("Name"),
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
elif self.update_partial:
|
||||
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user_name} in {library_name}"
|
||||
|
||||
if not dryrun:
|
||||
playback_position_payload: dict[str, float] = {
|
||||
"PlaybackPositionTicks": stored_movie.status.time
|
||||
* 10_000,
|
||||
}
|
||||
self.query(
|
||||
f"/Users/{user_id}/Items/{jellyfin_video_id}/UserData",
|
||||
"post",
|
||||
json=playback_position_payload,
|
||||
)
|
||||
|
||||
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
|
||||
log_marked(
|
||||
self.server_type,
|
||||
self.server_name,
|
||||
user_name,
|
||||
library_name,
|
||||
jellyfin_video.get("Name"),
|
||||
duration=floor(stored_movie.status.time / 60_000),
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.trace(
|
||||
f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}",
|
||||
)
|
||||
|
||||
# Update TV Shows (series/episodes).
|
||||
if library_data.series:
|
||||
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",
|
||||
)
|
||||
if not jellyfin_search or not isinstance(jellyfin_search, dict):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get shows for {user_name} {library_name}"
|
||||
)
|
||||
return
|
||||
|
||||
jellyfin_shows = [x for x in jellyfin_search.get("Items", [])]
|
||||
|
||||
for jellyfin_show in jellyfin_shows:
|
||||
jellyfin_show_identifiers = extract_identifiers_from_item(
|
||||
self.server_type,
|
||||
jellyfin_show,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
# Try to find a matching series in your stored library.
|
||||
for stored_series in library_data.series:
|
||||
if check_same_identifiers(
|
||||
jellyfin_show_identifiers, stored_series.identifiers
|
||||
):
|
||||
logger.trace(
|
||||
f"Found matching show for '{jellyfin_show.get('Name')}'",
|
||||
)
|
||||
# Now update episodes.
|
||||
# Get the list of Plex episodes for this show.
|
||||
jellyfin_show_id = jellyfin_show.get("Id")
|
||||
jellyfin_episodes = self.query(
|
||||
f"/Shows/{jellyfin_show_id}/Episodes"
|
||||
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
|
||||
"get",
|
||||
)
|
||||
|
||||
if not jellyfin_episodes or not isinstance(
|
||||
jellyfin_episodes, dict
|
||||
):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get episodes for {user_name} {library_name} {jellyfin_show.get('Name')}"
|
||||
)
|
||||
return
|
||||
|
||||
for jellyfin_episode in jellyfin_episodes.get("Items", []):
|
||||
jellyfin_episode_identifiers = (
|
||||
extract_identifiers_from_item(
|
||||
self.server_type,
|
||||
jellyfin_episode,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
)
|
||||
for stored_ep in stored_series.episodes:
|
||||
if check_same_identifiers(
|
||||
jellyfin_episode_identifiers,
|
||||
stored_ep.identifiers,
|
||||
):
|
||||
jellyfin_episode_id = jellyfin_episode.get("Id")
|
||||
if stored_ep.status.completed:
|
||||
msg = (
|
||||
f"{self.server_type}: {jellyfin_episode.get('SeriesName')} {jellyfin_episode.get('SeasonName')} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
|
||||
+ f" as watched for {user_name} in {library_name}"
|
||||
)
|
||||
if not dryrun:
|
||||
self.query(
|
||||
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
|
||||
"post",
|
||||
)
|
||||
|
||||
logger.success(
|
||||
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
|
||||
)
|
||||
log_marked(
|
||||
self.server_type,
|
||||
self.server_name,
|
||||
user_name,
|
||||
library_name,
|
||||
jellyfin_episode.get("SeriesName"),
|
||||
jellyfin_episode.get("Name"),
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
elif self.update_partial:
|
||||
msg = (
|
||||
f"{self.server_type}: {jellyfin_episode.get('SeriesName')} {jellyfin_episode.get('SeasonName')} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
|
||||
+ f" as partially watched for {floor(stored_ep.status.time / 60_000)} minutes for {user_name} in {library_name}"
|
||||
)
|
||||
|
||||
if not dryrun:
|
||||
playback_position_payload = {
|
||||
"PlaybackPositionTicks": stored_ep.status.time
|
||||
* 10_000,
|
||||
}
|
||||
self.query(
|
||||
f"/Users/{user_id}/Items/{jellyfin_episode_id}/UserData",
|
||||
"post",
|
||||
json=playback_position_payload,
|
||||
)
|
||||
|
||||
logger.success(
|
||||
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
|
||||
)
|
||||
log_marked(
|
||||
self.server_type,
|
||||
self.server_name,
|
||||
user_name,
|
||||
library_name,
|
||||
jellyfin_episode.get("SeriesName"),
|
||||
jellyfin_episode.get("Name"),
|
||||
duration=floor(
|
||||
stored_ep.status.time / 60_000
|
||||
),
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.trace(
|
||||
f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}",
|
||||
)
|
||||
else:
|
||||
logger.trace(
|
||||
f"{self.server_type}: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.server_type}: Error updating watched for {user_name} in library {library_name}, {e}",
|
||||
)
|
||||
|
||||
def update_watched(
|
||||
self,
|
||||
watched_list: dict[str, UserData],
|
||||
user_mapping: dict[str, str] | None = None,
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
dryrun: bool = False,
|
||||
) -> None:
|
||||
for user, user_data in watched_list.items():
|
||||
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 or not user_name:
|
||||
logger.info(f"{user} {user_other} not found in Jellyfin")
|
||||
continue
|
||||
|
||||
jellyfin_libraries = self.query(
|
||||
f"/Users/{user_id}/Views",
|
||||
"get",
|
||||
)
|
||||
|
||||
if not jellyfin_libraries or not isinstance(jellyfin_libraries, dict):
|
||||
logger.debug(
|
||||
f"{self.server_type}: Failed to get libraries for {user_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
jellyfin_libraries = [x for x in jellyfin_libraries.get("Items", [])]
|
||||
|
||||
for library_name in user_data.libraries:
|
||||
library_data = user_data.libraries[library_name]
|
||||
library_other = None
|
||||
if library_mapping:
|
||||
if library_name in library_mapping.keys():
|
||||
library_other = library_mapping[library_name]
|
||||
elif library_name in library_mapping.values():
|
||||
library_other = search_mapping(library_mapping, library_name)
|
||||
|
||||
if library_name.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.info(
|
||||
f"{self.server_type}: Library {library_name} not found, but {library_other} found, using {library_other}",
|
||||
)
|
||||
library_name = library_other
|
||||
else:
|
||||
logger.info(
|
||||
f"{self.server_type}: Library {library_name} or {library_other} not found in library list",
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.info(
|
||||
f"{self.server_type}: Library {library_name} not found in library list",
|
||||
)
|
||||
continue
|
||||
|
||||
library_id = None
|
||||
for jellyfin_library in jellyfin_libraries:
|
||||
if jellyfin_library["Name"].lower() == library_name.lower():
|
||||
library_id = jellyfin_library["Id"]
|
||||
continue
|
||||
|
||||
if library_id:
|
||||
try:
|
||||
self.update_user_watched(
|
||||
user_name,
|
||||
user_id,
|
||||
library_data,
|
||||
library_name,
|
||||
library_id,
|
||||
dryrun,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.server_type}: Error updating watched for {user_name} in library {library_name}, {e}",
|
||||
)
|
||||
207
src/library.py
Normal file
207
src/library.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from loguru import logger
|
||||
|
||||
from src.functions import (
|
||||
match_list,
|
||||
search_mapping,
|
||||
)
|
||||
|
||||
from src.emby import Emby
|
||||
from src.jellyfin import Jellyfin
|
||||
from src.plex import Plex
|
||||
|
||||
|
||||
def check_skip_logic(
|
||||
library_title: str,
|
||||
library_type: str,
|
||||
blacklist_library: list[str],
|
||||
whitelist_library: list[str],
|
||||
blacklist_library_type: list[str],
|
||||
whitelist_library_type: list[str],
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
) -> str | None:
|
||||
skip_reason = None
|
||||
library_other = None
|
||||
if library_mapping:
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
|
||||
skip_reason_black = check_blacklist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
blacklist_library_type,
|
||||
library_other,
|
||||
)
|
||||
skip_reason_white = check_whitelist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
whitelist_library,
|
||||
whitelist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
# Combine skip reasons
|
||||
if skip_reason_black:
|
||||
skip_reason = skip_reason_black
|
||||
|
||||
if skip_reason_white:
|
||||
if skip_reason:
|
||||
skip_reason = skip_reason + " and " + skip_reason_white
|
||||
else:
|
||||
skip_reason = skip_reason_white
|
||||
|
||||
return skip_reason
|
||||
|
||||
|
||||
def check_blacklist_logic(
|
||||
library_title: str,
|
||||
library_type: str,
|
||||
blacklist_library: list[str],
|
||||
blacklist_library_type: list[str],
|
||||
library_other: str | None = None,
|
||||
) -> str | None:
|
||||
skip_reason = None
|
||||
if isinstance(library_type, (list, tuple, set)):
|
||||
for library_type_item in library_type:
|
||||
if library_type_item.lower() in blacklist_library_type:
|
||||
skip_reason = f"{library_type_item} is in blacklist_library_type"
|
||||
else:
|
||||
if library_type.lower() in blacklist_library_type:
|
||||
skip_reason = f"{library_type} is in blacklist_library_type"
|
||||
|
||||
if library_title.lower() in [x.lower() for x in blacklist_library]:
|
||||
if skip_reason:
|
||||
skip_reason = (
|
||||
skip_reason + " and " + f"{library_title} is in blacklist_library"
|
||||
)
|
||||
else:
|
||||
skip_reason = f"{library_title} is in blacklist_library"
|
||||
|
||||
if library_other:
|
||||
if library_other.lower() in [x.lower() for x in blacklist_library]:
|
||||
if skip_reason:
|
||||
skip_reason = (
|
||||
skip_reason + " and " + f"{library_other} is in blacklist_library"
|
||||
)
|
||||
else:
|
||||
skip_reason = f"{library_other} is in blacklist_library"
|
||||
|
||||
return skip_reason
|
||||
|
||||
|
||||
def check_whitelist_logic(
|
||||
library_title: str,
|
||||
library_type: str,
|
||||
whitelist_library: list[str],
|
||||
whitelist_library_type: list[str],
|
||||
library_other: str | None = None,
|
||||
) -> str | None:
|
||||
skip_reason = None
|
||||
if len(whitelist_library_type) > 0:
|
||||
if isinstance(library_type, (list, tuple, set)):
|
||||
for library_type_item in library_type:
|
||||
if library_type_item.lower() not in whitelist_library_type:
|
||||
skip_reason = (
|
||||
f"{library_type_item} is not in whitelist_library_type"
|
||||
)
|
||||
else:
|
||||
if library_type.lower() not in whitelist_library_type:
|
||||
skip_reason = f"{library_type} is not in whitelist_library_type"
|
||||
|
||||
# if whitelist is not empty and library is not in whitelist
|
||||
if len(whitelist_library) > 0:
|
||||
if library_other:
|
||||
if library_title.lower() not in [
|
||||
x.lower() for x in whitelist_library
|
||||
] and library_other.lower() not in [x.lower() for x in whitelist_library]:
|
||||
if skip_reason:
|
||||
skip_reason = (
|
||||
skip_reason
|
||||
+ " and "
|
||||
+ f"{library_title} is not in whitelist_library"
|
||||
)
|
||||
else:
|
||||
skip_reason = f"{library_title} is not in whitelist_library"
|
||||
else:
|
||||
if library_title.lower() not in [x.lower() for x in whitelist_library]:
|
||||
if skip_reason:
|
||||
skip_reason = (
|
||||
skip_reason
|
||||
+ " and "
|
||||
+ f"{library_title} is not in whitelist_library"
|
||||
)
|
||||
else:
|
||||
skip_reason = f"{library_title} is not in whitelist_library"
|
||||
|
||||
return skip_reason
|
||||
|
||||
|
||||
def filter_libaries(
|
||||
server_libraries: dict[str, str],
|
||||
blacklist_library: list[str],
|
||||
blacklist_library_type: list[str],
|
||||
whitelist_library: list[str],
|
||||
whitelist_library_type: list[str],
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
filtered_libaries: list[str] = []
|
||||
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.info(f"Skipping library {library}: {skip_reason}")
|
||||
continue
|
||||
|
||||
filtered_libaries.append(library)
|
||||
|
||||
return filtered_libaries
|
||||
|
||||
|
||||
def setup_libraries(
|
||||
server_1: Plex | Jellyfin | Emby,
|
||||
server_2: Plex | Jellyfin | Emby,
|
||||
blacklist_library: list[str],
|
||||
blacklist_library_type: list[str],
|
||||
whitelist_library: list[str],
|
||||
whitelist_library_type: list[str],
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
server_1_libraries = server_1.get_libraries()
|
||||
server_2_libraries = server_2.get_libraries()
|
||||
|
||||
logger.debug(f"{server_1.server_type}: Libraries and types {server_1_libraries}")
|
||||
logger.debug(f"{server_2.server_type}: Libraries and types {server_2_libraries}")
|
||||
|
||||
# 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
|
||||
775
src/main.py
775
src/main.py
@@ -1,542 +1,156 @@
|
||||
import copy, os, traceback, json, asyncio
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import traceback
|
||||
import json
|
||||
import sys
|
||||
from dotenv import dotenv_values
|
||||
from time import sleep, perf_counter
|
||||
from loguru import logger
|
||||
|
||||
from src.functions import (
|
||||
logger,
|
||||
str_to_bool,
|
||||
search_mapping,
|
||||
generate_library_guids_dict,
|
||||
)
|
||||
from src.plex import Plex
|
||||
from src.emby import Emby
|
||||
from src.jellyfin import Jellyfin
|
||||
|
||||
load_dotenv(override=True)
|
||||
from src.plex import Plex
|
||||
from src.library import setup_libraries
|
||||
from src.functions import (
|
||||
parse_string_to_list,
|
||||
str_to_bool,
|
||||
get_env_value,
|
||||
)
|
||||
from src.users import setup_users
|
||||
from src.watched import (
|
||||
cleanup_watched,
|
||||
merge_server_watched,
|
||||
)
|
||||
from src.black_white import setup_black_white_lists
|
||||
from src.connection import generate_server_connections
|
||||
|
||||
|
||||
def cleanup_watched(
|
||||
watched_list_1, watched_list_2, user_mapping=None, library_mapping=None
|
||||
):
|
||||
modified_watched_list_1 = copy.deepcopy(watched_list_1)
|
||||
def configure_logger(log_file: str = "log.log", debug_level: str = "INFO") -> None:
|
||||
# Remove default logger to configure our own
|
||||
logger.remove()
|
||||
|
||||
# remove entries from plex_watched that are in jellyfin_watched
|
||||
for user_1 in watched_list_1:
|
||||
user_other = None
|
||||
if user_mapping:
|
||||
user_other = search_mapping(user_mapping, user_1)
|
||||
if user_1 in modified_watched_list_1:
|
||||
if user_1 in watched_list_2:
|
||||
user_2 = user_1
|
||||
elif user_other in watched_list_2:
|
||||
user_2 = user_other
|
||||
else:
|
||||
logger(f"User {user_1} and {user_other} not found in watched list 2", 1)
|
||||
continue
|
||||
# Choose log level based on environment
|
||||
# If in debug mode with a "debug" level, use DEBUG; otherwise, default to INFO.
|
||||
|
||||
for library_1 in watched_list_1[user_1]:
|
||||
library_other = None
|
||||
if library_mapping:
|
||||
library_other = search_mapping(library_mapping, library_1)
|
||||
if library_1 in modified_watched_list_1[user_1]:
|
||||
if library_1 in watched_list_2[user_2]:
|
||||
library_2 = library_1
|
||||
elif library_other in watched_list_2[user_2]:
|
||||
library_2 = library_other
|
||||
else:
|
||||
logger(
|
||||
f"library {library_1} and {library_other} not found in watched list 2",
|
||||
1,
|
||||
)
|
||||
continue
|
||||
if debug_level not in ["INFO", "DEBUG", "TRACE"]:
|
||||
logger.add(sys.stdout)
|
||||
raise Exception(
|
||||
f"Invalid DEBUG_LEVEL {debug_level}, please choose between INFO, DEBUG, TRACE"
|
||||
)
|
||||
|
||||
(
|
||||
_,
|
||||
episode_watched_list_2_keys_dict,
|
||||
movies_watched_list_2_keys_dict,
|
||||
) = generate_library_guids_dict(watched_list_2[user_2][library_2])
|
||||
|
||||
# Movies
|
||||
if isinstance(watched_list_1[user_1][library_1], list):
|
||||
for movie in watched_list_1[user_1][library_1]:
|
||||
movie_found = False
|
||||
for movie_key, movie_value in movie.items():
|
||||
if movie_key == "locations":
|
||||
if (
|
||||
"locations"
|
||||
in movies_watched_list_2_keys_dict.keys()
|
||||
):
|
||||
for location in movie_value:
|
||||
if (
|
||||
location
|
||||
in movies_watched_list_2_keys_dict[
|
||||
"locations"
|
||||
]
|
||||
):
|
||||
movie_found = True
|
||||
break
|
||||
else:
|
||||
if (
|
||||
movie_key
|
||||
in movies_watched_list_2_keys_dict.keys()
|
||||
):
|
||||
if (
|
||||
movie_value
|
||||
in movies_watched_list_2_keys_dict[
|
||||
movie_key
|
||||
]
|
||||
):
|
||||
movie_found = True
|
||||
|
||||
if movie_found:
|
||||
logger(f"Removing {movie} from {library_1}", 3)
|
||||
modified_watched_list_1[user_1][library_1].remove(
|
||||
movie
|
||||
)
|
||||
break
|
||||
|
||||
# TV Shows
|
||||
elif isinstance(watched_list_1[user_1][library_1], dict):
|
||||
# Generate full list of provider ids for episodes in watch_list_2 to easily compare if they exist in watch_list_1
|
||||
|
||||
for show_key_1 in watched_list_1[user_1][library_1].keys():
|
||||
show_key_dict = dict(show_key_1)
|
||||
for season in watched_list_1[user_1][library_1][show_key_1]:
|
||||
for episode in watched_list_1[user_1][library_1][
|
||||
show_key_1
|
||||
][season]:
|
||||
episode_found = False
|
||||
for episode_key, episode_value in episode.items():
|
||||
# If episode_key and episode_value are in episode_watched_list_2_keys_dict exactly, then remove from watch_list_1
|
||||
if episode_key == "locations":
|
||||
if (
|
||||
"locations"
|
||||
in episode_watched_list_2_keys_dict.keys()
|
||||
):
|
||||
for location in episode_value:
|
||||
if (
|
||||
location
|
||||
in episode_watched_list_2_keys_dict[
|
||||
"locations"
|
||||
]
|
||||
):
|
||||
episode_found = True
|
||||
break
|
||||
|
||||
else:
|
||||
if (
|
||||
episode_key
|
||||
in episode_watched_list_2_keys_dict.keys()
|
||||
):
|
||||
if (
|
||||
episode_value
|
||||
in episode_watched_list_2_keys_dict[
|
||||
episode_key
|
||||
]
|
||||
):
|
||||
episode_found = True
|
||||
|
||||
if episode_found:
|
||||
if (
|
||||
episode
|
||||
in modified_watched_list_1[user_1][
|
||||
library_1
|
||||
][show_key_1][season]
|
||||
):
|
||||
logger(
|
||||
f"Removing {episode} from {show_key_dict['title']}",
|
||||
3,
|
||||
)
|
||||
modified_watched_list_1[user_1][
|
||||
library_1
|
||||
][show_key_1][season].remove(episode)
|
||||
break
|
||||
|
||||
# Remove empty seasons
|
||||
if (
|
||||
len(
|
||||
modified_watched_list_1[user_1][library_1][
|
||||
show_key_1
|
||||
][season]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
if (
|
||||
season
|
||||
in modified_watched_list_1[user_1][library_1][
|
||||
show_key_1
|
||||
]
|
||||
):
|
||||
logger(
|
||||
f"Removing {season} from {show_key_dict['title']} because it is empty",
|
||||
3,
|
||||
)
|
||||
del modified_watched_list_1[user_1][library_1][
|
||||
show_key_1
|
||||
][season]
|
||||
|
||||
# If the show is empty, remove the show
|
||||
if (
|
||||
len(
|
||||
modified_watched_list_1[user_1][library_1][
|
||||
show_key_1
|
||||
]
|
||||
)
|
||||
== 0
|
||||
):
|
||||
if (
|
||||
show_key_1
|
||||
in modified_watched_list_1[user_1][library_1]
|
||||
):
|
||||
logger(
|
||||
f"Removing {show_key_dict['title']} from {library_1} because it is empty",
|
||||
1,
|
||||
)
|
||||
del modified_watched_list_1[user_1][library_1][
|
||||
show_key_1
|
||||
]
|
||||
|
||||
for user_1 in watched_list_1:
|
||||
for library_1 in watched_list_1[user_1]:
|
||||
if library_1 in modified_watched_list_1[user_1]:
|
||||
# If library is empty then remove it
|
||||
if len(modified_watched_list_1[user_1][library_1]) == 0:
|
||||
logger(f"Removing {library_1} from {user_1} because it is empty", 1)
|
||||
del modified_watched_list_1[user_1][library_1]
|
||||
|
||||
if user_1 in modified_watched_list_1:
|
||||
# If user is empty delete user
|
||||
if len(modified_watched_list_1[user_1]) == 0:
|
||||
logger(f"Removing {user_1} from watched list 1 because it is empty", 1)
|
||||
del modified_watched_list_1[user_1]
|
||||
|
||||
return modified_watched_list_1
|
||||
# Add a sink for file logging and the console.
|
||||
logger.add(log_file, level=debug_level, mode="w")
|
||||
logger.add(sys.stdout, level=debug_level)
|
||||
|
||||
|
||||
def setup_black_white_lists(
|
||||
blacklist_library: str,
|
||||
whitelist_library: str,
|
||||
blacklist_library_type: str,
|
||||
whitelist_library_type: str,
|
||||
blacklist_users: str,
|
||||
whitelist_users: str,
|
||||
library_mapping=None,
|
||||
user_mapping=None,
|
||||
):
|
||||
if blacklist_library:
|
||||
if len(blacklist_library) > 0:
|
||||
blacklist_library = blacklist_library.split(",")
|
||||
blacklist_library = [x.strip() for x in blacklist_library]
|
||||
if library_mapping:
|
||||
temp_library = []
|
||||
for library in blacklist_library:
|
||||
library_other = search_mapping(library_mapping, library)
|
||||
if library_other:
|
||||
temp_library.append(library_other)
|
||||
|
||||
blacklist_library = blacklist_library + temp_library
|
||||
else:
|
||||
blacklist_library = []
|
||||
logger(f"Blacklist Library: {blacklist_library}", 1)
|
||||
|
||||
if whitelist_library:
|
||||
if len(whitelist_library) > 0:
|
||||
whitelist_library = whitelist_library.split(",")
|
||||
whitelist_library = [x.strip() for x in whitelist_library]
|
||||
if library_mapping:
|
||||
temp_library = []
|
||||
for library in whitelist_library:
|
||||
library_other = search_mapping(library_mapping, library)
|
||||
if library_other:
|
||||
temp_library.append(library_other)
|
||||
|
||||
whitelist_library = whitelist_library + temp_library
|
||||
else:
|
||||
whitelist_library = []
|
||||
logger(f"Whitelist Library: {whitelist_library}", 1)
|
||||
|
||||
if blacklist_library_type:
|
||||
if len(blacklist_library_type) > 0:
|
||||
blacklist_library_type = blacklist_library_type.split(",")
|
||||
blacklist_library_type = [x.lower().strip() for x in blacklist_library_type]
|
||||
else:
|
||||
blacklist_library_type = []
|
||||
logger(f"Blacklist Library Type: {blacklist_library_type}", 1)
|
||||
|
||||
if whitelist_library_type:
|
||||
if len(whitelist_library_type) > 0:
|
||||
whitelist_library_type = whitelist_library_type.split(",")
|
||||
whitelist_library_type = [x.lower().strip() for x in whitelist_library_type]
|
||||
else:
|
||||
whitelist_library_type = []
|
||||
logger(f"Whitelist Library Type: {whitelist_library_type}", 1)
|
||||
|
||||
if blacklist_users:
|
||||
if len(blacklist_users) > 0:
|
||||
blacklist_users = blacklist_users.split(",")
|
||||
blacklist_users = [x.lower().strip() for x in blacklist_users]
|
||||
if user_mapping:
|
||||
temp_users = []
|
||||
for user in blacklist_users:
|
||||
user_other = search_mapping(user_mapping, user)
|
||||
if user_other:
|
||||
temp_users.append(user_other)
|
||||
|
||||
blacklist_users = blacklist_users + temp_users
|
||||
else:
|
||||
blacklist_users = []
|
||||
logger(f"Blacklist Users: {blacklist_users}", 1)
|
||||
|
||||
if whitelist_users:
|
||||
if len(whitelist_users) > 0:
|
||||
whitelist_users = whitelist_users.split(",")
|
||||
whitelist_users = [x.lower().strip() for x in whitelist_users]
|
||||
if user_mapping:
|
||||
temp_users = []
|
||||
for user in whitelist_users:
|
||||
user_other = search_mapping(user_mapping, user)
|
||||
if user_other:
|
||||
temp_users.append(user_other)
|
||||
|
||||
whitelist_users = whitelist_users + temp_users
|
||||
else:
|
||||
whitelist_users = []
|
||||
else:
|
||||
whitelist_users = []
|
||||
logger(f"Whitelist Users: {whitelist_users}", 1)
|
||||
|
||||
return (
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
blacklist_users,
|
||||
whitelist_users,
|
||||
def should_sync_server(
|
||||
env,
|
||||
server_1: Plex | Jellyfin | Emby,
|
||||
server_2: Plex | Jellyfin | Emby,
|
||||
) -> bool:
|
||||
sync_from_plex_to_jellyfin = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_PLEX_TO_JELLYFIN", "True")
|
||||
)
|
||||
sync_from_plex_to_plex = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_PLEX_TO_PLEX", "True")
|
||||
)
|
||||
sync_from_plex_to_emby = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_PLEX_TO_EMBY", "True")
|
||||
)
|
||||
|
||||
sync_from_jelly_to_plex = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_JELLYFIN_TO_PLEX", "True")
|
||||
)
|
||||
sync_from_jelly_to_jellyfin = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True")
|
||||
)
|
||||
sync_from_jelly_to_emby = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_JELLYFIN_TO_EMBY", "True")
|
||||
)
|
||||
|
||||
def setup_users(
|
||||
server_1, server_2, blacklist_users, whitelist_users, user_mapping=None
|
||||
):
|
||||
sync_from_emby_to_plex = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_EMBY_TO_PLEX", "True")
|
||||
)
|
||||
sync_from_emby_to_jellyfin = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_EMBY_TO_JELLYFIN", "True")
|
||||
)
|
||||
sync_from_emby_to_emby = str_to_bool(
|
||||
get_env_value(env, "SYNC_FROM_EMBY_TO_EMBY", "True")
|
||||
)
|
||||
|
||||
# generate list of users from server 1 and server 2
|
||||
server_1_type = server_1[0]
|
||||
server_1_connection = server_1[1]
|
||||
server_2_type = server_2[0]
|
||||
server_2_connection = server_2[1]
|
||||
print(f"Server 1: {server_1_type} {server_1_connection}")
|
||||
print(f"Server 2: {server_2_type} {server_2_connection}")
|
||||
if isinstance(server_1, Plex):
|
||||
if isinstance(server_2, Jellyfin) and not sync_from_plex_to_jellyfin:
|
||||
logger.info("Sync from plex -> jellyfin is disabled")
|
||||
return False
|
||||
|
||||
server_1_users = []
|
||||
if server_1_type == "plex":
|
||||
server_1_users = [x.title.lower() for x in server_1_connection.users]
|
||||
elif server_1_type == "jellyfin":
|
||||
server_1_users = [key.lower() for key in server_1_connection.users.keys()]
|
||||
if isinstance(server_2, Emby) and not sync_from_plex_to_emby:
|
||||
logger.info("Sync from plex -> emby is disabled")
|
||||
return False
|
||||
|
||||
server_2_users = []
|
||||
if server_2_type == "plex":
|
||||
server_2_users = [x.title.lower() for x in server_2_connection.users]
|
||||
elif server_2_type == "jellyfin":
|
||||
server_2_users = [key.lower() for key in server_2_connection.users.keys()]
|
||||
if isinstance(server_2, Plex) and not sync_from_plex_to_plex:
|
||||
logger.info("Sync from plex -> plex is disabled")
|
||||
return False
|
||||
|
||||
# combined list of overlapping users from plex and jellyfin
|
||||
users = {}
|
||||
if isinstance(server_1, Jellyfin):
|
||||
if isinstance(server_2, Plex) and not sync_from_jelly_to_plex:
|
||||
logger.info("Sync from jellyfin -> plex is disabled")
|
||||
return False
|
||||
|
||||
for server_1_user in server_1_users:
|
||||
if user_mapping:
|
||||
jellyfin_plex_mapped_user = search_mapping(user_mapping, server_1_user)
|
||||
if jellyfin_plex_mapped_user:
|
||||
users[server_1_user] = jellyfin_plex_mapped_user
|
||||
continue
|
||||
if isinstance(server_2, Jellyfin) and not sync_from_jelly_to_jellyfin:
|
||||
logger.info("Sync from jellyfin -> jellyfin is disabled")
|
||||
return False
|
||||
|
||||
if server_1_user in server_2_users:
|
||||
users[server_1_user] = server_1_user
|
||||
if isinstance(server_2, Emby) and not sync_from_jelly_to_emby:
|
||||
logger.info("Sync from jellyfin -> emby is disabled")
|
||||
return False
|
||||
|
||||
for server_2_user in server_2_users:
|
||||
if user_mapping:
|
||||
plex_jellyfin_mapped_user = search_mapping(user_mapping, server_2_user)
|
||||
if plex_jellyfin_mapped_user:
|
||||
users[plex_jellyfin_mapped_user] = server_2_user
|
||||
continue
|
||||
if isinstance(server_1, Emby):
|
||||
if isinstance(server_2, Plex) and not sync_from_emby_to_plex:
|
||||
logger.info("Sync from emby -> plex is disabled")
|
||||
return False
|
||||
|
||||
if server_2_user in server_1_users:
|
||||
users[server_2_user] = server_2_user
|
||||
if isinstance(server_2, Jellyfin) and not sync_from_emby_to_jellyfin:
|
||||
logger.info("Sync from emby -> jellyfin is disabled")
|
||||
return False
|
||||
|
||||
logger(f"User list that exist on both servers {users}", 1)
|
||||
if isinstance(server_2, Emby) and not sync_from_emby_to_emby:
|
||||
logger.info("Sync from emby -> emby is disabled")
|
||||
return False
|
||||
|
||||
users_filtered = {}
|
||||
for user in users:
|
||||
# whitelist_user is not empty and user lowercase is not in whitelist lowercase
|
||||
if len(whitelist_users) > 0:
|
||||
if user not in whitelist_users and users[user] not in whitelist_users:
|
||||
logger(f"{user} or {users[user]} is not in whitelist", 1)
|
||||
continue
|
||||
|
||||
if user not in blacklist_users and users[user] not in blacklist_users:
|
||||
users_filtered[user] = users[user]
|
||||
|
||||
logger(f"Filtered user list {users_filtered}", 1)
|
||||
|
||||
if server_1_type == "plex":
|
||||
output_server_1_users = []
|
||||
for plex_user in server_1_connection.users:
|
||||
if (
|
||||
plex_user.title.lower() in users_filtered.keys()
|
||||
or plex_user.title.lower() in users_filtered.values()
|
||||
):
|
||||
output_server_1_users.append(plex_user)
|
||||
elif server_1_type == "jellyfin":
|
||||
output_server_1_users = {}
|
||||
for jellyfin_user, jellyfin_id in server_1_connection.users.items():
|
||||
if (
|
||||
jellyfin_user.lower() in users_filtered.keys()
|
||||
or jellyfin_user.lower() in users_filtered.values()
|
||||
):
|
||||
output_server_1_users[jellyfin_user] = jellyfin_id
|
||||
|
||||
if server_2_type == "plex":
|
||||
output_server_2_users = []
|
||||
for plex_user in server_2_connection.users:
|
||||
if (
|
||||
plex_user.title.lower() in users_filtered.keys()
|
||||
or plex_user.title.lower() in users_filtered.values()
|
||||
):
|
||||
output_server_2_users.append(plex_user)
|
||||
elif server_2_type == "jellyfin":
|
||||
output_server_2_users = {}
|
||||
for jellyfin_user, jellyfin_id in server_2_connection.users.items():
|
||||
if (
|
||||
jellyfin_user.lower() in users_filtered.keys()
|
||||
or jellyfin_user.lower() in users_filtered.values()
|
||||
):
|
||||
output_server_2_users[jellyfin_user] = jellyfin_id
|
||||
|
||||
if len(output_server_1_users) == 0:
|
||||
raise Exception(
|
||||
f"No users found for server 1, users found {users} filtered users {users_filtered}"
|
||||
)
|
||||
|
||||
if len(output_server_2_users) == 0:
|
||||
raise Exception(
|
||||
f"No users found for server 2, users found {users} filtered users {users_filtered}"
|
||||
)
|
||||
|
||||
logger(f"Server 1 users: {output_server_1_users}", 1)
|
||||
logger(f"Server 2 users: {output_server_2_users}", 1)
|
||||
|
||||
return output_server_1_users, output_server_2_users
|
||||
return True
|
||||
|
||||
|
||||
def generate_server_connections():
|
||||
servers = []
|
||||
def main_loop(env) -> None:
|
||||
dryrun = str_to_bool(get_env_value(env, "DRYRUN", "False"))
|
||||
logger.info(f"Dryrun: {dryrun}")
|
||||
|
||||
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"))
|
||||
user_mapping_env = get_env_value(env, "USER_MAPPING", None)
|
||||
user_mapping = None
|
||||
if user_mapping_env:
|
||||
user_mapping = json.loads(user_mapping_env.lower())
|
||||
logger.info(f"User Mapping: {user_mapping}")
|
||||
|
||||
if plex_baseurl and plex_token:
|
||||
plex_baseurl = plex_baseurl.split(",")
|
||||
plex_token = plex_token.split(",")
|
||||
|
||||
if len(plex_baseurl) != len(plex_token):
|
||||
raise Exception(
|
||||
"PLEX_BASEURL and PLEX_TOKEN must have the same number of entries"
|
||||
)
|
||||
|
||||
for i, url in enumerate(plex_baseurl):
|
||||
servers.append(
|
||||
(
|
||||
"plex",
|
||||
Plex(
|
||||
baseurl=url.strip(),
|
||||
token=plex_token[i].strip(),
|
||||
username=None,
|
||||
password=None,
|
||||
servername=None,
|
||||
ssl_bypass=ssl_bypass,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if plex_username and plex_password and plex_servername:
|
||||
plex_username = plex_username.split(",")
|
||||
plex_password = plex_password.split(",")
|
||||
plex_servername = plex_servername.split(",")
|
||||
|
||||
if len(plex_username) != len(plex_password) or len(plex_username) != len(
|
||||
plex_servername
|
||||
):
|
||||
raise Exception(
|
||||
"PLEX_USERNAME, PLEX_PASSWORD and PLEX_SERVERNAME must have the same number of entries"
|
||||
)
|
||||
|
||||
for i, username in enumerate(plex_username):
|
||||
servers.append(
|
||||
(
|
||||
"plex",
|
||||
Plex(
|
||||
baseurl=None,
|
||||
token=None,
|
||||
username=username.strip(),
|
||||
password=plex_password[i].strip(),
|
||||
servername=plex_servername[i].strip(),
|
||||
ssl_bypass=ssl_bypass,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None)
|
||||
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
|
||||
|
||||
if jellyfin_baseurl and jellyfin_token:
|
||||
jellyfin_baseurl = jellyfin_baseurl.split(",")
|
||||
jellyfin_token = jellyfin_token.split(",")
|
||||
|
||||
if len(jellyfin_baseurl) != len(jellyfin_token):
|
||||
raise Exception(
|
||||
"JELLYFIN_BASEURL and JELLYFIN_TOKEN must have the same number of entries"
|
||||
)
|
||||
|
||||
for i, baseurl in enumerate(jellyfin_baseurl):
|
||||
servers.append(
|
||||
(
|
||||
"jellyfin",
|
||||
Jellyfin(baseurl=baseurl.strip(), token=jellyfin_token[i].strip()),
|
||||
)
|
||||
)
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def main_loop():
|
||||
logfile = os.getenv("LOGFILE", "log.log")
|
||||
# Delete logfile if it exists
|
||||
if os.path.exists(logfile):
|
||||
os.remove(logfile)
|
||||
|
||||
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
|
||||
logger(f"Dryrun: {dryrun}", 1)
|
||||
|
||||
user_mapping = os.getenv("USER_MAPPING")
|
||||
if user_mapping:
|
||||
user_mapping = json.loads(user_mapping.lower())
|
||||
logger(f"User Mapping: {user_mapping}", 1)
|
||||
|
||||
library_mapping = os.getenv("LIBRARY_MAPPING")
|
||||
if library_mapping:
|
||||
library_mapping = json.loads(library_mapping)
|
||||
logger(f"Library Mapping: {library_mapping}", 1)
|
||||
library_mapping_env = get_env_value(env, "LIBRARY_MAPPING", None)
|
||||
library_mapping = None
|
||||
if library_mapping_env:
|
||||
library_mapping = json.loads(library_mapping_env)
|
||||
logger.info(f"Library Mapping: {library_mapping}")
|
||||
|
||||
# Create (black/white)lists
|
||||
logger("Creating (black/white)lists", 1)
|
||||
blacklist_library = os.getenv("BLACKLIST_LIBRARY", None)
|
||||
whitelist_library = os.getenv("WHITELIST_LIBRARY", None)
|
||||
blacklist_library_type = os.getenv("BLACKLIST_LIBRARY_TYPE", None)
|
||||
whitelist_library_type = os.getenv("WHITELIST_LIBRARY_TYPE", None)
|
||||
blacklist_users = os.getenv("BLACKLIST_USERS", None)
|
||||
whitelist_users = os.getenv("WHITELIST_USERS", None)
|
||||
logger.info("Creating (black/white)lists")
|
||||
blacklist_library = parse_string_to_list(
|
||||
get_env_value(env, "BLACKLIST_LIBRARY", None)
|
||||
)
|
||||
whitelist_library = parse_string_to_list(
|
||||
get_env_value(env, "WHITELIST_LIBRARY", None)
|
||||
)
|
||||
blacklist_library_type = parse_string_to_list(
|
||||
get_env_value(env, "BLACKLIST_LIBRARY_TYPE", None)
|
||||
)
|
||||
whitelist_library_type = parse_string_to_list(
|
||||
get_env_value(env, "WHITELIST_LIBRARY_TYPE", None)
|
||||
)
|
||||
blacklist_users = parse_string_to_list(get_env_value(env, "BLACKLIST_USERS", None))
|
||||
whitelist_users = parse_string_to_list(get_env_value(env, "WHITELIST_USERS", None))
|
||||
|
||||
(
|
||||
blacklist_library,
|
||||
@@ -557,106 +171,153 @@ def main_loop():
|
||||
)
|
||||
|
||||
# Create server connections
|
||||
logger("Creating server connections", 1)
|
||||
servers = generate_server_connections()
|
||||
logger.info("Creating server connections")
|
||||
servers = generate_server_connections(env)
|
||||
|
||||
for server_1 in servers:
|
||||
# If server is the final server in the list, then we are done with the loop
|
||||
if server_1 == servers[-1]:
|
||||
break
|
||||
|
||||
# Store a copy of server_1_watched that way it can be used multiple times without having to regather everyones watch history every single time
|
||||
server_1_watched = None
|
||||
|
||||
# Start server_2 at the next server in the list
|
||||
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(
|
||||
env, server_1, server_2
|
||||
) and not should_sync_server(env, server_2, server_1):
|
||||
continue
|
||||
|
||||
server_1_connection = server_1[1]
|
||||
server_2_connection = server_2[1]
|
||||
logger.info(f"Server 1: {type(server_1)}: {server_1.info()}")
|
||||
logger.info(f"Server 2: {type(server_2)}: {server_2.info()}")
|
||||
|
||||
# Create users list
|
||||
logger("Creating users list", 1)
|
||||
logger.info("Creating users list")
|
||||
server_1_users, server_2_users = setup_users(
|
||||
server_1, server_2, blacklist_users, whitelist_users, user_mapping
|
||||
)
|
||||
|
||||
logger("Creating watched lists", 1)
|
||||
server_1_watched = server_1_connection.get_watched(
|
||||
server_1_users,
|
||||
server_1_libraries, server_2_libraries = setup_libraries(
|
||||
server_1,
|
||||
server_2,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
)
|
||||
logger("Finished creating watched list server 1", 1)
|
||||
server_2_watched = asyncio.run(
|
||||
server_2_connection.get_watched(
|
||||
server_2_users,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
)
|
||||
)
|
||||
logger("Finished creating watched list server 2", 1)
|
||||
logger(f"Server 1 watched: {server_1_watched}", 3)
|
||||
logger(f"Server 2 watched: {server_2_watched}", 3)
|
||||
logger.info(f"Server 1 syncing libraries: {server_1_libraries}")
|
||||
logger.info(f"Server 2 syncing libraries: {server_2_libraries}")
|
||||
|
||||
logger("Cleaning Server 1 Watched", 1)
|
||||
logger.info("Creating watched lists", 1)
|
||||
server_1_watched = server_1.get_watched(
|
||||
server_1_users, server_1_libraries, server_1_watched
|
||||
)
|
||||
logger.info("Finished creating watched list server 1")
|
||||
|
||||
server_2_watched = server_2.get_watched(server_2_users, server_2_libraries)
|
||||
logger.info("Finished creating watched list server 2")
|
||||
|
||||
logger.trace(f"Server 1 watched: {server_1_watched}")
|
||||
logger.trace(f"Server 2 watched: {server_2_watched}")
|
||||
|
||||
logger.info("Cleaning Server 1 Watched", 1)
|
||||
server_1_watched_filtered = cleanup_watched(
|
||||
server_1_watched, server_2_watched, user_mapping, library_mapping
|
||||
)
|
||||
|
||||
logger("Cleaning Server 2 Watched", 1)
|
||||
logger.info("Cleaning Server 2 Watched", 1)
|
||||
server_2_watched_filtered = cleanup_watched(
|
||||
server_2_watched, server_1_watched, user_mapping, library_mapping
|
||||
)
|
||||
|
||||
logger(
|
||||
logger.debug(
|
||||
f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}",
|
||||
1,
|
||||
)
|
||||
logger(
|
||||
logger.debug(
|
||||
f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}",
|
||||
1,
|
||||
)
|
||||
|
||||
server_1_connection.update_watched(
|
||||
server_2_watched_filtered, user_mapping, library_mapping, dryrun
|
||||
)
|
||||
asyncio.run(
|
||||
server_2_connection.update_watched(
|
||||
server_1_watched_filtered, user_mapping, library_mapping, dryrun
|
||||
if should_sync_server(env, server_2, server_1):
|
||||
logger.info(f"Syncing {server_2.info()} -> {server_1.info()}")
|
||||
|
||||
# Add server_2_watched_filtered to server_1_watched that way the stored version isn't stale for the next server
|
||||
if not dryrun:
|
||||
server_1_watched = merge_server_watched(
|
||||
server_1_watched,
|
||||
server_2_watched_filtered,
|
||||
user_mapping,
|
||||
library_mapping,
|
||||
)
|
||||
|
||||
server_1.update_watched(
|
||||
server_2_watched_filtered,
|
||||
user_mapping,
|
||||
library_mapping,
|
||||
dryrun,
|
||||
)
|
||||
|
||||
if should_sync_server(env, server_1, server_2):
|
||||
logger.info(f"Syncing {server_1.info()} -> {server_2.info()}")
|
||||
server_2.update_watched(
|
||||
server_1_watched_filtered,
|
||||
user_mapping,
|
||||
library_mapping,
|
||||
dryrun,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
|
||||
times = []
|
||||
@logger.catch
|
||||
def main() -> None:
|
||||
# Get environment variables
|
||||
env_file = get_env_value(None, "ENV_FILE", ".env")
|
||||
env = dotenv_values(env_file)
|
||||
|
||||
run_only_once = str_to_bool(get_env_value(env, "RUN_ONLY_ONCE", "False"))
|
||||
sleep_duration = float(get_env_value(env, "SLEEP_DURATION", "3600"))
|
||||
log_file = get_env_value(env, "LOG_FILE", "log.log")
|
||||
debug_level = get_env_value(env, "DEBUG_LEVEL", "INFO")
|
||||
if debug_level:
|
||||
debug_level = debug_level.upper()
|
||||
|
||||
times: list[float] = []
|
||||
while True:
|
||||
try:
|
||||
start = perf_counter()
|
||||
main_loop()
|
||||
# Reconfigure the logger on each loop so the logs are rotated on each run
|
||||
configure_logger(log_file, debug_level)
|
||||
main_loop(env)
|
||||
end = perf_counter()
|
||||
times.append(end - start)
|
||||
|
||||
if len(times) > 0:
|
||||
logger(f"Average time: {sum(times) / len(times)}", 0)
|
||||
logger.info(f"Average time: {sum(times) / len(times)}")
|
||||
|
||||
logger(f"Looping in {sleep_duration}")
|
||||
if run_only_once:
|
||||
break
|
||||
|
||||
logger.info(f"Looping in {sleep_duration}")
|
||||
sleep(sleep_duration)
|
||||
|
||||
except Exception as error:
|
||||
if isinstance(error, list):
|
||||
for message in error:
|
||||
logger(message, log_type=2)
|
||||
logger.error(message)
|
||||
else:
|
||||
logger(error, log_type=2)
|
||||
logger.error(error)
|
||||
|
||||
logger(traceback.format_exc(), 2)
|
||||
logger(f"Retrying in {sleep_duration}", log_type=0)
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
if run_only_once:
|
||||
break
|
||||
|
||||
logger.info(f"Retrying in {sleep_duration}")
|
||||
sleep(sleep_duration)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger("Exiting", log_type=0)
|
||||
if len(times) > 0:
|
||||
logger.info(f"Average time: {sum(times) / len(times)}")
|
||||
logger.info("Exiting")
|
||||
os._exit(0)
|
||||
|
||||
874
src/plex.py
874
src/plex.py
@@ -1,430 +1,598 @@
|
||||
import re, requests
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
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.myplex import MyPlexAccount
|
||||
from plexapi.myplex import MyPlexAccount, MyPlexUser
|
||||
from plexapi.library import MovieSection, ShowSection
|
||||
|
||||
from src.functions import (
|
||||
logger,
|
||||
search_mapping,
|
||||
check_skip_logic,
|
||||
generate_library_guids_dict,
|
||||
future_thread_executor,
|
||||
log_marked,
|
||||
str_to_bool,
|
||||
get_env_value,
|
||||
)
|
||||
from src.watched import (
|
||||
LibraryData,
|
||||
MediaIdentifiers,
|
||||
MediaItem,
|
||||
WatchedStatus,
|
||||
Series,
|
||||
UserData,
|
||||
check_same_identifiers,
|
||||
)
|
||||
|
||||
|
||||
def get_user_watched(user, user_plex, library):
|
||||
try:
|
||||
user_name = user.title.lower()
|
||||
user_watched = {}
|
||||
user_watched[user_name] = {}
|
||||
|
||||
logger(
|
||||
f"Plex: Generating watched for {user_name} in library {library.title}",
|
||||
0,
|
||||
# Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
|
||||
class HostNameIgnoringAdapter(RequestsHTTPAdapter):
|
||||
def init_poolmanager(
|
||||
self, connections: int, maxsize: int | None, block=..., **pool_kwargs
|
||||
) -> None:
|
||||
self.poolmanager = PoolManager(
|
||||
num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
assert_hostname=False,
|
||||
**pool_kwargs,
|
||||
)
|
||||
|
||||
if library.type == "movie":
|
||||
user_watched[user_name][library.title] = []
|
||||
|
||||
library_videos = user_plex.library.section(library.title)
|
||||
for video in library_videos.search(unwatched=False):
|
||||
movie_guids = {}
|
||||
for guid in video.guids:
|
||||
guid_source = re.search(r"(.*)://", guid.id).group(1).lower()
|
||||
guid_id = re.search(r"://(.*)", guid.id).group(1)
|
||||
movie_guids[guid_source] = guid_id
|
||||
def extract_guids_from_item(
|
||||
item: Movie | Show | Episode, generate_guids: bool
|
||||
) -> dict[str, str]:
|
||||
# If GENERATE_GUIDS is set to False, then return an empty dict
|
||||
if not generate_guids:
|
||||
return {}
|
||||
|
||||
movie_guids["title"] = video.title
|
||||
movie_guids["locations"] = tuple(
|
||||
[x.split("/")[-1] for x in video.locations]
|
||||
)
|
||||
guids: dict[str, str] = dict(
|
||||
guid.id.split("://")
|
||||
for guid in item.guids
|
||||
if guid.id and len(guid.id.strip()) > 0
|
||||
)
|
||||
|
||||
user_watched[user_name][library.title].append(movie_guids)
|
||||
|
||||
elif library.type == "show":
|
||||
user_watched[user_name][library.title] = {}
|
||||
|
||||
library_videos = user_plex.library.section(library.title)
|
||||
for show in library_videos.search(unwatched=False):
|
||||
show_guids = {}
|
||||
for show_guid in show.guids:
|
||||
# Extract after :// from guid.id
|
||||
show_guid_source = (
|
||||
re.search(r"(.*)://", show_guid.id).group(1).lower()
|
||||
)
|
||||
show_guid_id = re.search(r"://(.*)", show_guid.id).group(1)
|
||||
show_guids[show_guid_source] = show_guid_id
|
||||
|
||||
show_guids["title"] = show.title
|
||||
show_guids["locations"] = tuple(
|
||||
[x.split("/")[-1] for x in show.locations]
|
||||
)
|
||||
show_guids = frozenset(show_guids.items())
|
||||
|
||||
for season in show.seasons():
|
||||
episode_guids = []
|
||||
for episode in season.episodes():
|
||||
if episode.viewCount > 0:
|
||||
episode_guids_temp = {}
|
||||
for guid in episode.guids:
|
||||
# Extract after :// from guid.id
|
||||
guid_source = (
|
||||
re.search(r"(.*)://", guid.id).group(1).lower()
|
||||
)
|
||||
guid_id = re.search(r"://(.*)", guid.id).group(1)
|
||||
episode_guids_temp[guid_source] = guid_id
|
||||
|
||||
episode_guids_temp["locations"] = tuple(
|
||||
[x.split("/")[-1] for x in episode.locations]
|
||||
)
|
||||
episode_guids.append(episode_guids_temp)
|
||||
|
||||
if episode_guids:
|
||||
# append show, season, episode
|
||||
if show_guids not in user_watched[user_name][library.title]:
|
||||
user_watched[user_name][library.title][show_guids] = {}
|
||||
if (
|
||||
season.title
|
||||
not in user_watched[user_name][library.title][show_guids]
|
||||
):
|
||||
user_watched[user_name][library.title][show_guids][
|
||||
season.title
|
||||
] = {}
|
||||
user_watched[user_name][library.title][show_guids][
|
||||
season.title
|
||||
] = episode_guids
|
||||
|
||||
return user_watched
|
||||
except Exception as e:
|
||||
logger(
|
||||
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
|
||||
2,
|
||||
)
|
||||
raise Exception(e)
|
||||
return guids
|
||||
|
||||
|
||||
def update_user_watched(user, user_plex, library, videos, dryrun):
|
||||
try:
|
||||
logger(f"Plex: Updating watched for {user.title} in library {library}", 1)
|
||||
(
|
||||
videos_shows_ids,
|
||||
videos_episodes_ids,
|
||||
videos_movies_ids,
|
||||
) = generate_library_guids_dict(videos)
|
||||
logger(
|
||||
f"Plex: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
|
||||
1,
|
||||
)
|
||||
def extract_identifiers_from_item(
|
||||
item: Movie | Show | Episode,
|
||||
generate_guids: bool,
|
||||
generate_locations: bool,
|
||||
) -> MediaIdentifiers:
|
||||
guids = extract_guids_from_item(item, generate_guids)
|
||||
return MediaIdentifiers(
|
||||
title=item.title,
|
||||
locations=(
|
||||
tuple([location.split("/")[-1] for location in item.locations])
|
||||
if generate_locations
|
||||
else tuple()
|
||||
),
|
||||
imdb_id=guids.get("imdb"),
|
||||
tvdb_id=guids.get("tvdb"),
|
||||
tmdb_id=guids.get("tmdb"),
|
||||
)
|
||||
|
||||
library_videos = user_plex.library.section(library)
|
||||
if videos_movies_ids:
|
||||
for movies_search in library_videos.search(unwatched=True):
|
||||
movie_found = False
|
||||
for movie_location in movies_search.locations:
|
||||
if movie_location.split("/")[-1] in videos_movies_ids["locations"]:
|
||||
movie_found = True
|
||||
break
|
||||
|
||||
if not movie_found:
|
||||
for movie_guid in movies_search.guids:
|
||||
movie_guid_source = (
|
||||
re.search(r"(.*)://", movie_guid.id).group(1).lower()
|
||||
)
|
||||
movie_guid_id = re.search(r"://(.*)", movie_guid.id).group(1)
|
||||
|
||||
# If movie provider source and movie provider id are in videos_movie_ids exactly, then the movie is in the list
|
||||
if movie_guid_source in videos_movies_ids.keys():
|
||||
if movie_guid_id in videos_movies_ids[movie_guid_source]:
|
||||
movie_found = True
|
||||
break
|
||||
|
||||
if movie_found:
|
||||
msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex"
|
||||
if not dryrun:
|
||||
logger(f"Marked {msg}", 0)
|
||||
movies_search.markWatched()
|
||||
else:
|
||||
logger(f"Dryrun {msg}", 0)
|
||||
else:
|
||||
logger(
|
||||
f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}",
|
||||
1,
|
||||
)
|
||||
|
||||
if videos_shows_ids and videos_episodes_ids:
|
||||
for show_search in library_videos.search(unwatched=True):
|
||||
show_found = False
|
||||
for show_location in show_search.locations:
|
||||
if show_location.split("/")[-1] in videos_shows_ids["locations"]:
|
||||
show_found = True
|
||||
break
|
||||
|
||||
if not show_found:
|
||||
for show_guid in show_search.guids:
|
||||
show_guid_source = (
|
||||
re.search(r"(.*)://", show_guid.id).group(1).lower()
|
||||
)
|
||||
show_guid_id = re.search(r"://(.*)", show_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_guid_source in videos_shows_ids.keys():
|
||||
if show_guid_id in videos_shows_ids[show_guid_source]:
|
||||
show_found = True
|
||||
break
|
||||
|
||||
if show_found:
|
||||
for episode_search in show_search.episodes():
|
||||
episode_found = False
|
||||
|
||||
for episode_location in episode_search.locations:
|
||||
if (
|
||||
episode_location.split("/")[-1]
|
||||
in videos_episodes_ids["locations"]
|
||||
):
|
||||
episode_found = True
|
||||
break
|
||||
|
||||
if not episode_found:
|
||||
for episode_guid in episode_search.guids:
|
||||
episode_guid_source = (
|
||||
re.search(r"(.*)://", episode_guid.id)
|
||||
.group(1)
|
||||
.lower()
|
||||
)
|
||||
episode_guid_id = re.search(
|
||||
r"://(.*)", episode_guid.id
|
||||
).group(1)
|
||||
|
||||
# If episode provider source and episode provider id are in videos_episodes_ids exactly, then the episode is in the list
|
||||
if episode_guid_source in videos_episodes_ids.keys():
|
||||
if (
|
||||
episode_guid_id
|
||||
in videos_episodes_ids[episode_guid_source]
|
||||
):
|
||||
episode_found = True
|
||||
break
|
||||
|
||||
if episode_found:
|
||||
msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex"
|
||||
if not dryrun:
|
||||
logger(f"Marked {msg}", 0)
|
||||
episode_search.markWatched()
|
||||
else:
|
||||
logger(f"Dryrun {msg}", 0)
|
||||
else:
|
||||
logger(
|
||||
f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}",
|
||||
1,
|
||||
)
|
||||
else:
|
||||
logger(
|
||||
f"Plex: Skipping show {show_search.title} as it is not in mark list for {user.title}",
|
||||
1,
|
||||
)
|
||||
|
||||
if not videos_movies_ids and not videos_shows_ids and not videos_episodes_ids:
|
||||
logger(
|
||||
f"Jellyfin: No videos to mark as watched for {user.title} in library {library}",
|
||||
1,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger(
|
||||
f"Plex: Failed to update watched for {user.title} in library {library}, Error: {e}",
|
||||
2,
|
||||
)
|
||||
raise Exception(e)
|
||||
def get_mediaitem(
|
||||
item: Movie | Episode,
|
||||
completed: bool,
|
||||
generate_guids: bool = True,
|
||||
generate_locations: bool = True,
|
||||
) -> MediaItem:
|
||||
return MediaItem(
|
||||
identifiers=extract_identifiers_from_item(
|
||||
item, generate_guids, generate_locations
|
||||
),
|
||||
status=WatchedStatus(completed=completed, time=item.viewOffset),
|
||||
)
|
||||
|
||||
|
||||
# class plex accept base url and token and username and password but default with none
|
||||
class Plex:
|
||||
def __init__(
|
||||
self,
|
||||
baseurl=None,
|
||||
token=None,
|
||||
username=None,
|
||||
password=None,
|
||||
servername=None,
|
||||
ssl_bypass=False,
|
||||
):
|
||||
self.baseurl = baseurl
|
||||
self.token = token
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.servername = servername
|
||||
self.plex = self.login(ssl_bypass)
|
||||
self.admin_user = self.plex.myPlexAccount()
|
||||
self.users = self.get_users()
|
||||
env,
|
||||
base_url: str | None = None,
|
||||
token: str | None = None,
|
||||
user_name: str | None = None,
|
||||
password: str | None = None,
|
||||
server_name: str | None = None,
|
||||
ssl_bypass: bool = False,
|
||||
session: requests.Session | None = None,
|
||||
) -> None:
|
||||
self.env = env
|
||||
|
||||
def login(self, ssl_bypass=False):
|
||||
self.server_type: str = "Plex"
|
||||
self.ssl_bypass: bool = ssl_bypass
|
||||
if ssl_bypass:
|
||||
# Session for ssl bypass
|
||||
session = requests.Session()
|
||||
# By pass ssl hostname check https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
|
||||
session.mount("https://", HostNameIgnoringAdapter())
|
||||
self.session = session
|
||||
self.plex: PlexServer = self.login(
|
||||
base_url, token, user_name, password, server_name
|
||||
)
|
||||
|
||||
self.base_url: str = self.plex._baseurl
|
||||
|
||||
self.admin_user: MyPlexAccount = self.plex.myPlexAccount()
|
||||
self.users: list[MyPlexUser | MyPlexAccount] = self.get_users()
|
||||
self.generate_guids: bool = str_to_bool(
|
||||
get_env_value(self.env, "GENERATE_GUIDS", "True")
|
||||
)
|
||||
self.generate_locations: bool = str_to_bool(
|
||||
get_env_value(self.env, "GENERATE_LOCATIONS", "True")
|
||||
)
|
||||
|
||||
def login(
|
||||
self,
|
||||
base_url: str | None,
|
||||
token: str | None,
|
||||
user_name: str | None,
|
||||
password: str | None,
|
||||
server_name: str | None,
|
||||
) -> PlexServer:
|
||||
try:
|
||||
if self.baseurl and self.token:
|
||||
# Login via token
|
||||
if ssl_bypass:
|
||||
session = requests.Session()
|
||||
session.verify = False
|
||||
plex = PlexServer(self.baseurl, self.token, session=session)
|
||||
else:
|
||||
plex = PlexServer(self.baseurl, self.token)
|
||||
elif self.username and self.password and self.servername:
|
||||
if base_url and token:
|
||||
plex: PlexServer = PlexServer(base_url, token, session=self.session)
|
||||
elif user_name and password and server_name:
|
||||
# Login via plex account
|
||||
account = MyPlexAccount(self.username, self.password)
|
||||
plex = account.resource(self.servername).connect()
|
||||
account = MyPlexAccount(user_name, password)
|
||||
plex = account.resource(server_name).connect()
|
||||
else:
|
||||
raise Exception("No complete plex credentials provided")
|
||||
|
||||
return plex
|
||||
except Exception as e:
|
||||
if self.username or self.password:
|
||||
msg = f"Failed to login via plex account {self.username}"
|
||||
logger(f"Plex: Failed to login, {msg}, Error: {e}", 2)
|
||||
if user_name:
|
||||
msg = f"Failed to login via plex account {user_name}"
|
||||
logger.error(f"Plex: Failed to login, {msg}, Error: {e}")
|
||||
else:
|
||||
logger(f"Plex: Failed to login, Error: {e}", 2)
|
||||
logger.error(f"Plex: Failed to login, Error: {e}")
|
||||
raise Exception(e)
|
||||
|
||||
def get_users(self):
|
||||
def info(self) -> str:
|
||||
return f"Plex {self.plex.friendlyName}: {self.plex.version}"
|
||||
|
||||
def get_users(self) -> list[MyPlexUser | MyPlexAccount]:
|
||||
try:
|
||||
users = self.plex.myPlexAccount().users()
|
||||
users: list[MyPlexUser | MyPlexAccount] = self.plex.myPlexAccount().users()
|
||||
|
||||
# append self to users
|
||||
users.append(self.plex.myPlexAccount())
|
||||
|
||||
return users
|
||||
except Exception as e:
|
||||
logger(f"Plex: Failed to get users, Error: {e}", 2)
|
||||
logger.error(f"Plex: Failed to get users, Error: {e}")
|
||||
raise Exception(e)
|
||||
|
||||
def get_libraries(self) -> dict[str, str]:
|
||||
try:
|
||||
output = {}
|
||||
|
||||
libraries = self.plex.library.sections()
|
||||
logger.debug(
|
||||
f"Plex: All Libraries {[library.title for library in libraries]}"
|
||||
)
|
||||
|
||||
for library in libraries:
|
||||
library_title = library.title
|
||||
library_type = library.type
|
||||
|
||||
if library_type not in ["movie", "show"]:
|
||||
logger.debug(
|
||||
f"Plex: Skipping Library {library_title} found type {library_type}",
|
||||
)
|
||||
continue
|
||||
|
||||
output[library_title] = library_type
|
||||
|
||||
return output
|
||||
except Exception as e:
|
||||
logger.error(f"Plex: Failed to get libraries, Error: {e}")
|
||||
raise Exception(e)
|
||||
|
||||
def get_user_library_watched(
|
||||
self, user_name: str, user_plex: PlexServer, library: MovieSection | ShowSection
|
||||
) -> LibraryData:
|
||||
try:
|
||||
logger.info(
|
||||
f"Plex: Generating watched for {user_name} in library {library.title}",
|
||||
)
|
||||
watched = LibraryData(title=library.title)
|
||||
|
||||
library_videos = user_plex.library.section(library.title)
|
||||
|
||||
if library.type == "movie":
|
||||
for video in library_videos.search(
|
||||
unwatched=False
|
||||
) + library_videos.search(inProgress=True):
|
||||
if video.isWatched or video.viewOffset >= 60000:
|
||||
watched.movies.append(
|
||||
get_mediaitem(
|
||||
video,
|
||||
video.isWatched,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
)
|
||||
|
||||
elif library.type == "show":
|
||||
# Keep track of processed shows to reduce duplicate shows
|
||||
processed_shows = []
|
||||
for show in library_videos.search(
|
||||
unwatched=False
|
||||
) + library_videos.search(inProgress=True):
|
||||
if show.key in processed_shows:
|
||||
continue
|
||||
processed_shows.append(show.key)
|
||||
show_guids = extract_guids_from_item(show, self.generate_guids)
|
||||
episode_mediaitem = []
|
||||
|
||||
# Fetch watched or partially watched episodes
|
||||
for episode in show.watched() + show.episodes(
|
||||
viewOffset__gte=60_000
|
||||
):
|
||||
episode_mediaitem.append(
|
||||
get_mediaitem(
|
||||
episode,
|
||||
episode.isWatched,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
)
|
||||
|
||||
if episode_mediaitem:
|
||||
watched.series.append(
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title=show.title,
|
||||
locations=(
|
||||
tuple(
|
||||
[
|
||||
location.split("/")[-1]
|
||||
for location in show.locations
|
||||
]
|
||||
)
|
||||
if self.generate_locations
|
||||
else tuple()
|
||||
),
|
||||
imdb_id=show_guids.get("imdb"),
|
||||
tvdb_id=show_guids.get("tvdb"),
|
||||
tmdb_id=show_guids.get("tmdb"),
|
||||
),
|
||||
episodes=episode_mediaitem,
|
||||
)
|
||||
)
|
||||
|
||||
return watched
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
|
||||
)
|
||||
return LibraryData(title=library.title)
|
||||
|
||||
def get_watched(
|
||||
self,
|
||||
users,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
):
|
||||
users: list[MyPlexUser | MyPlexAccount],
|
||||
sync_libraries: list[str],
|
||||
users_watched: dict[str, UserData] = None,
|
||||
) -> dict[str, UserData]:
|
||||
try:
|
||||
# Get all libraries
|
||||
users_watched = {}
|
||||
args = []
|
||||
if not users_watched:
|
||||
users_watched: dict[str, UserData] = {}
|
||||
|
||||
for user in users:
|
||||
if self.admin_user == user:
|
||||
user_plex = self.plex
|
||||
else:
|
||||
user_plex = PlexServer(
|
||||
self.plex._baseurl, user.get_token(self.plex.machineIdentifier)
|
||||
)
|
||||
token = user.get_token(self.plex.machineIdentifier)
|
||||
if token:
|
||||
user_plex = self.login(self.base_url, token, None, None, None)
|
||||
else:
|
||||
logger.error(
|
||||
f"Plex: Failed to get token for {user.title}, skipping",
|
||||
)
|
||||
continue
|
||||
|
||||
user_name: str = (
|
||||
user.username.lower() if user.username else user.title.lower()
|
||||
)
|
||||
|
||||
libraries = user_plex.library.sections()
|
||||
|
||||
for library in libraries:
|
||||
library_title = library.title
|
||||
library_type = library.type
|
||||
if library.title not in sync_libraries:
|
||||
continue
|
||||
|
||||
skip_reason = check_skip_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
)
|
||||
if user_name not in users_watched:
|
||||
users_watched[user_name] = UserData()
|
||||
|
||||
if skip_reason:
|
||||
logger(
|
||||
f"Plex: Skipping library {library_title} {skip_reason}", 1
|
||||
if library.title in users_watched[user_name].libraries:
|
||||
logger.info(
|
||||
f"Plex: {user_name} {library.title} watched history has already been gathered, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
args.append([get_user_watched, user, user_plex, library])
|
||||
library_data = self.get_user_library_watched(
|
||||
user_name, user_plex, library
|
||||
)
|
||||
|
||||
for user_watched in future_thread_executor(args):
|
||||
for user, user_watched_temp in user_watched.items():
|
||||
if user not in users_watched:
|
||||
users_watched[user] = {}
|
||||
users_watched[user].update(user_watched_temp)
|
||||
users_watched[user_name].libraries[library.title] = library_data
|
||||
|
||||
return users_watched
|
||||
except Exception as e:
|
||||
logger(f"Plex: Failed to get watched, Error: {e}", 2)
|
||||
raise Exception(e)
|
||||
logger.error(f"Plex: Failed to get users watched, Error: {e}")
|
||||
return {}
|
||||
|
||||
def update_user_watched(
|
||||
self,
|
||||
user: MyPlexAccount,
|
||||
user_plex: PlexServer,
|
||||
library_data: LibraryData,
|
||||
library_name: str,
|
||||
dryrun: bool,
|
||||
) -> None:
|
||||
# If there are no movies or shows to update, exit early.
|
||||
if not library_data.series and not library_data.movies:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Plex: Updating watched for {user.title} in library {library_name}"
|
||||
)
|
||||
library_section = user_plex.library.section(library_name)
|
||||
if not library_section:
|
||||
logger.error(
|
||||
f"Plex: Library {library_name} not found for {user.title}, skipping",
|
||||
)
|
||||
return
|
||||
|
||||
# Update movies.
|
||||
if library_data.movies:
|
||||
# Search for Plex movies that are currently marked as unwatched.
|
||||
for plex_movie in library_section.search(unwatched=True):
|
||||
plex_identifiers = extract_identifiers_from_item(
|
||||
plex_movie, self.generate_guids, self.generate_locations
|
||||
)
|
||||
# Check each stored movie for a match.
|
||||
for stored_movie in library_data.movies:
|
||||
if check_same_identifiers(
|
||||
plex_identifiers, stored_movie.identifiers
|
||||
):
|
||||
# If the stored movie is marked as watched (or has enough progress),
|
||||
# update the Plex movie accordingly.
|
||||
if stored_movie.status.completed:
|
||||
msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}"
|
||||
if not dryrun:
|
||||
try:
|
||||
plex_movie.markWatched()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Plex: Failed to mark {plex_movie.title} as watched, Error: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
|
||||
log_marked(
|
||||
"Plex",
|
||||
user_plex.friendlyName,
|
||||
user.title,
|
||||
library_name,
|
||||
plex_movie.title,
|
||||
None,
|
||||
None,
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
else:
|
||||
msg = f"Plex: {plex_movie.title} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user.title} in {library_name}"
|
||||
if not dryrun:
|
||||
try:
|
||||
plex_movie.updateTimeline(stored_movie.status.time)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Plex: Failed to update {plex_movie.title} timeline, Error: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
|
||||
log_marked(
|
||||
"Plex",
|
||||
user_plex.friendlyName,
|
||||
user.title,
|
||||
library_name,
|
||||
plex_movie.title,
|
||||
duration=stored_movie.status.time,
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
# Once matched, no need to check further.
|
||||
break
|
||||
|
||||
# Update TV Shows (series/episodes).
|
||||
if library_data.series:
|
||||
# For each Plex show in the library section:
|
||||
plex_shows = library_section.search(unwatched=True)
|
||||
for plex_show in plex_shows:
|
||||
# Extract identifiers from the Plex show.
|
||||
plex_show_identifiers = extract_identifiers_from_item(
|
||||
plex_show, self.generate_guids, self.generate_locations
|
||||
)
|
||||
# Try to find a matching series in your stored library.
|
||||
for stored_series in library_data.series:
|
||||
if check_same_identifiers(
|
||||
plex_show_identifiers, stored_series.identifiers
|
||||
):
|
||||
logger.trace(f"Found matching show for '{plex_show.title}'")
|
||||
# Now update episodes.
|
||||
# Get the list of Plex episodes for this show.
|
||||
plex_episodes = plex_show.episodes()
|
||||
for plex_episode in plex_episodes:
|
||||
plex_episode_identifiers = extract_identifiers_from_item(
|
||||
plex_episode,
|
||||
self.generate_guids,
|
||||
self.generate_locations,
|
||||
)
|
||||
for stored_ep in stored_series.episodes:
|
||||
if check_same_identifiers(
|
||||
plex_episode_identifiers, stored_ep.identifiers
|
||||
):
|
||||
if stored_ep.status.completed:
|
||||
msg = f"Plex: {plex_show.title} {plex_episode.title} as watched for {user.title} in {library_name}"
|
||||
if not dryrun:
|
||||
try:
|
||||
plex_episode.markWatched()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Plex: Failed to mark {plex_show.title} {plex_episode.title} as watched, Error: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.success(
|
||||
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
|
||||
)
|
||||
log_marked(
|
||||
"Plex",
|
||||
user_plex.friendlyName,
|
||||
user.title,
|
||||
library_name,
|
||||
plex_show.title,
|
||||
plex_episode.title,
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
else:
|
||||
msg = f"Plex: {plex_show.title} {plex_episode.title} as partially watched for {floor(stored_ep.status.time / 60_000)} minutes for {user.title} in {library_name}"
|
||||
if not dryrun:
|
||||
try:
|
||||
plex_episode.updateTimeline(
|
||||
stored_ep.status.time
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Plex: Failed to update {plex_show.title} {plex_episode.title} timeline, Error: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.success(
|
||||
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
|
||||
)
|
||||
log_marked(
|
||||
"Plex",
|
||||
user_plex.friendlyName,
|
||||
user.title,
|
||||
library_name,
|
||||
plex_show.title,
|
||||
plex_episode.title,
|
||||
stored_ep.status.time,
|
||||
mark_file=get_env_value(
|
||||
self.env, "MARK_FILE", "mark.log"
|
||||
),
|
||||
)
|
||||
break # Found a matching episode.
|
||||
break # Found a matching show.
|
||||
|
||||
def update_watched(
|
||||
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
|
||||
):
|
||||
try:
|
||||
args = []
|
||||
self,
|
||||
watched_list: dict[str, UserData],
|
||||
user_mapping: dict[str, str] | None = None,
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
dryrun: bool = False,
|
||||
) -> None:
|
||||
for user, user_data in watched_list.items():
|
||||
user_other = None
|
||||
# If type of user is dict
|
||||
if user_mapping:
|
||||
user_other = search_mapping(user_mapping, user)
|
||||
|
||||
for user, libraries in watched_list.items():
|
||||
user_other = None
|
||||
# If type of user is dict
|
||||
if user_mapping:
|
||||
if user in user_mapping.keys():
|
||||
user_other = user_mapping[user]
|
||||
elif user in user_mapping.values():
|
||||
user_other = search_mapping(user_mapping, user)
|
||||
for index, value in enumerate(self.users):
|
||||
username_title = (
|
||||
value.username.lower() if value.username else value.title.lower()
|
||||
)
|
||||
|
||||
for index, value in enumerate(self.users):
|
||||
if user.lower() == value.title.lower():
|
||||
user = self.users[index]
|
||||
break
|
||||
elif user_other and user_other.lower() == value.title.lower():
|
||||
user = self.users[index]
|
||||
break
|
||||
if user.lower() == username_title:
|
||||
user = self.users[index]
|
||||
break
|
||||
elif user_other and user_other.lower() == username_title:
|
||||
user = self.users[index]
|
||||
break
|
||||
|
||||
if self.admin_user == user:
|
||||
user_plex = self.plex
|
||||
else:
|
||||
user_plex = PlexServer(
|
||||
self.plex._baseurl, user.get_token(self.plex.machineIdentifier)
|
||||
if self.admin_user == user:
|
||||
user_plex = self.plex
|
||||
else:
|
||||
if isinstance(user, str):
|
||||
logger.debug(
|
||||
f"Plex: {user} is not a plex object, attempting to get object for user",
|
||||
)
|
||||
user = self.plex.myPlexAccount().user(user)
|
||||
|
||||
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 not isinstance(user, MyPlexUser):
|
||||
logger.error(f"Plex: {user} failed to get PlexUser")
|
||||
continue
|
||||
|
||||
# if library in plex library list
|
||||
library_list = user_plex.library.sections()
|
||||
if library.lower() not in [x.title.lower() for x in library_list]:
|
||||
if library_other:
|
||||
if library_other.lower() in [
|
||||
x.title.lower() for x in library_list
|
||||
]:
|
||||
logger(
|
||||
f"Plex: Library {library} not found, but {library_other} found, using {library_other}",
|
||||
1,
|
||||
)
|
||||
library = library_other
|
||||
else:
|
||||
logger(
|
||||
f"Plex: Library {library} or {library_other} not found in library list",
|
||||
2,
|
||||
)
|
||||
continue
|
||||
token = user.get_token(self.plex.machineIdentifier)
|
||||
if token:
|
||||
user_plex = PlexServer(
|
||||
self.base_url,
|
||||
token,
|
||||
session=self.session,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Plex: Failed to get token for {user.title}, skipping",
|
||||
)
|
||||
continue
|
||||
|
||||
if not user_plex:
|
||||
logger.error(f"Plex: {user} Failed to get PlexServer")
|
||||
continue
|
||||
|
||||
for library_name in user_data.libraries:
|
||||
library_data = user_data.libraries[library_name]
|
||||
library_other = None
|
||||
if library_mapping:
|
||||
library_other = search_mapping(library_mapping, library_name)
|
||||
# if library in plex library list
|
||||
library_list = user_plex.library.sections()
|
||||
if library_name.lower() not in [x.title.lower() for x in library_list]:
|
||||
if library_other:
|
||||
if library_other.lower() in [
|
||||
x.title.lower() for x in library_list
|
||||
]:
|
||||
logger.info(
|
||||
f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}",
|
||||
)
|
||||
library_name = library_other
|
||||
else:
|
||||
logger(
|
||||
f"Plex: Library {library} not found in library list", 2
|
||||
logger.info(
|
||||
f"Plex: Library {library_name} or {library_other} not found in library list",
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.info(
|
||||
f"Plex: Library {library_name} not found in library list",
|
||||
)
|
||||
continue
|
||||
|
||||
args.append(
|
||||
[
|
||||
update_user_watched,
|
||||
user,
|
||||
user_plex,
|
||||
library,
|
||||
videos,
|
||||
dryrun,
|
||||
]
|
||||
try:
|
||||
self.update_user_watched(
|
||||
user,
|
||||
user_plex,
|
||||
library_data,
|
||||
library_name,
|
||||
dryrun,
|
||||
)
|
||||
|
||||
future_thread_executor(args)
|
||||
except Exception as e:
|
||||
logger(f"Plex: Failed to update watched, Error: {e}", 2)
|
||||
raise Exception(e)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Plex: Failed to update watched for {user.title} in {library_name}, Error: {e}",
|
||||
)
|
||||
continue
|
||||
|
||||
152
src/users.py
Normal file
152
src/users.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from plexapi.myplex import MyPlexAccount, MyPlexUser
|
||||
from loguru import logger
|
||||
|
||||
from src.emby import Emby
|
||||
from src.jellyfin import Jellyfin
|
||||
from src.plex import Plex
|
||||
from src.functions import search_mapping
|
||||
|
||||
|
||||
def generate_user_list(server: Plex | Jellyfin | Emby) -> list[str]:
|
||||
# generate list of users from server 1 and server 2
|
||||
|
||||
server_users: list[str] = []
|
||||
if isinstance(server, Plex):
|
||||
for user in server.users:
|
||||
server_users.append(
|
||||
user.username.lower() if user.username else user.title.lower()
|
||||
)
|
||||
|
||||
elif isinstance(server, (Jellyfin, Emby)):
|
||||
server_users = [key.lower() for key in server.users.keys()]
|
||||
|
||||
return server_users
|
||||
|
||||
|
||||
def combine_user_lists(
|
||||
server_1_users: list[str],
|
||||
server_2_users: list[str],
|
||||
user_mapping: dict[str, str] | None,
|
||||
) -> dict[str, str]:
|
||||
# combined list of overlapping users from plex and jellyfin
|
||||
users: dict[str, str] = {}
|
||||
|
||||
for server_1_user in server_1_users:
|
||||
if user_mapping:
|
||||
mapped_user = search_mapping(user_mapping, server_1_user)
|
||||
if mapped_user in server_2_users:
|
||||
users[server_1_user] = mapped_user
|
||||
continue
|
||||
|
||||
if server_1_user in server_2_users:
|
||||
users[server_1_user] = server_1_user
|
||||
|
||||
for server_2_user in server_2_users:
|
||||
if user_mapping:
|
||||
mapped_user = search_mapping(user_mapping, server_2_user)
|
||||
if mapped_user in server_1_users:
|
||||
users[mapped_user] = server_2_user
|
||||
continue
|
||||
|
||||
if server_2_user in server_1_users:
|
||||
users[server_2_user] = server_2_user
|
||||
|
||||
return users
|
||||
|
||||
|
||||
def filter_user_lists(
|
||||
users: dict[str, str], blacklist_users: list[str], whitelist_users: list[str]
|
||||
) -> dict[str, str]:
|
||||
users_filtered: dict[str, str] = {}
|
||||
for user in users:
|
||||
# whitelist_user is not empty and user lowercase is not in whitelist lowercase
|
||||
if len(whitelist_users) > 0:
|
||||
if user not in whitelist_users and users[user] not in whitelist_users:
|
||||
logger.info(f"{user} or {users[user]} is not in whitelist")
|
||||
continue
|
||||
|
||||
if user not in blacklist_users and users[user] not in blacklist_users:
|
||||
users_filtered[user] = users[user]
|
||||
|
||||
return users_filtered
|
||||
|
||||
|
||||
def generate_server_users(
|
||||
server: Plex | Jellyfin | Emby,
|
||||
users: dict[str, str],
|
||||
) -> list[MyPlexAccount] | dict[str, str] | None:
|
||||
if isinstance(server, Plex):
|
||||
plex_server_users: list[MyPlexAccount] = []
|
||||
for plex_user in server.users:
|
||||
username_title = (
|
||||
plex_user.username if plex_user.username else plex_user.title
|
||||
)
|
||||
|
||||
if (
|
||||
username_title.lower() in users.keys()
|
||||
or username_title.lower() in users.values()
|
||||
):
|
||||
plex_server_users.append(plex_user)
|
||||
|
||||
return plex_server_users
|
||||
elif isinstance(server, (Jellyfin, Emby)):
|
||||
jelly_emby_server_users: dict[str, str] = {}
|
||||
for jellyfin_user, jellyfin_id in server.users.items():
|
||||
if (
|
||||
jellyfin_user.lower() in users.keys()
|
||||
or jellyfin_user.lower() in users.values()
|
||||
):
|
||||
jelly_emby_server_users[jellyfin_user] = jellyfin_id
|
||||
|
||||
return jelly_emby_server_users
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def setup_users(
|
||||
server_1: Plex | Jellyfin | Emby,
|
||||
server_2: Plex | Jellyfin | Emby,
|
||||
blacklist_users: list[str],
|
||||
whitelist_users: list[str],
|
||||
user_mapping: dict[str, str] | None = None,
|
||||
) -> tuple[
|
||||
list[MyPlexAccount | MyPlexUser] | dict[str, str],
|
||||
list[MyPlexAccount | MyPlexUser] | dict[str, str],
|
||||
]:
|
||||
server_1_users = generate_user_list(server_1)
|
||||
server_2_users = generate_user_list(server_2)
|
||||
logger.debug(f"Server 1 users: {server_1_users}")
|
||||
logger.debug(f"Server 2 users: {server_2_users}")
|
||||
|
||||
users = combine_user_lists(server_1_users, server_2_users, user_mapping)
|
||||
logger.debug(f"User list that exist on both servers {users}")
|
||||
|
||||
users_filtered = filter_user_lists(users, blacklist_users, whitelist_users)
|
||||
logger.debug(f"Filtered user list {users_filtered}")
|
||||
|
||||
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.warning(
|
||||
f"No users found for server 1 {type(server_1)}, users: {server_1_users}, overlapping users {users}, filtered users {users_filtered}, server 1 users {server_1.users}"
|
||||
)
|
||||
|
||||
if output_server_2_users is None or len(output_server_2_users) == 0:
|
||||
logger.warning(
|
||||
f"No users found for server 2 {type(server_2)}, users: {server_2_users}, overlapping users {users} filtered users {users_filtered}, server 2 users {server_2.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.info(f"Server 1 users: {output_server_1_users}")
|
||||
logger.info(f"Server 2 users: {output_server_2_users}")
|
||||
|
||||
return output_server_1_users, output_server_2_users
|
||||
321
src/watched.py
Normal file
321
src/watched.py
Normal file
@@ -0,0 +1,321 @@
|
||||
import copy
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
from typing import Any
|
||||
|
||||
from src.functions import search_mapping
|
||||
|
||||
|
||||
class MediaIdentifiers(BaseModel):
|
||||
title: str | None = None
|
||||
|
||||
# File information, will be folder for series and media file for episode/movie
|
||||
locations: tuple[str, ...] = tuple()
|
||||
|
||||
# Guids
|
||||
imdb_id: str | None = None
|
||||
tvdb_id: str | None = None
|
||||
tmdb_id: str | None = None
|
||||
|
||||
|
||||
class WatchedStatus(BaseModel):
|
||||
completed: bool
|
||||
time: int
|
||||
|
||||
|
||||
class MediaItem(BaseModel):
|
||||
identifiers: MediaIdentifiers
|
||||
status: WatchedStatus
|
||||
|
||||
|
||||
class Series(BaseModel):
|
||||
identifiers: MediaIdentifiers
|
||||
episodes: list[MediaItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LibraryData(BaseModel):
|
||||
title: str
|
||||
movies: list[MediaItem] = Field(default_factory=list)
|
||||
series: list[Series] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserData(BaseModel):
|
||||
libraries: dict[str, LibraryData] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def merge_mediaitem_data(ep1: MediaItem, ep2: MediaItem) -> MediaItem:
|
||||
"""
|
||||
Merge two MediaItem episodes by comparing their watched status.
|
||||
If one is completed while the other isn't, choose the completed one.
|
||||
If both are completed or both are not, choose the one with the higher time.
|
||||
"""
|
||||
if ep1.status.completed != ep2.status.completed:
|
||||
return ep1 if ep1.status.completed else ep2
|
||||
return ep1 if ep1.status.time >= ep2.status.time else ep2
|
||||
|
||||
|
||||
def merge_series_data(series1: Series, series2: Series) -> Series:
|
||||
"""
|
||||
Merge two Series objects by combining their episodes.
|
||||
For duplicate episodes (determined by check_same_identifiers), merge their watched status.
|
||||
"""
|
||||
merged_series = copy.deepcopy(series1)
|
||||
for ep in series2.episodes:
|
||||
for idx, merged_ep in enumerate(merged_series.episodes):
|
||||
if check_same_identifiers(ep.identifiers, merged_ep.identifiers):
|
||||
merged_series.episodes[idx] = merge_mediaitem_data(merged_ep, ep)
|
||||
break
|
||||
else:
|
||||
merged_series.episodes.append(copy.deepcopy(ep))
|
||||
return merged_series
|
||||
|
||||
|
||||
def merge_library_data(lib1: LibraryData, lib2: LibraryData) -> LibraryData:
|
||||
"""
|
||||
Merge two LibraryData objects by extending movies and merging series.
|
||||
For series, duplicates are determined using check_same_identifiers.
|
||||
"""
|
||||
merged = copy.deepcopy(lib1)
|
||||
|
||||
# Merge movies.
|
||||
for movie in lib2.movies:
|
||||
for idx, merged_movie in enumerate(merged.movies):
|
||||
if check_same_identifiers(movie.identifiers, merged_movie.identifiers):
|
||||
merged.movies[idx] = merge_mediaitem_data(merged_movie, movie)
|
||||
break
|
||||
else:
|
||||
merged.movies.append(copy.deepcopy(movie))
|
||||
|
||||
# Merge series.
|
||||
for series2 in lib2.series:
|
||||
for idx, series1 in enumerate(merged.series):
|
||||
if check_same_identifiers(series1.identifiers, series2.identifiers):
|
||||
merged.series[idx] = merge_series_data(series1, series2)
|
||||
break
|
||||
else:
|
||||
merged.series.append(copy.deepcopy(series2))
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def merge_user_data(user1: UserData, user2: UserData) -> UserData:
|
||||
"""
|
||||
Merge two UserData objects by merging their libraries.
|
||||
If a library exists in both, merge its content; otherwise, add the new library.
|
||||
"""
|
||||
merged_libraries = copy.deepcopy(user1.libraries)
|
||||
for lib_key, lib_data in user2.libraries.items():
|
||||
if lib_key in merged_libraries:
|
||||
merged_libraries[lib_key] = merge_library_data(
|
||||
merged_libraries[lib_key], lib_data
|
||||
)
|
||||
else:
|
||||
merged_libraries[lib_key] = copy.deepcopy(lib_data)
|
||||
return UserData(libraries=merged_libraries)
|
||||
|
||||
|
||||
def merge_server_watched(
|
||||
watched_list_1: dict[str, UserData],
|
||||
watched_list_2: dict[str, UserData],
|
||||
user_mapping: dict[str, str] | None = None,
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
) -> dict[str, UserData]:
|
||||
"""
|
||||
Merge two dictionaries of UserData while taking into account possible
|
||||
differences in user and library keys via the provided mappings.
|
||||
"""
|
||||
merged_watched = copy.deepcopy(watched_list_1)
|
||||
|
||||
for user_2, user_data in watched_list_2.items():
|
||||
# Determine matching user key.
|
||||
user_key = user_mapping.get(user_2, user_2) if user_mapping else user_2
|
||||
if user_key not in merged_watched:
|
||||
merged_watched[user_2] = copy.deepcopy(user_data)
|
||||
continue
|
||||
|
||||
for lib_key, lib_data in user_data.libraries.items():
|
||||
mapped_lib_key = (
|
||||
library_mapping.get(lib_key, lib_key) if library_mapping else lib_key
|
||||
)
|
||||
if mapped_lib_key not in merged_watched[user_key].libraries:
|
||||
merged_watched[user_key].libraries[lib_key] = copy.deepcopy(lib_data)
|
||||
else:
|
||||
merged_watched[user_key].libraries[mapped_lib_key] = merge_library_data(
|
||||
merged_watched[user_key].libraries[mapped_lib_key],
|
||||
lib_data,
|
||||
)
|
||||
|
||||
return merged_watched
|
||||
|
||||
|
||||
def check_same_identifiers(item1: MediaIdentifiers, item2: MediaIdentifiers) -> bool:
|
||||
# Check for duplicate based on file locations:
|
||||
if item1.locations and item2.locations:
|
||||
if set(item1.locations) & set(item2.locations):
|
||||
return True
|
||||
|
||||
# Check for duplicate based on GUIDs:
|
||||
if (
|
||||
(item1.imdb_id and item2.imdb_id and item1.imdb_id == item2.imdb_id)
|
||||
or (item1.tvdb_id and item2.tvdb_id and item1.tvdb_id == item2.tvdb_id)
|
||||
or (item1.tmdb_id and item2.tmdb_id and item1.tmdb_id == item2.tmdb_id)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_remove_entry(item1: MediaItem, item2: MediaItem) -> bool:
|
||||
"""
|
||||
Returns True if item1 (from watched_list_1) should be removed
|
||||
in favor of item2 (from watched_list_2), based on:
|
||||
- Duplicate criteria:
|
||||
* They match if any file location is shared OR
|
||||
at least one of imdb_id, tvdb_id, or tmdb_id matches.
|
||||
- Watched status:
|
||||
* If one is complete and the other is not, remove the incomplete one.
|
||||
* If both are incomplete, remove the one with lower progress (time).
|
||||
* If both are complete, remove item1 as duplicate.
|
||||
"""
|
||||
if not check_same_identifiers(item1.identifiers, item2.identifiers):
|
||||
return False
|
||||
|
||||
# Compare watched statuses.
|
||||
status1 = item1.status
|
||||
status2 = item2.status
|
||||
|
||||
# If one is complete and the other isn't, remove the one that's not complete.
|
||||
if status1.completed != status2.completed:
|
||||
if not status1.completed and status2.completed:
|
||||
return True # Remove item1 since it's not complete.
|
||||
else:
|
||||
return False # Do not remove item1; it's complete.
|
||||
|
||||
# Both have the same completed status.
|
||||
if not status1.completed and not status2.completed:
|
||||
# Both incomplete: remove the one with lower progress (time)
|
||||
if status1.time < status2.time:
|
||||
return True # Remove item1 because it has watched less.
|
||||
elif status1.time > status2.time:
|
||||
return False # Keep item1 because it has more progress.
|
||||
else:
|
||||
# Same progress; Remove duplicate
|
||||
return True
|
||||
|
||||
# If both are complete, consider item1 the duplicate and remove it.
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_watched(
|
||||
watched_list_1: dict[str, UserData],
|
||||
watched_list_2: dict[str, UserData],
|
||||
user_mapping: dict[str, str] | None = None,
|
||||
library_mapping: dict[str, str] | None = None,
|
||||
) -> dict[str, UserData]:
|
||||
modified_watched_list_1 = copy.deepcopy(watched_list_1)
|
||||
|
||||
# remove entries from watched_list_1 that are in watched_list_2
|
||||
for user_1 in watched_list_1:
|
||||
user_other = None
|
||||
if user_mapping:
|
||||
user_other = search_mapping(user_mapping, user_1)
|
||||
user_2 = get_other(watched_list_2, user_1, user_other)
|
||||
if user_2 is None:
|
||||
continue
|
||||
|
||||
for library_1_key in watched_list_1[user_1].libraries:
|
||||
library_other = None
|
||||
if library_mapping:
|
||||
library_other = search_mapping(library_mapping, library_1_key)
|
||||
library_2_key = get_other(
|
||||
watched_list_2[user_2].libraries, library_1_key, library_other
|
||||
)
|
||||
if library_2_key is None:
|
||||
continue
|
||||
|
||||
library_1 = watched_list_1[user_1].libraries[library_1_key]
|
||||
library_2 = watched_list_2[user_2].libraries[library_2_key]
|
||||
|
||||
filtered_movies = []
|
||||
for movie in library_1.movies:
|
||||
remove_flag = False
|
||||
for movie2 in library_2.movies:
|
||||
if check_remove_entry(movie, movie2):
|
||||
logger.trace(f"Removing movie: {movie.identifiers.title}")
|
||||
remove_flag = True
|
||||
break
|
||||
|
||||
if not remove_flag:
|
||||
filtered_movies.append(movie)
|
||||
|
||||
modified_watched_list_1[user_1].libraries[
|
||||
library_1_key
|
||||
].movies = filtered_movies
|
||||
|
||||
# TV Shows
|
||||
filtered_series_list = []
|
||||
for series1 in library_1.series:
|
||||
matching_series = None
|
||||
for series2 in library_2.series:
|
||||
if check_same_identifiers(series1.identifiers, series2.identifiers):
|
||||
matching_series = series2
|
||||
break
|
||||
|
||||
if matching_series is None:
|
||||
# No matching show in watched_list_2; keep the series as is.
|
||||
filtered_series_list.append(series1)
|
||||
else:
|
||||
# We have a matching show; now clean up the episodes.
|
||||
filtered_episodes = []
|
||||
for ep1 in series1.episodes:
|
||||
remove_flag = False
|
||||
for ep2 in matching_series.episodes:
|
||||
if check_remove_entry(ep1, ep2):
|
||||
logger.trace(
|
||||
f"Removing episode '{ep1.identifiers.title}' from show '{series1.identifiers.title}'",
|
||||
)
|
||||
remove_flag = True
|
||||
break
|
||||
if not remove_flag:
|
||||
filtered_episodes.append(ep1)
|
||||
|
||||
# Only keep the series if there are remaining episodes.
|
||||
if filtered_episodes:
|
||||
modified_series1 = copy.deepcopy(series1)
|
||||
modified_series1.episodes = filtered_episodes
|
||||
filtered_series_list.append(modified_series1)
|
||||
else:
|
||||
logger.trace(
|
||||
f"Removing entire show '{series1.identifiers.title}' as no episodes remain after cleanup.",
|
||||
)
|
||||
modified_watched_list_1[user_1].libraries[
|
||||
library_1_key
|
||||
].series = filtered_series_list
|
||||
|
||||
# After processing, remove any library that is completely empty.
|
||||
for user, user_data in modified_watched_list_1.items():
|
||||
new_libraries = {}
|
||||
for lib_key, library in user_data.libraries.items():
|
||||
if library.movies or library.series:
|
||||
new_libraries[lib_key] = library
|
||||
else:
|
||||
logger.trace(f"Removing empty library '{lib_key}' for user '{user}'")
|
||||
user_data.libraries = new_libraries
|
||||
|
||||
return modified_watched_list_1
|
||||
|
||||
|
||||
def get_other(
|
||||
watched_list: dict[str, Any], object_1: str, object_2: str | None
|
||||
) -> str | None:
|
||||
if object_1 in watched_list:
|
||||
return object_1
|
||||
|
||||
if object_2 and object_2 in watched_list:
|
||||
return object_2
|
||||
|
||||
logger.info(
|
||||
f"{object_1}{' and ' + object_2 if object_2 else ''} not found in watched list 2"
|
||||
)
|
||||
|
||||
return None
|
||||
113
test/ci_emby.env
Normal file
113
test/ci_emby.env
Normal file
@@ -0,0 +1,113 @@
|
||||
# Global Settings
|
||||
|
||||
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||
DRYRUN = "True"
|
||||
|
||||
## Debugging level, "info" is default, "debug" is more verbose
|
||||
DEBUG_LEVEL = "trace"
|
||||
|
||||
## 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"
|
||||
113
test/ci_guids.env
Normal file
113
test/ci_guids.env
Normal file
@@ -0,0 +1,113 @@
|
||||
# Global Settings
|
||||
|
||||
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||
DRYRUN = "True"
|
||||
|
||||
## Debugging level, "info" is default, "debug" is more verbose
|
||||
DEBUG_LEVEL = "trace"
|
||||
|
||||
## 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"
|
||||
113
test/ci_jellyfin.env
Normal file
113
test/ci_jellyfin.env
Normal file
@@ -0,0 +1,113 @@
|
||||
# Global Settings
|
||||
|
||||
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||
DRYRUN = "True"
|
||||
|
||||
## Debugging level, "info" is default, "debug" is more verbose
|
||||
DEBUG_LEVEL = "trace"
|
||||
|
||||
## 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"
|
||||
113
test/ci_locations.env
Normal file
113
test/ci_locations.env
Normal file
@@ -0,0 +1,113 @@
|
||||
# Global Settings
|
||||
|
||||
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||
DRYRUN = "True"
|
||||
|
||||
## Debugging level, "info" is default, "debug" is more verbose
|
||||
DEBUG_LEVEL = "trace"
|
||||
|
||||
## 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"
|
||||
113
test/ci_plex.env
Normal file
113
test/ci_plex.env
Normal file
@@ -0,0 +1,113 @@
|
||||
# Global Settings
|
||||
|
||||
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||
DRYRUN = "True"
|
||||
|
||||
## Debugging level, "info" is default, "debug" is more verbose
|
||||
DEBUG_LEVEL = "trace"
|
||||
|
||||
## 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"
|
||||
113
test/ci_write.env
Normal file
113
test/ci_write.env
Normal file
@@ -0,0 +1,113 @@
|
||||
# Global Settings
|
||||
|
||||
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
|
||||
DRYRUN = "False"
|
||||
|
||||
## Debugging level, "info" is default, "debug" is more verbose
|
||||
DEBUG_LEVEL = "trace"
|
||||
|
||||
## 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
|
||||
|
||||
@@ -13,17 +13,17 @@ parent = os.path.dirname(current)
|
||||
# the sys.path.
|
||||
sys.path.append(parent)
|
||||
|
||||
from src.main import setup_black_white_lists
|
||||
from src.black_white import setup_black_white_lists
|
||||
|
||||
|
||||
def test_setup_black_white_lists():
|
||||
# Simple
|
||||
blacklist_library = "library1, library2"
|
||||
whitelist_library = "library1, library2"
|
||||
blacklist_library_type = "library_type1, library_type2"
|
||||
whitelist_library_type = "library_type1, library_type2"
|
||||
blacklist_users = "user1, user2"
|
||||
whitelist_users = "user1, user2"
|
||||
blacklist_library = ["library1", "library2"]
|
||||
whitelist_library = ["library1", "library2"]
|
||||
blacklist_library_type = ["library_type1", "library_type2"]
|
||||
whitelist_library_type = ["library_type1", "library_type2"]
|
||||
blacklist_users = ["user1", "user2"]
|
||||
whitelist_users = ["user1", "user2"]
|
||||
|
||||
(
|
||||
results_blacklist_library,
|
||||
@@ -48,6 +48,15 @@ def test_setup_black_white_lists():
|
||||
assert return_blacklist_users == ["user1", "user2"]
|
||||
assert return_whitelist_users == ["user1", "user2"]
|
||||
|
||||
|
||||
def test_library_mapping_black_white_list():
|
||||
blacklist_library = ["library1", "library2"]
|
||||
whitelist_library = ["library1", "library2"]
|
||||
blacklist_library_type = ["library_type1", "library_type2"]
|
||||
whitelist_library_type = ["library_type1", "library_type2"]
|
||||
blacklist_users = ["user1", "user2"]
|
||||
whitelist_users = ["user1", "user2"]
|
||||
|
||||
# Library Mapping and user mapping
|
||||
library_mapping = {"library1": "library3"}
|
||||
user_mapping = {"user1": "user3"}
|
||||
278
test/test_library.py
Normal file
278
test/test_library.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# getting the name of the directory
|
||||
# where the this file is present.
|
||||
current = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
# Getting the parent directory name
|
||||
# where the current directory is present.
|
||||
parent = os.path.dirname(current)
|
||||
|
||||
# adding the parent directory to
|
||||
# the sys.path.
|
||||
sys.path.append(parent)
|
||||
|
||||
from src.functions import (
|
||||
search_mapping,
|
||||
)
|
||||
|
||||
from src.library import (
|
||||
check_skip_logic,
|
||||
check_blacklist_logic,
|
||||
check_whitelist_logic,
|
||||
)
|
||||
|
||||
blacklist_library = ["TV Shows"]
|
||||
whitelist_library = ["Movies"]
|
||||
blacklist_library_type = ["episodes"]
|
||||
whitelist_library_type = ["movies"]
|
||||
library_mapping = {"Shows": "TV Shows", "Movie": "Movies"}
|
||||
|
||||
show_list = {
|
||||
frozenset(
|
||||
{
|
||||
("locations", ("The Last of Us",)),
|
||||
("tmdb", "100088"),
|
||||
("imdb", "tt3581920"),
|
||||
("tvdb", "392256"),
|
||||
("title", "The Last of Us"),
|
||||
}
|
||||
): [
|
||||
{
|
||||
"imdb": "tt11957006",
|
||||
"tmdb": "2181581",
|
||||
"tvdb": "8444132",
|
||||
"locations": (
|
||||
(
|
||||
"The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",
|
||||
)
|
||||
),
|
||||
"status": {"completed": True, "time": 0},
|
||||
}
|
||||
]
|
||||
}
|
||||
movie_list = [
|
||||
{
|
||||
"title": "Coco",
|
||||
"imdb": "tt2380307",
|
||||
"tmdb": "354912",
|
||||
"locations": [("Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv")],
|
||||
"status": {"completed": True, "time": 0},
|
||||
}
|
||||
]
|
||||
|
||||
show_titles = {
|
||||
"imdb": ["tt3581920"],
|
||||
"locations": [("The Last of Us",)],
|
||||
"tmdb": ["100088"],
|
||||
"tvdb": ["392256"],
|
||||
}
|
||||
episode_titles = {
|
||||
"imdb": ["tt11957006"],
|
||||
"locations": [
|
||||
("The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",)
|
||||
],
|
||||
"tmdb": ["2181581"],
|
||||
"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 = {
|
||||
"imdb": ["tt2380307"],
|
||||
"locations": [
|
||||
[
|
||||
(
|
||||
"Coco (2017) Remux-2160p.mkv",
|
||||
"Coco (2017) Remux-1080p.mkv",
|
||||
)
|
||||
]
|
||||
],
|
||||
"title": ["coco"],
|
||||
"tmdb": ["354912"],
|
||||
"completed": [True],
|
||||
"time": [0],
|
||||
}
|
||||
|
||||
|
||||
def test_check_skip_logic():
|
||||
# Failes
|
||||
library_title = "Test"
|
||||
library_type = "movies"
|
||||
skip_reason = check_skip_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
)
|
||||
|
||||
assert skip_reason == "Test is not in whitelist_library"
|
||||
|
||||
library_title = "Shows"
|
||||
library_type = "episodes"
|
||||
skip_reason = check_skip_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
)
|
||||
|
||||
assert (
|
||||
skip_reason
|
||||
== "episodes is in blacklist_library_type and TV Shows is in blacklist_library and "
|
||||
+ "episodes is not in whitelist_library_type and Shows is not in whitelist_library"
|
||||
)
|
||||
|
||||
# Passes
|
||||
library_title = "Movie"
|
||||
library_type = "movies"
|
||||
skip_reason = check_skip_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
whitelist_library,
|
||||
blacklist_library_type,
|
||||
whitelist_library_type,
|
||||
library_mapping,
|
||||
)
|
||||
|
||||
assert skip_reason is None
|
||||
|
||||
|
||||
def test_check_blacklist_logic():
|
||||
# Fails
|
||||
library_title = "Shows"
|
||||
library_type = "episodes"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_blacklist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
blacklist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert (
|
||||
skip_reason
|
||||
== "episodes is in blacklist_library_type and TV Shows is in blacklist_library"
|
||||
)
|
||||
|
||||
library_title = "TV Shows"
|
||||
library_type = "episodes"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_blacklist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
blacklist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert (
|
||||
skip_reason
|
||||
== "episodes is in blacklist_library_type and TV Shows is in blacklist_library"
|
||||
)
|
||||
|
||||
# Passes
|
||||
library_title = "Movie"
|
||||
library_type = "movies"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_blacklist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
blacklist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert skip_reason is None
|
||||
|
||||
library_title = "Movies"
|
||||
library_type = "movies"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_blacklist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
blacklist_library,
|
||||
blacklist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert skip_reason is None
|
||||
|
||||
|
||||
def test_check_whitelist_logic():
|
||||
# Fails
|
||||
library_title = "Shows"
|
||||
library_type = "episodes"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_whitelist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
whitelist_library,
|
||||
whitelist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert (
|
||||
skip_reason
|
||||
== "episodes is not in whitelist_library_type and Shows is not in whitelist_library"
|
||||
)
|
||||
|
||||
library_title = "TV Shows"
|
||||
library_type = "episodes"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_whitelist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
whitelist_library,
|
||||
whitelist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert (
|
||||
skip_reason
|
||||
== "episodes is not in whitelist_library_type and TV Shows is not in whitelist_library"
|
||||
)
|
||||
|
||||
# Passes
|
||||
library_title = "Movie"
|
||||
library_type = "movies"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_whitelist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
whitelist_library,
|
||||
whitelist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert skip_reason is None
|
||||
|
||||
library_title = "Movies"
|
||||
library_type = "movies"
|
||||
library_other = search_mapping(library_mapping, library_title)
|
||||
skip_reason = check_whitelist_logic(
|
||||
library_title,
|
||||
library_type,
|
||||
whitelist_library,
|
||||
whitelist_library_type,
|
||||
library_other,
|
||||
)
|
||||
|
||||
assert skip_reason is None
|
||||
@@ -1,301 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# getting the name of the directory
|
||||
# where the this file is present.
|
||||
current = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
# Getting the parent directory name
|
||||
# where the current directory is present.
|
||||
parent = os.path.dirname(current)
|
||||
|
||||
# adding the parent directory to
|
||||
# the sys.path.
|
||||
sys.path.append(parent)
|
||||
|
||||
from src.main import cleanup_watched
|
||||
|
||||
tv_shows_watched_list_1 = {
|
||||
frozenset(
|
||||
{
|
||||
("tvdb", "75710"),
|
||||
("title", "Criminal Minds"),
|
||||
("imdb", "tt0452046"),
|
||||
("locations", ("Criminal Minds",)),
|
||||
("tmdb", "4057"),
|
||||
}
|
||||
): {
|
||||
"Season 1": [
|
||||
{
|
||||
"imdb": "tt0550489",
|
||||
"tmdb": "282843",
|
||||
"tvdb": "176357",
|
||||
"locations": (
|
||||
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
|
||||
),
|
||||
},
|
||||
{
|
||||
"imdb": "tt0550487",
|
||||
"tmdb": "282861",
|
||||
"tvdb": "300385",
|
||||
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",),
|
||||
},
|
||||
]
|
||||
},
|
||||
frozenset({("title", "Test"), ("locations", ("Test",))}): {
|
||||
"Season 1": [
|
||||
{"locations": ("Test S01E01.mkv",)},
|
||||
{"locations": ("Test S01E02.mkv",)},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
movies_watched_list_1 = [
|
||||
{
|
||||
"imdb": "tt2380307",
|
||||
"tmdb": "354912",
|
||||
"title": "Coco",
|
||||
"locations": ("Coco (2017) Remux-1080p.mkv",),
|
||||
},
|
||||
{
|
||||
"tmdbcollection": "448150",
|
||||
"imdb": "tt1431045",
|
||||
"tmdb": "293660",
|
||||
"title": "Deadpool",
|
||||
"locations": ("Deadpool (2016) Remux-1080p.mkv",),
|
||||
},
|
||||
]
|
||||
|
||||
tv_shows_watched_list_2 = {
|
||||
frozenset(
|
||||
{
|
||||
("tvdb", "75710"),
|
||||
("title", "Criminal Minds"),
|
||||
("imdb", "tt0452046"),
|
||||
("locations", ("Criminal Minds",)),
|
||||
("tmdb", "4057"),
|
||||
}
|
||||
): {
|
||||
"Season 1": [
|
||||
{
|
||||
"imdb": "tt0550487",
|
||||
"tmdb": "282861",
|
||||
"tvdb": "300385",
|
||||
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",),
|
||||
},
|
||||
{
|
||||
"imdb": "tt0550498",
|
||||
"tmdb": "282865",
|
||||
"tvdb": "300474",
|
||||
"locations": (
|
||||
"Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",
|
||||
),
|
||||
},
|
||||
]
|
||||
},
|
||||
frozenset({("title", "Test"), ("locations", ("Test",))}): {
|
||||
"Season 1": [
|
||||
{"locations": ("Test S01E02.mkv",)},
|
||||
{"locations": ("Test S01E03.mkv",)},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
movies_watched_list_2 = [
|
||||
{
|
||||
"imdb": "tt2380307",
|
||||
"tmdb": "354912",
|
||||
"title": "Coco",
|
||||
"locations": ("Coco (2017) Remux-1080p.mkv",),
|
||||
},
|
||||
{
|
||||
"imdb": "tt0384793",
|
||||
"tmdb": "9788",
|
||||
"tvdb": "9103",
|
||||
"title": "Accepted",
|
||||
"locations": ("Accepted (2006) Remux-1080p.mkv",),
|
||||
},
|
||||
]
|
||||
|
||||
# Test to see if objects get deleted all the way up to the root.
|
||||
tv_shows_2_watched_list_1 = {
|
||||
frozenset(
|
||||
{
|
||||
("tvdb", "75710"),
|
||||
("title", "Criminal Minds"),
|
||||
("imdb", "tt0452046"),
|
||||
("locations", ("Criminal Minds",)),
|
||||
("tmdb", "4057"),
|
||||
}
|
||||
): {
|
||||
"Season 1": [
|
||||
{
|
||||
"imdb": "tt0550489",
|
||||
"tmdb": "282843",
|
||||
"tvdb": "176357",
|
||||
"locations": (
|
||||
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expected_tv_show_watched_list_1 = {
|
||||
frozenset(
|
||||
{
|
||||
("tvdb", "75710"),
|
||||
("title", "Criminal Minds"),
|
||||
("imdb", "tt0452046"),
|
||||
("locations", ("Criminal Minds",)),
|
||||
("tmdb", "4057"),
|
||||
}
|
||||
): {
|
||||
"Season 1": [
|
||||
{
|
||||
"imdb": "tt0550489",
|
||||
"tmdb": "282843",
|
||||
"tvdb": "176357",
|
||||
"locations": (
|
||||
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
frozenset({("title", "Test"), ("locations", ("Test",))}): {
|
||||
"Season 1": [{"locations": ("Test S01E01.mkv",)}]
|
||||
},
|
||||
}
|
||||
|
||||
expected_movie_watched_list_1 = [
|
||||
{
|
||||
"tmdbcollection": "448150",
|
||||
"imdb": "tt1431045",
|
||||
"tmdb": "293660",
|
||||
"title": "Deadpool",
|
||||
"locations": ("Deadpool (2016) Remux-1080p.mkv",),
|
||||
}
|
||||
]
|
||||
|
||||
expected_tv_show_watched_list_2 = {
|
||||
frozenset(
|
||||
{
|
||||
("tvdb", "75710"),
|
||||
("title", "Criminal Minds"),
|
||||
("imdb", "tt0452046"),
|
||||
("locations", ("Criminal Minds",)),
|
||||
("tmdb", "4057"),
|
||||
}
|
||||
): {
|
||||
"Season 1": [
|
||||
{
|
||||
"imdb": "tt0550498",
|
||||
"tmdb": "282865",
|
||||
"tvdb": "300474",
|
||||
"locations": (
|
||||
"Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv",
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
frozenset({("title", "Test"), ("locations", ("Test",))}): {
|
||||
"Season 1": [{"locations": ("Test S01E03.mkv",)}]
|
||||
},
|
||||
}
|
||||
|
||||
expected_movie_watched_list_2 = [
|
||||
{
|
||||
"imdb": "tt0384793",
|
||||
"tmdb": "9788",
|
||||
"tvdb": "9103",
|
||||
"title": "Accepted",
|
||||
"locations": ("Accepted (2006) Remux-1080p.mkv",),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_simple_cleanup_watched():
|
||||
user_watched_list_1 = {
|
||||
"user1": {
|
||||
"TV Shows": tv_shows_watched_list_1,
|
||||
"Movies": movies_watched_list_1,
|
||||
"Other Shows": tv_shows_2_watched_list_1,
|
||||
},
|
||||
}
|
||||
user_watched_list_2 = {
|
||||
"user1": {
|
||||
"TV Shows": tv_shows_watched_list_2,
|
||||
"Movies": movies_watched_list_2,
|
||||
"Other Shows": tv_shows_2_watched_list_1,
|
||||
}
|
||||
}
|
||||
|
||||
expected_watched_list_1 = {
|
||||
"user1": {
|
||||
"TV Shows": expected_tv_show_watched_list_1,
|
||||
"Movies": expected_movie_watched_list_1,
|
||||
}
|
||||
}
|
||||
|
||||
expected_watched_list_2 = {
|
||||
"user1": {
|
||||
"TV Shows": expected_tv_show_watched_list_2,
|
||||
"Movies": expected_movie_watched_list_2,
|
||||
}
|
||||
}
|
||||
|
||||
return_watched_list_1 = cleanup_watched(user_watched_list_1, user_watched_list_2)
|
||||
return_watched_list_2 = cleanup_watched(user_watched_list_2, user_watched_list_1)
|
||||
|
||||
assert return_watched_list_1 == expected_watched_list_1
|
||||
assert return_watched_list_2 == expected_watched_list_2
|
||||
|
||||
|
||||
def test_mapping_cleanup_watched():
|
||||
user_watched_list_1 = {
|
||||
"user1": {
|
||||
"TV Shows": tv_shows_watched_list_1,
|
||||
"Movies": movies_watched_list_1,
|
||||
"Other Shows": tv_shows_2_watched_list_1,
|
||||
},
|
||||
}
|
||||
user_watched_list_2 = {
|
||||
"user2": {
|
||||
"Shows": tv_shows_watched_list_2,
|
||||
"Movies": movies_watched_list_2,
|
||||
"Other Shows": tv_shows_2_watched_list_1,
|
||||
}
|
||||
}
|
||||
|
||||
expected_watched_list_1 = {
|
||||
"user1": {
|
||||
"TV Shows": expected_tv_show_watched_list_1,
|
||||
"Movies": expected_movie_watched_list_1,
|
||||
}
|
||||
}
|
||||
|
||||
expected_watched_list_2 = {
|
||||
"user2": {
|
||||
"Shows": expected_tv_show_watched_list_2,
|
||||
"Movies": expected_movie_watched_list_2,
|
||||
}
|
||||
}
|
||||
|
||||
user_mapping = {"user1": "user2"}
|
||||
library_mapping = {"TV Shows": "Shows"}
|
||||
|
||||
return_watched_list_1 = cleanup_watched(
|
||||
user_watched_list_1,
|
||||
user_watched_list_2,
|
||||
user_mapping=user_mapping,
|
||||
library_mapping=library_mapping,
|
||||
)
|
||||
return_watched_list_2 = cleanup_watched(
|
||||
user_watched_list_2,
|
||||
user_watched_list_1,
|
||||
user_mapping=user_mapping,
|
||||
library_mapping=library_mapping,
|
||||
)
|
||||
|
||||
assert return_watched_list_1 == expected_watched_list_1
|
||||
assert return_watched_list_2 == expected_watched_list_2
|
||||
39
test/test_users.py
Normal file
39
test/test_users.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# getting the name of the directory
|
||||
# where the this file is present.
|
||||
current = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
# Getting the parent directory name
|
||||
# where the current directory is present.
|
||||
parent = os.path.dirname(current)
|
||||
|
||||
# adding the parent directory to
|
||||
# the sys.path.
|
||||
sys.path.append(parent)
|
||||
|
||||
from src.users import (
|
||||
combine_user_lists,
|
||||
filter_user_lists,
|
||||
)
|
||||
|
||||
|
||||
def test_combine_user_lists():
|
||||
server_1_users = ["test", "test3", "luigi311"]
|
||||
server_2_users = ["luigi311", "test2", "test3"]
|
||||
user_mapping = {"test2": "test"}
|
||||
|
||||
combined = combine_user_lists(server_1_users, server_2_users, user_mapping)
|
||||
|
||||
assert combined == {"luigi311": "luigi311", "test": "test2", "test3": "test3"}
|
||||
|
||||
|
||||
def test_filter_user_lists():
|
||||
users = {"luigi311": "luigi311", "test": "test2", "test3": "test3"}
|
||||
blacklist_users = ["test3"]
|
||||
whitelist_users = ["test", "luigi311"]
|
||||
|
||||
filtered = filter_user_lists(users, blacklist_users, whitelist_users)
|
||||
|
||||
assert filtered == {"test": "test2", "luigi311": "luigi311"}
|
||||
705
test/test_watched.py
Normal file
705
test/test_watched.py
Normal file
@@ -0,0 +1,705 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# getting the name of the directory
|
||||
# where the this file is present.
|
||||
current = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
# Getting the parent directory name
|
||||
# where the current directory is present.
|
||||
parent = os.path.dirname(current)
|
||||
|
||||
# adding the parent directory to
|
||||
# the sys.path.
|
||||
sys.path.append(parent)
|
||||
|
||||
from src.watched import (
|
||||
LibraryData,
|
||||
MediaIdentifiers,
|
||||
MediaItem,
|
||||
Series,
|
||||
UserData,
|
||||
WatchedStatus,
|
||||
cleanup_watched,
|
||||
)
|
||||
|
||||
tv_shows_watched_list_1: list[Series] = [
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Doctor Who (2005)",
|
||||
locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",),
|
||||
imdb_id="tt0436992",
|
||||
tmdb_id="57243",
|
||||
tvdb_id="78804",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Unquiet Dead",
|
||||
locations=("S01E03.mkv",),
|
||||
imdb_id="tt0563001",
|
||||
tmdb_id="968589",
|
||||
tvdb_id="295296",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Aliens of London (1)",
|
||||
locations=("S01E04.mkv",),
|
||||
imdb_id="tt0562985",
|
||||
tmdb_id="968590",
|
||||
tvdb_id="295297",
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=240000),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="World War Three (2)",
|
||||
locations=("S01E05.mkv",),
|
||||
imdb_id="tt0563003",
|
||||
tmdb_id="968592",
|
||||
tvdb_id="295298",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
],
|
||||
),
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Monarch: Legacy of Monsters",
|
||||
locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||
imdb_id="tt17220216",
|
||||
tmdb_id="202411",
|
||||
tvdb_id="422598",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Secrets and Lies",
|
||||
locations=("S01E03.mkv",),
|
||||
imdb_id="tt21255044",
|
||||
tmdb_id="4661246",
|
||||
tvdb_id="10009418",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Parallels and Interiors",
|
||||
locations=("S01E04.mkv",),
|
||||
imdb_id="tt21255050",
|
||||
tmdb_id="4712059",
|
||||
tvdb_id="10009419",
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=240000),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Way Out",
|
||||
locations=("S01E05.mkv",),
|
||||
imdb_id="tt23787572",
|
||||
tmdb_id="4712061",
|
||||
tvdb_id="10009420",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
],
|
||||
),
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="My Adventures with Superman",
|
||||
locations=("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",),
|
||||
imdb_id="tt14681924",
|
||||
tmdb_id="125928",
|
||||
tvdb_id="403172",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Adventures of a Normal Man (1)",
|
||||
locations=("S01E01.mkv",),
|
||||
imdb_id="tt15699926",
|
||||
tmdb_id="3070048",
|
||||
tvdb_id="8438181",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Adventures of a Normal Man (2)",
|
||||
locations=("S01E02.mkv",),
|
||||
imdb_id="tt20413322",
|
||||
tmdb_id="4568681",
|
||||
tvdb_id="9829910",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="My Interview with Superman",
|
||||
locations=("S01E03.mkv",),
|
||||
imdb_id="tt20413328",
|
||||
tmdb_id="4497012",
|
||||
tvdb_id="9870382",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# TV Shows Watched list 2
|
||||
|
||||
tv_shows_watched_list_2: list[Series] = [
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Doctor Who",
|
||||
locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",),
|
||||
imdb_id="tt0436992",
|
||||
tmdb_id="57243",
|
||||
tvdb_id="78804",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Rose",
|
||||
locations=("S01E01.mkv",),
|
||||
imdb_id="tt0562992",
|
||||
tvdb_id="295294",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The End of the World",
|
||||
locations=("S01E02.mkv",),
|
||||
imdb_id="tt0562997",
|
||||
tvdb_id="295295",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=300670),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="World War Three (2)",
|
||||
locations=("S01E05.mkv",),
|
||||
imdb_id="tt0563003",
|
||||
tvdb_id="295298",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
],
|
||||
),
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Monarch: Legacy of Monsters",
|
||||
locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||
imdb_id="tt17220216",
|
||||
tmdb_id="202411",
|
||||
tvdb_id="422598",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Aftermath",
|
||||
locations=("S01E01.mkv",),
|
||||
imdb_id="tt20412166",
|
||||
tvdb_id="9959300",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Departure",
|
||||
locations=("S01E02.mkv",),
|
||||
imdb_id="tt22866594",
|
||||
tvdb_id="10009417",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=300741),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Way Out",
|
||||
locations=("S01E05.mkv",),
|
||||
imdb_id="tt23787572",
|
||||
tvdb_id="10009420",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
],
|
||||
),
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="My Adventures with Superman",
|
||||
locations=("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",),
|
||||
imdb_id="tt14681924",
|
||||
tmdb_id="125928",
|
||||
tvdb_id="403172",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Adventures of a Normal Man (1)",
|
||||
locations=("S01E01.mkv",),
|
||||
imdb_id="tt15699926",
|
||||
tvdb_id="8438181",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Adventures of a Normal Man (2)",
|
||||
locations=("S01E02.mkv",),
|
||||
imdb_id="tt20413322",
|
||||
tvdb_id="9829910",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="My Interview with Superman",
|
||||
locations=("S01E03.mkv",),
|
||||
imdb_id="tt20413328",
|
||||
tvdb_id="9870382",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Expected TV Shows Watched list 1 (after cleanup)
|
||||
|
||||
expected_tv_show_watched_list_1: list[Series] = [
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Doctor Who (2005)",
|
||||
locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",),
|
||||
imdb_id="tt0436992",
|
||||
tmdb_id="57243",
|
||||
tvdb_id="78804",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Unquiet Dead",
|
||||
locations=("S01E03.mkv",),
|
||||
imdb_id="tt0563001",
|
||||
tmdb_id="968589",
|
||||
tvdb_id="295296",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Aliens of London (1)",
|
||||
locations=("S01E04.mkv",),
|
||||
imdb_id="tt0562985",
|
||||
tmdb_id="968590",
|
||||
tvdb_id="295297",
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=240000),
|
||||
),
|
||||
],
|
||||
),
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Monarch: Legacy of Monsters",
|
||||
locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||
imdb_id="tt17220216",
|
||||
tmdb_id="202411",
|
||||
tvdb_id="422598",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Secrets and Lies",
|
||||
locations=("S01E03.mkv",),
|
||||
imdb_id="tt21255044",
|
||||
tmdb_id="4661246",
|
||||
tvdb_id="10009418",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Parallels and Interiors",
|
||||
locations=("S01E04.mkv",),
|
||||
imdb_id="tt21255050",
|
||||
tmdb_id="4712059",
|
||||
tvdb_id="10009419",
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=240000),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Expected TV Shows Watched list 2 (after cleanup)
|
||||
|
||||
expected_tv_show_watched_list_2: list[Series] = [
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Doctor Who",
|
||||
locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",),
|
||||
imdb_id="tt0436992",
|
||||
tmdb_id="57243",
|
||||
tvdb_id="78804",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Rose",
|
||||
locations=("S01E01.mkv",),
|
||||
imdb_id="tt0562992",
|
||||
tvdb_id="295294",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The End of the World",
|
||||
locations=("S01E02.mkv",),
|
||||
imdb_id="tt0562997",
|
||||
tvdb_id="295295",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=300670),
|
||||
),
|
||||
],
|
||||
),
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Monarch: Legacy of Monsters",
|
||||
locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
|
||||
imdb_id="tt17220216",
|
||||
tmdb_id="202411",
|
||||
tvdb_id="422598",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Aftermath",
|
||||
locations=("S01E01.mkv",),
|
||||
imdb_id="tt20412166",
|
||||
tvdb_id="9959300",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Departure",
|
||||
locations=("S01E02.mkv",),
|
||||
imdb_id="tt22866594",
|
||||
tvdb_id="10009417",
|
||||
tmdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=300741),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Movies Watched list 1
|
||||
|
||||
movies_watched_list_1: list[MediaItem] = [
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Big Buck Bunny",
|
||||
locations=("Big Buck Bunny.mkv",),
|
||||
imdb_id="tt1254207",
|
||||
tmdb_id="10378",
|
||||
tvdb_id="12352",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Family Plan",
|
||||
locations=("The Family Plan (2023).mkv",),
|
||||
imdb_id="tt16431870",
|
||||
tmdb_id="1029575",
|
||||
tvdb_id="351194",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Killers of the Flower Moon",
|
||||
locations=("Killers of the Flower Moon (2023).mkv",),
|
||||
imdb_id="tt5537002",
|
||||
tmdb_id="466420",
|
||||
tvdb_id="135852",
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=240000),
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Movies Watched list 2
|
||||
|
||||
movies_watched_list_2: list[MediaItem] = [
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Family Plan",
|
||||
locations=("The Family Plan (2023).mkv",),
|
||||
imdb_id="tt16431870",
|
||||
tmdb_id="1029575",
|
||||
tvdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Five Nights at Freddy's",
|
||||
locations=("Five Nights at Freddy's (2023).mkv",),
|
||||
imdb_id="tt4589218",
|
||||
tmdb_id="507089",
|
||||
tvdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Hunger Games: The Ballad of Songbirds & Snakes",
|
||||
locations=("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",),
|
||||
imdb_id="tt10545296",
|
||||
tmdb_id="695721",
|
||||
tvdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=301215),
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Expected Movies Watched list 1
|
||||
|
||||
expected_movie_watched_list_1: list[MediaItem] = [
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Big Buck Bunny",
|
||||
locations=("Big Buck Bunny.mkv",),
|
||||
imdb_id="tt1254207",
|
||||
tmdb_id="10378",
|
||||
tvdb_id="12352",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Killers of the Flower Moon",
|
||||
locations=("Killers of the Flower Moon (2023).mkv",),
|
||||
imdb_id="tt5537002",
|
||||
tmdb_id="466420",
|
||||
tvdb_id="135852",
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=240000),
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Expected Movies Watched list 2
|
||||
|
||||
expected_movie_watched_list_2: list[MediaItem] = [
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Five Nights at Freddy's",
|
||||
locations=("Five Nights at Freddy's (2023).mkv",),
|
||||
imdb_id="tt4589218",
|
||||
tmdb_id="507089",
|
||||
tvdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
),
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="The Hunger Games: The Ballad of Songbirds & Snakes",
|
||||
locations=("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",),
|
||||
imdb_id="tt10545296",
|
||||
tmdb_id="695721",
|
||||
tvdb_id=None,
|
||||
),
|
||||
status=WatchedStatus(completed=False, time=301215),
|
||||
),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# TV Shows 2 Watched list 1 (for testing deletion up to the root)
|
||||
# Here we use a single Series entry for "Criminal Minds"
|
||||
|
||||
tv_shows_2_watched_list_1: list[Series] = [
|
||||
Series(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Criminal Minds",
|
||||
locations=("Criminal Minds",),
|
||||
imdb_id="tt0452046",
|
||||
tmdb_id="4057",
|
||||
tvdb_id="75710",
|
||||
),
|
||||
episodes=[
|
||||
MediaItem(
|
||||
identifiers=MediaIdentifiers(
|
||||
title="Extreme Aggressor",
|
||||
locations=(
|
||||
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",
|
||||
),
|
||||
imdb_id="tt0550489",
|
||||
tmdb_id="282843",
|
||||
tvdb_id="176357",
|
||||
),
|
||||
status=WatchedStatus(completed=True, time=0),
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_simple_cleanup_watched():
|
||||
user_watched_list_1: dict[str, UserData] = {
|
||||
"user1": UserData(
|
||||
libraries={
|
||||
"TV Shows": LibraryData(
|
||||
title="TV Shows",
|
||||
movies=[],
|
||||
series=tv_shows_watched_list_1,
|
||||
),
|
||||
"Movies": LibraryData(
|
||||
title="Movies",
|
||||
movies=movies_watched_list_1,
|
||||
series=[],
|
||||
),
|
||||
"Other Shows": LibraryData(
|
||||
title="Other Shows",
|
||||
movies=[],
|
||||
series=tv_shows_2_watched_list_1,
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
user_watched_list_2: dict[str, UserData] = {
|
||||
"user1": UserData(
|
||||
libraries={
|
||||
"TV Shows": LibraryData(
|
||||
title="TV Shows",
|
||||
movies=[],
|
||||
series=tv_shows_watched_list_2,
|
||||
),
|
||||
"Movies": LibraryData(
|
||||
title="Movies",
|
||||
movies=movies_watched_list_2,
|
||||
series=[],
|
||||
),
|
||||
"Other Shows": LibraryData(
|
||||
title="Other Shows",
|
||||
movies=[],
|
||||
series=tv_shows_2_watched_list_1,
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
expected_watched_list_1: dict[str, UserData] = {
|
||||
"user1": UserData(
|
||||
libraries={
|
||||
"TV Shows": LibraryData(
|
||||
title="TV Shows",
|
||||
movies=[],
|
||||
series=expected_tv_show_watched_list_1,
|
||||
),
|
||||
"Movies": LibraryData(
|
||||
title="Movies",
|
||||
movies=expected_movie_watched_list_1,
|
||||
series=[],
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
expected_watched_list_2: dict[str, UserData] = {
|
||||
"user1": UserData(
|
||||
libraries={
|
||||
"TV Shows": LibraryData(
|
||||
title="TV Shows",
|
||||
movies=[],
|
||||
series=expected_tv_show_watched_list_2,
|
||||
),
|
||||
"Movies": LibraryData(
|
||||
title="Movies",
|
||||
movies=expected_movie_watched_list_2,
|
||||
series=[],
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return_watched_list_1 = cleanup_watched(user_watched_list_1, user_watched_list_2)
|
||||
return_watched_list_2 = cleanup_watched(user_watched_list_2, user_watched_list_1)
|
||||
|
||||
assert return_watched_list_1 == expected_watched_list_1
|
||||
assert return_watched_list_2 == expected_watched_list_2
|
||||
|
||||
|
||||
# def test_mapping_cleanup_watched():
|
||||
# user_watched_list_1 = {
|
||||
# "user1": {
|
||||
# "TV Shows": tv_shows_watched_list_1,
|
||||
# "Movies": movies_watched_list_1,
|
||||
# "Other Shows": tv_shows_2_watched_list_1,
|
||||
# },
|
||||
# }
|
||||
# user_watched_list_2 = {
|
||||
# "user2": {
|
||||
# "Shows": tv_shows_watched_list_2,
|
||||
# "Movies": movies_watched_list_2,
|
||||
# "Other Shows": tv_shows_2_watched_list_1,
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# expected_watched_list_1 = {
|
||||
# "user1": {
|
||||
# "TV Shows": expected_tv_show_watched_list_1,
|
||||
# "Movies": expected_movie_watched_list_1,
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# expected_watched_list_2 = {
|
||||
# "user2": {
|
||||
# "Shows": expected_tv_show_watched_list_2,
|
||||
# "Movies": expected_movie_watched_list_2,
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# user_mapping = {"user1": "user2"}
|
||||
# library_mapping = {"TV Shows": "Shows"}
|
||||
#
|
||||
# return_watched_list_1 = cleanup_watched(
|
||||
# user_watched_list_1,
|
||||
# user_watched_list_2,
|
||||
# user_mapping=user_mapping,
|
||||
# library_mapping=library_mapping,
|
||||
# )
|
||||
# return_watched_list_2 = cleanup_watched(
|
||||
# user_watched_list_2,
|
||||
# user_watched_list_1,
|
||||
# user_mapping=user_mapping,
|
||||
# library_mapping=library_mapping,
|
||||
# )
|
||||
#
|
||||
# assert return_watched_list_1 == expected_watched_list_1
|
||||
# assert return_watched_list_2 == expected_watched_list_2
|
||||
217
test/validate_ci_marklog.py
Normal file
217
test/validate_ci_marklog.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from loguru import logger
|
||||
from collections import Counter
|
||||
|
||||
|
||||
class MarkLogError(Exception):
|
||||
"""Custom exception for mark.log validation failures."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
"--guids", action="store_true", help="Check the mark.log file for guids"
|
||||
)
|
||||
group.add_argument(
|
||||
"--locations", action="store_true", help="Check the mark.log file for locations"
|
||||
)
|
||||
group.add_argument(
|
||||
"--write", action="store_true", help="Check the mark.log file for write-run"
|
||||
)
|
||||
group.add_argument(
|
||||
"--plex", action="store_true", help="Check the mark.log file for Plex"
|
||||
)
|
||||
group.add_argument(
|
||||
"--jellyfin", action="store_true", help="Check the mark.log file for Jellyfin"
|
||||
)
|
||||
group.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")
|
||||
try:
|
||||
with open(marklog, "r") as f:
|
||||
lines = [line.strip() for line in f if line.strip()]
|
||||
return lines
|
||||
except Exception as e:
|
||||
raise MarkLogError(f"Error reading {marklog}: {e}")
|
||||
|
||||
|
||||
def check_marklog(lines, expected_values):
|
||||
found_counter = Counter(lines)
|
||||
expected_counter = Counter(expected_values)
|
||||
|
||||
# Determine missing and extra items by comparing counts
|
||||
missing = expected_counter - found_counter
|
||||
extra = found_counter - expected_counter
|
||||
|
||||
if missing or extra:
|
||||
if missing:
|
||||
logger.error("Missing expected entries (with counts):")
|
||||
for entry, count in missing.items():
|
||||
logger.error(f" {entry}: missing {count} time(s)")
|
||||
if extra:
|
||||
logger.error("Unexpected extra entries found (with counts):")
|
||||
for entry, count in extra.items():
|
||||
logger.error(f" {entry}: found {count} extra time(s)")
|
||||
|
||||
logger.error(
|
||||
f"Entry count mismatch: found {len(lines)} entries, expected {len(expected_values)} entries."
|
||||
)
|
||||
logger.error("Full mark.log content:")
|
||||
for line in sorted(lines):
|
||||
logger.error(f" {line}")
|
||||
raise MarkLogError("mark.log validation failed.")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
# Expected values defined for each check
|
||||
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",
|
||||
]
|
||||
|
||||
# Determine which expected values to use based on the command-line flag
|
||||
if args.guids:
|
||||
expected_values = expected_guids
|
||||
check_type = "GUIDs"
|
||||
elif args.locations:
|
||||
expected_values = expected_locations
|
||||
check_type = "locations"
|
||||
elif args.write:
|
||||
expected_values = expected_write
|
||||
check_type = "write-run"
|
||||
elif args.plex:
|
||||
expected_values = expected_plex
|
||||
check_type = "Plex"
|
||||
elif args.jellyfin:
|
||||
expected_values = expected_jellyfin
|
||||
check_type = "Jellyfin"
|
||||
elif args.emby:
|
||||
expected_values = expected_emby
|
||||
check_type = "Emby"
|
||||
else:
|
||||
raise MarkLogError("No server specified")
|
||||
|
||||
logger.info(f"Validating mark.log for {check_type}...")
|
||||
|
||||
try:
|
||||
lines = read_marklog()
|
||||
check_marklog(lines, expected_values)
|
||||
except MarkLogError as e:
|
||||
logger.error(e)
|
||||
sys.exit(1)
|
||||
|
||||
logger.success("Successfully validated mark.log")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
393
uv.lock
generated
Normal file
393
uv.lock
generated
Normal file
@@ -0,0 +1,393 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jellyplex-watched"
|
||||
version = "8.0.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "loguru" },
|
||||
{ name = "packaging" },
|
||||
{ name = "plexapi" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "packaging", specifier = "==25.0" },
|
||||
{ name = "plexapi", specifier = "==4.17.0" },
|
||||
{ name = "pydantic", specifier = "==2.11.7" },
|
||||
{ name = "python-dotenv", specifier = "==1.1.1" },
|
||||
{ name = "requests", specifier = "==2.32.4" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "mypy", specifier = ">=1.16.1" },
|
||||
{ name = "pytest", specifier = ">=8.4.1" },
|
||||
{ name = "types-requests", specifier = ">=2.32.0.20250611" },
|
||||
]
|
||||
lint = [{ name = "ruff", specifier = ">=0.12.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plexapi"
|
||||
version = "4.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/79/129a01479ae08d934782a4ae2ece5bb1eee7e9576c14cf41b467a403dcb6/plexapi-4.17.0.tar.gz", hash = "sha256:065ff984a9500e049a9cc30927ab3245e518e39edc2f4058e31528be1a0a2aef", size = 154599, upload-time = "2025-04-19T02:24:08.532Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/42/400828990b1884bb3d18d6cdbd1c26f91f1ca256619d057bd5f5d8a9ec7b/plexapi-4.17.0-py3-none-any.whl", hash = "sha256:cf42a990205c0327a2ab1d2871087a91b50596e6e960b99a185bf657525e6938", size = 166667, upload-time = "2025-04-19T02:24:06.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.12.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341, upload-time = "2025-07-11T13:21:16.086Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499, upload-time = "2025-07-11T13:20:26.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413, upload-time = "2025-07-11T13:20:30.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941, upload-time = "2025-07-11T13:20:33.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001, upload-time = "2025-07-11T13:20:35.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641, upload-time = "2025-07-11T13:20:38.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059, upload-time = "2025-07-11T13:20:41.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890, upload-time = "2025-07-11T13:20:44.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008, upload-time = "2025-07-11T13:20:47.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096, upload-time = "2025-07-11T13:20:50.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307, upload-time = "2025-07-11T13:20:52.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020, upload-time = "2025-07-11T13:20:55.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300, upload-time = "2025-07-11T13:20:58.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119, upload-time = "2025-07-11T13:21:01.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990, upload-time = "2025-07-11T13:21:04.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263, upload-time = "2025-07-11T13:21:07.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072, upload-time = "2025-07-11T13:21:11.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-requests"
|
||||
version = "2.32.4.20250611"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/7f/73b3a04a53b0fd2a911d4ec517940ecd6600630b559e4505cc7b68beb5a0/types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826", size = 23118, upload-time = "2025-06-11T03:11:41.272Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ea/0be9258c5a4fa1ba2300111aa5a0767ee6d18eb3fd20e91616c12082284d/types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072", size = 20643, upload-time = "2025-06-11T03:11:40.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user