393 Commits

Author SHA1 Message Date
Luigi311
61e3dddd6b Merge pull request #245 from luigi311/none_title
Watched: Allow None for mediaidentifier title
2025-02-26 23:14:20 -07:00
Luis Garcia
aaaa7eba70 Watched: Allow None for mediaidentifier title 2025-02-26 23:13:49 +00:00
Luigi311
991355716d Merge pull request #240 from luigi311/user_name
Plex: Use username for watch key if exists
2025-02-26 15:48:32 -07:00
Luis Garcia
54bd6e836f Plex: Use username for watch key if exists
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-26 22:46:16 +00:00
Luigi311
57c41f41bc Merge pull request #244 from luigi311/watched_trace
Move server watched log to trace
2025-02-26 13:27:09 -07:00
Luis Garcia
ea85a31d9c Move server watched log to trace
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-26 20:23:40 +00:00
Luigi311
80d5c9e54c Merge pull request #242 from BadCoder2/patch-1
Update README.md to reflect uv update; make environment setting more clear
2025-02-25 23:05:30 -07:00
Nathan Hoffman
5828701944 Update README.md to uv; make environment setting more clear 2025-02-25 23:44:26 -06:00
Luigi311
81ba9bd7f9 Merge pull request #237 from luigi311/rotate_logging
Reconfigure the logger on each loop so the logs are rotated on each run
2025-02-23 12:46:14 -07:00
Luis Garcia
d15759570e Reconfigure the logger on each loop so the logs are rotated on each run
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-23 19:42:55 +00:00
Luigi311
1b88ecf2eb Merge pull request #236 from luigi311/logging
More logging
2025-02-22 17:18:10 -07:00
Luis Garcia
c62809c615 More logging
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-23 00:15:14 +00:00
Luigi311
899a6b05a4 Merge pull request #235 from luigi311/docker_defaults
Docker: Set default env values to prevent issues
2025-02-21 23:41:12 -07:00
Luis Garcia
fcd6103e17 Docker: Set default env values to prevent issues
Set default values to prevent issues where it thinks values are set and
causing json read errors

Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 23:36:19 -07:00
Luigi311
ac5be474f8 Merge pull request #234 from luigi311/cleanup
Cleanup
2025-02-21 19:55:09 -07:00
Luis Garcia
d15f29b772 Do not include test in docker image
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 19:52:12 -07:00
Luis Garcia
c9944866f8 Remove extra print
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 19:51:34 -07:00
Luigi311
846e18fffe Merge pull request #232 from luigi311/test_update
Update test validation to use loguru
2025-02-21 19:39:07 -07:00
Luis Garcia
eb09de2bdf CI: Better validate logging 2025-02-21 19:37:04 -07:00
Luis Garcia
c0e207924c CI: Enable trace
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 19:36:48 -07:00
Luigi311
e48533dfbd Merge pull request #230 from luigi311/file_logfile
Fix logfile
2025-02-21 17:01:35 -07:00
Luis Garcia
8503b087b2 Fix logfile
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 16:59:50 -07:00
Luigi311
305fea8f9a Merge pull request #229 from luigi311/loguru
Switch logging to loguru
2025-02-21 16:09:15 -07:00
Luis Garcia
588c23ce41 Switch logging to loguru
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 16:03:29 -07:00
Luigi311
8f4a2e2690 Merge pull request #228 from luigi311/misc
Misc
2025-02-21 14:54:10 -07:00
Luis Garcia
38e65f5a17 vscode: Fix deprecated python debug type
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 14:47:14 -07:00
Luis Garcia
de32d59aa1 Better initial library filtering
Filter for only tv shows and movie type libraries in plex and jellyfin.
Jellyfin no longer require pulling in multiple different items and
instead use the actual library category

Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 14:41:42 -07:00
Luis Garcia
998f2b1209 Formatting
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 14:41:19 -07:00
Luis Garcia
0b02f531c1 Force python 3.12 or greater
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-21 12:10:53 -07:00
Luigi311
e589935b37 Merge pull request #226 from luigi311/plex_episodes
Plex: only fetch watched or partially watched episodes
2025-02-19 20:09:41 -07:00
Luigi311
031d43e980 Merge pull request #227 from luigi311/uv
Swap to UV
2025-02-19 20:09:27 -07:00
Luis Garcia
ba6cad13f6 Swap to UV 2025-02-19 19:48:16 -07:00
Luis Garcia
f3801a0bd2 Plex: only fetch watched or partially watched episodes
Instead of fetching all episodes and checking if watched or view time it
is faster to search for only watched and partially watched episodes.

Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-19 13:52:05 -07:00
Luigi311
196a49fca4 Merge pull request #224 from luigi311/pydantic
Use Pydantic for watch structure
2025-02-19 13:20:45 -07:00
Luis Garcia
4d0f1d303f Plex: Remove logging if locations or guids
Remove the logging of if an item has locations or guids as that
forces a fetch of that data which defeats the purpose of generate
guid/location variables

Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-19 12:44:21 -07:00
Luis Garcia
ce5b810a5b Use pydantic for structure
Complete redesign of everything using pydantic to create the
watched structure. This brings in type checking support and
simplifies a lot of things

Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-19 10:51:10 -07:00
Luigi311
a1e1ccde42 Merge pull request #210 from luigi311/more_cleanup
More cleanup
2025-02-18 18:09:57 -07:00
Luis Garcia
bf633c75d1 More typing
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
46fa5e7c9a Use list instead of tuple for servers
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
170757aca1 Add lots of static typing
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
9786e9e27d Test: Split out black and white mapping tests
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
8b691b7bfa Test: Remove duplicate black and white list test
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:38 -07:00
Luis Garcia
e1c65fc082 Jellyfin/emby: Combine info/version, add typing for versions
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:45:21 -07:00
Luis Garcia
58749a4fb8 Plex: Improve variable names to avoid confusion
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:45:21 -07:00
Luis Garcia
51ec69f651 Simplify search_mapping
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:45:21 -07:00
Luigi311
4771f736b0 Merge pull request #223 from luigi311/update
Update python and PlexAPI
2025-02-18 17:23:47 -07:00
Luis Garcia
8d7436579e CI: Update plex token 2025-02-18 17:10:54 -07:00
Luis Garcia
43e1df98b1 Update to Python 3.13 2025-02-18 16:43:22 -07:00
Luis Garcia
3017030f52 Requirements: Update PlexAPI 4.16.1 2025-02-18 16:42:35 -07:00
Luis Garcia
348a0b8226 Dont show error on extract show/episode/movies output dict
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 13:38:59 -07:00
Luis Garcia
4e60c08120 Reduce sample max_threads to 2
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 12:53:13 -07:00
Luis Garcia
10b58379cd Test: Validate for GUIDS and Locations
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 12:17:33 -07:00
Luis Garcia
fa9201b20f Update requirements
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 10:44:31 -07:00
Luigi311
86f72997b4 Merge pull request #205 from luigi311/simplify_watched
Simplify get watched process
2024-10-27 18:13:31 -06:00
Luis Garcia
62d0319aad Remove unused
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
a096a09eb7 CI: Fix Validation. Print marklog on failed validation
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
7294241fed Add server type and name to marklog
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
a5995d3999 CI: More verbose
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
30f31b2f3f Remove unused combine_watched_dicts
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
bc09c873e9 Simplify get watched process. Only get watched for syncing libraries
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luigi311
8428be9dda Create FUNDING.yml 2024-10-27 09:05:06 -06:00
Luigi311
6a45ad18f9 Merge pull request #202 from luigi311/python-12
Update to python 12
2024-10-07 23:10:00 -06:00
Luigi311
023b638729 CI: Pin to python 3.12 2024-10-08 04:59:44 +00:00
Luigi311
7e13c14636 Merge pull request #195 from luigi311/fix_user
Fix user
2024-09-13 17:01:45 -06:00
Luis Garcia
0c218fa9dd Entrypoint: Alpine fix overlapping PGID issue 2024-09-13 16:24:58 -06:00
Luis Garcia
b3b0ccac73 Docker: Fix alpine 2024-09-13 10:12:18 -06:00
Luis Garcia
fa0134551f Entrypoint: Check root user, check addgroup/adduser command exists 2024-09-13 09:56:34 -06:00
Luis Garcia
34d62c9021 Docker: Add dos2unix 2024-09-08 01:28:11 -06:00
Luis Garcia
920bbbb3be Move more functions out of main 2024-09-05 16:21:06 -06:00
Luis Garcia
762e5f10da Fix typos in variables 2024-08-28 17:14:45 -06:00
Luis Garcia
27797cb361 Formatting 2024-08-28 17:14:37 -06:00
Luis Garcia
066f9d1f66 Docker Compose: Use env_file for most variables 2024-08-28 16:46:19 -06:00
Luis Garcia
acf7c2cdf2 Entrypoint: Fix typos 2024-08-28 16:45:58 -06:00
Luis Garcia
469857a31a Dockerfiles: Remove most env 2024-08-28 16:45:37 -06:00
Luigi311
405e5decf2 CI: Move away from docker-compose 2024-08-11 06:14:50 -06:00
Luigi311
da9abf8a24 Merge pull request #187 from luigi311/puid_pgid
Add puid pgid support to fix permission issues
2024-08-03 08:30:18 -06:00
Luis Garcia
128c6a1c76 Fix missing logs/mark folder if set 2024-07-24 02:09:04 -06:00
Luis Garcia
99f32c10ef Add support for PGID and PUID 2024-07-24 01:57:45 -06:00
Luigi311
44e42f99db Merge pull request #185 from luigi311/partial_support_check
Jellyfin/Emby: Check partial sync support
2024-07-15 21:08:07 -06:00
awakenedhaggis
b1639eab0f Jellyfin/Emby: Check partial sync support
- add `is_partial_update_supported` method to each class to validate given version against earliest known supported version
- add `get_server_version` to get server version number
- add `update_partial` parameter to user update function, deciding whether or not to allow partial updates
2024-07-15 11:45:43 -06:00
Luis Garcia
679d3535b1 CI: Fix latest 2024-07-15 05:10:36 -06:00
Luis Garcia
a795d4bba5 README: Remove dev information 2024-07-15 04:36:27 -06:00
Luis Garcia
0a025cf5fa Tie latest to version releases
Tie latest to version releases so dev no longer needs to exists and instead main is the new dev.
2024-07-15 04:36:27 -06:00
Luigi311
6a1ceb4db3 Merge pull request #182 from luigi311/dev
Jellyfin: Skip partial on version lower than 10.9
2024-07-15 03:36:40 -06:00
Luis Garcia
99c339c405 CI: Plex remove https 2024-07-15 03:28:59 -06:00
Luis Garcia
bd75d865ba Update PlexAPI and requests 2024-07-15 03:03:44 -06:00
Luis Garcia
d30e03b702 Jellyfin: Skip partial on version lower than 10.9 2024-07-15 03:01:53 -06:00
Luigi311
3b749faefb Merge pull request #175 from luigi311/dev
Remove seasons from watch list generation
2024-06-02 23:27:51 -06:00
Luis Garcia
74f29d44b3 README: Formatting 2024-06-02 23:22:06 -06:00
Luigi311
a397ceb54e Merge pull request #174 from luigi311/remove_season
Remove seasons from watch list
2024-06-02 23:14:06 -06:00
Luis Garcia
502b3616df Fix ci validation marklog 2024-06-02 23:11:11 -06:00
Luis Garcia
1a7178e32d Jellyfin: Remove episode filter as it doesnt exist in jellyfin 2024-06-02 23:11:11 -06:00
Luis Garcia
7119956ec7 Remove seasons from watch list 2024-06-02 20:14:02 -06:00
Luigi311
24035e217e Merge pull request #173 from luigi311/dev
Add support for emby, add sync in progress for jellyfin and emby
2024-06-02 18:29:02 -06:00
Luis Garcia
21ffce674f README: Add sync in progress ticks for Jellyfin and Emby 2024-06-02 18:22:30 -06:00
Luis Garcia
4185f5fc94 Fix tests to include partially watched jellyfin and emby 2024-06-02 18:19:48 -06:00
Luigi311
3fdcc99304 Merge pull request #171 from awakenedhaggis/feature/jellyfin-partial-watched
Jellyfin: support marking item as partially watched
2024-06-02 17:50:33 -06:00
awakenedhaggis
0fa2a698ac feat(jellyfin+emby): support marking item as partially watched
- this feature uses the new endpoint introduced with 24cbd64
2024-06-02 14:26:12 -06:00
dependabot[bot]
2b871c58ed ---
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-01 23:30:27 -06:00
Luigi311
5078243938 Merge pull request #172 from luigi311/emby
Add Emby support
2024-06-01 23:25:16 -06:00
Luis Garcia
b67e6d7257 Add skip if nothing is being synced 2024-06-01 23:21:46 -06:00
Luis Garcia
632dfbcadb CI: Add more tests 2024-06-01 23:21:46 -06:00
Luis Garcia
1f7da2f609 Add support for emby 2024-06-01 23:21:07 -06:00
Luigi311
b3175305bd Merge pull request #164 from luigi311/dev
Dev
2024-04-15 15:06:24 -06:00
Luis Garcia
5b1933cb08 format 2024-04-15 15:03:18 -06:00
Luis Garcia
ae71ca0940 Jellyfin/Plex: Log when guid items are missing 2024-04-14 17:44:31 -06:00
Luis Garcia
9b38729b95 Watched: Use get for season
Use get to avoid KeyError if season doesnt exist.
2024-04-14 17:08:49 -06:00
Luis Garcia
402c286742 Plex: format 2024-04-14 17:06:37 -06:00
Luis Garcia
dcd4ac1d36 Gitignore: expand .env 2024-04-14 17:06:29 -06:00
Luis Garcia
e6fbf746d8 CI: Increase wait 2024-04-14 17:06:07 -06:00
Luis Garcia
803d248cb8 Jellyfin: Skip if UserData is not avaliable 2024-04-05 00:47:24 -06:00
Luis Garcia
713be6970c Jellyfin: Fix error status code 2024-04-05 00:26:24 -06:00
Luigi311
62509f16db Merge pull request #151 from luigi311/dev
Return empty if season/show are missing from episode_watched_list
2024-02-13 15:27:44 -07:00
Luigi311
84899aef50 Return empty if season/show are missing from episode_watched_list
Signed-off-by: Luigi311 <git@luigi311.com>
2024-02-11 02:21:42 -07:00
Luigi311
86b30e1887 Merge pull request #147 from luigi311/dev
Plex: Use username
2024-01-25 17:53:38 -07:00
Luigi311
033ef76cfe Plex: Use username
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-25 17:41:43 -07:00
Luigi311
815596379c Merge pull request #141 from luigi311/dev
Dev
2024-01-18 16:07:10 -07:00
Luigi311
bc5e8bc65d Update requirements
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-18 15:40:02 -07:00
Luigi311
b32de7259b Jellyfin: Swap out is not
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-18 15:39:52 -07:00
Luigi311
29cb0cebd5 Merge pull request #140 from luigi311/fixes
Fixes
2024-01-17 15:01:59 -07:00
Luigi311
6744ebcb5b Jellyfin: Skip season if no indexnumber
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-16 04:07:40 -07:00
Luigi311
c6b026a82d Jellyfin: Remove redudent keys call
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-16 03:22:08 -07:00
Luigi311
cc706938ce Jellyfin: Add generate_guids/locations. Cleanup
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-16 02:53:36 -07:00
Luigi311
84b98db36b Jellyfin: Add timeout
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-16 00:50:22 -07:00
Luigi311
01ad15e2bd CI: Update action versions
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-16 00:27:01 -07:00
Luigi311
54adf0e56f Jellyfin: Remove async
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-16 00:21:58 -07:00
Luigi311
025e40b098 Merge pull request #139 from luigi311/dev
Dev
2024-01-12 19:19:23 -07:00
Luigi311
4534854001 Merge pull request #136 from luigi311/plex_optimize
Plex: Add GENERATE_GUIDS, remove recursive thread calls
2024-01-12 19:08:45 -07:00
Luigi311
362d54b471 plex: guids
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-12 17:55:28 -07:00
Luigi311
fa533ff65e Test Guids/Locations/both
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-12 17:22:48 -07:00
Luigi311
96fe367562 Add GENERATE_LOCATIONS
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-12 17:22:48 -07:00
Luigi311
9566ffa384 CI: Test twice
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-12 17:22:48 -07:00
Luigi311
f5835e1e72 Add GENERATE_GUIDS enviornment
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-12 17:22:48 -07:00
Luigi311
fe65716706 Plex: Remove recursive thread calls
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-12 17:22:16 -07:00
Luigi311
873735900f Functions: Add override_threads
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-12 17:22:16 -07:00
Luigi311
28c166146e Merge pull request #138 from remos/plex-guid-fix
Plex: fix guid lookup for X -> Plex sync
2024-01-12 12:07:42 -07:00
Somer Hayter
c6affc3108 Plex: add logging for failed find_video + get_video_status 2024-01-13 00:10:34 +11:00
Somer Hayter
59b49fd0df Plex: Fix guid lookup in find_video and get_video_status 2024-01-13 00:10:34 +11:00
Luigi311
6ec003f899 Merge pull request #135 from luigi311/dev
Dev
2024-01-06 04:45:19 -07:00
Luigi311
95f2a9ad30 If only one worker, run in main thread to avoid overhead 2024-01-06 01:13:15 -07:00
Luigi311
7317e8533d Watched: Use enumerate 2024-01-06 00:16:13 -07:00
Luigi311
f80c20d70c Watched: Remove deepcopy due to performance 2024-01-05 23:46:15 -07:00
Luigi311
01fc13c3e0 Merge branch 'dev' of github.com:luigi311/JellyPlex-Watched into dev 2024-01-05 22:45:19 -07:00
Luigi311
1edfecae42 Cleanup 2024-01-05 22:44:56 -07:00
Luigi311
9dab9a4632 Merge branch 'main' into dev 2024-01-05 15:12:54 -07:00
Luigi311
98a824bfdc Plex: Format 2024-01-05 14:58:24 -07:00
Luigi311
8fa9351ef1 Plex: Only partially watched more than 1 min 2024-01-05 14:58:24 -07:00
Roberto Banić
64b2197844 Remove unnecessary check 2024-01-05 14:58:24 -07:00
Roberto Banić
26f1f80be7 Refactor get_user_library_watched 2024-01-05 14:58:24 -07:00
Roberto Banić
2e4c2a6817 Refactor get_user_library_watched_show 2024-01-05 14:58:24 -07:00
Roberto Banić
9498335e22 Deduplicate get_movie_guids and get_episode_guids 2024-01-05 14:58:24 -07:00
Roberto Banić
26f40110d0 Bump minimum Python version to 3.9 2024-01-05 14:58:24 -07:00
Luigi311
9375d482b0 CI: Improve mark validation 2024-01-05 14:58:24 -07:00
Luigi311
de9180a124 Handle episode names are not unique 2024-01-05 14:58:24 -07:00
Luigi311
ba480d2cb7 CI: Add workflow dispatch 2024-01-05 14:58:24 -07:00
Luigi311
5014748ee1 CI: Speedup start containers 2024-01-05 14:58:24 -07:00
Luigi311
4e25ae5539 CI: Validate mark log 2024-01-05 14:58:24 -07:00
Jan Willhaus
a2b802a5de Add tini for sigterm handling 2024-01-05 14:58:24 -07:00
Luigi311
9739b27718 Remove failed message from show/episode/movie dict
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:55 -07:00
Luigi311
bdf6476689 Watched: combine_watched_dicts check types
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:55 -07:00
Luigi311
b8b627be1a Use season number instead of season name
Using season name is not reliable as it can vary between servers
and can be overridden by the user.

Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:55 -07:00
Luigi311
03cad668aa README: Add troubleshooting/Issues
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:55 -07:00
Luigi311
2e0ec9aa38 Plex: Use updateTimeline instead of updateProgress
Not all accounts have access to updateProgress, so use updateTimeline instead

Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:55 -07:00
Luigi311
4b02aae889 Show average time on exit
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:55 -07:00
Luigi311
c91ba0b1b3 Action: Add test
Spins up jellyfin and plex containers to test against
2024-01-05 14:57:55 -07:00
dependabot[bot]
6b7f8b04e6 Bump aiohttp from 3.8.6 to 3.9.0
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.8.6 to 3.9.0.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.8.6...v3.9.0)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-05 14:57:55 -07:00
Luigi311
5472baab51 Action: Limit ghcr push to luigi311
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:55 -07:00
Luigi311
d5b6859bf8 Action: Add default variant 2024-01-05 14:57:55 -07:00
Luigi311
8e23847c79 README: Change configuration to point to .env.sample 2024-01-05 14:57:55 -07:00
Luigi311
0c1579bae7 Use non root for containers 2024-01-05 14:57:55 -07:00
Luigi311
3dc50fff95 Docker-compose: Add markfile. Add user mapping ex 2024-01-05 14:57:55 -07:00
Luigi311
b8273f50c2 MARKFILE match LOGFILE 2024-01-05 14:57:55 -07:00
Luigi311
dbea28e9c6 Docker: Add RUN_ONLY_ONCE and MARKFILE 2024-01-05 14:57:55 -07:00
Luigi311
a1b11ab039 Add unraid to type 2024-01-05 14:57:55 -07:00
Luigi311
1841b0dea6 Jellyfin: Remove headers append 2024-01-05 14:57:55 -07:00
Luigi311
b311bf2770 Add MARK/DRYRUN logger levels 2024-01-05 14:57:55 -07:00
Luigi311
df13cef760 Add mark list support 2024-01-05 14:57:55 -07:00
Luigi311
76ac264b25 Add example baseurl/token to docker-compose 2024-01-05 14:57:44 -07:00
Luigi311
93bc94add5 Pin to 3.11 due to 3.12 issues 2024-01-05 14:57:44 -07:00
neofright
79325b8c61 Update README.md
Remove another unnecessary captialisation.
2024-01-05 14:57:44 -07:00
neofright
58c1eb7004 Improve README.md
- Inprogress is not a word in English, but two separate words.
- Many words are unnecessarily captialised as they are not names or at the beginning of the sentences.
- Prefer 'usernames' to 'usersnames'
2024-01-05 14:57:44 -07:00
neofright
466f292feb Typo in .env.sample 2024-01-05 14:57:44 -07:00
Luigi311
4de25a0d4a Print server info
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:44 -07:00
Luigi311
43d6bc0d82 Timeout issues (#103)
* Add timeout support for jellyfin requests

Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:57:44 -07:00
Luigi311
b53d7c9ecc Add docker compose to types
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
116d50a75a Add max_threads
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
e1fb365096 Update apis
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
03617dacfc Jellyfin: Remove isPlayed, Use get for name
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
e6b33f1bc9 Jellyfin: Fix locations logic
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
d9e6a554f6 Disable fast fail
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
7ef37fe848 Jellyfin: Check for provider_source in episode
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
dd64617cbd Jellyfin: Handle missing paths
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Lai Jiang
a227c01a7f Fix a type 2024-01-05 14:55:56 -07:00
Luigi311
da53609385 Jellyfin: Remove reassigning jellyfin_episode_id
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
Luigi311
e94a8fb2c3 Jellyfin: Fix errors with missing matches
Signed-off-by: Luigi311 <git@luigi311.com>
2024-01-05 14:55:56 -07:00
dependabot[bot]
d87542ab78 Bump requests from 2.28.2 to 2.31.0
Bumps [requests](https://github.com/psf/requests) from 2.28.2 to 2.31.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.28.2...v2.31.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-05 14:55:56 -07:00
Luigi311
945ffb2fb3 Plex: Cleanup username_title 2024-01-05 14:55:56 -07:00
Luigi311
da808ba25e CI: Add back in dev based on alpine 2024-01-05 14:55:56 -07:00
Luigi311
e4b4c7ba39 plex: Fix username/title 2024-01-05 14:55:56 -07:00
Luigi311
43ead4bb0f Plex: Fix username/title selection 2024-01-05 14:55:56 -07:00
Luigi311
c4a2f8af39 Users: Default to username and fall back to title 2024-01-05 14:55:56 -07:00
Luigi311
fd281a50b6 Log both servers users instead of exiting immediately 2024-01-05 14:55:56 -07:00
Luigi311
f8ef4fe6c9 Add docker-compose file 2024-01-05 14:55:56 -07:00
Luigi311
faef0ae246 Merge pull request #125 from luigi311/dev
Use season number instead of season name
2023-12-10 21:37:59 -07:00
Luigi311
117932e272 Use season number instead of season name
Using season name is not reliable as it can vary between servers
and can be overridden by the user.

Signed-off-by: Luigi311 <git@luigi311.com>
2023-12-10 10:41:59 -07:00
Luigi311
4297708d3e Merge pull request #124 from luigi311/dev
Dev
2023-12-10 09:56:19 -07:00
Luigi311
2d00d8cb3e README: Add troubleshooting/Issues
Signed-off-by: Luigi311 <git@luigi311.com>
2023-12-10 09:51:47 -07:00
Luigi311
0190788658 Plex: Use updateTimeline instead of updateProgress
Not all accounts have access to updateProgress, so use updateTimeline instead

Signed-off-by: Luigi311 <git@luigi311.com>
2023-12-10 09:38:19 -07:00
Luigi311
b46d4a7166 Show average time on exit
Signed-off-by: Luigi311 <git@luigi311.com>
2023-12-10 09:38:19 -07:00
Luigi311
994d529f59 Action: Add test
Spins up jellyfin and plex containers to test against
2023-12-10 09:38:19 -07:00
Luigi311
7f347ae186 Merge pull request #120 from luigi311/dev
Dev
2023-12-06 14:25:35 -07:00
Luigi311
4a4c9f9ccf Update to python 12 2023-12-06 14:16:47 -07:00
Luigi311
cd4ce186ca Merge pull request #119 from luigi311/CI-Testing
Action: Add test
2023-12-06 14:14:27 -07:00
Luigi311
ca5403f97b Action: Add test
Spins up jellyfin and plex containers to test against
2023-12-06 14:11:50 -07:00
Luigi311
7bb76f62a5 Merge pull request #117 from luigi311/dependabot/pip/aiohttp-3.9.0
Bump aiohttp from 3.8.6 to 3.9.0
2023-12-06 12:07:40 -07:00
Luigi311
dcdbe44648 Action: Limit ghcr push to luigi311
Signed-off-by: Luigi311 <git@luigi311.com>
2023-12-06 12:04:32 -07:00
dependabot[bot]
f91005f0ba Bump aiohttp from 3.8.6 to 3.9.0
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.8.6 to 3.9.0.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.8.6...v3.9.0)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-28 01:04:37 +00:00
Luigi311
5baea978ab Merge pull request #114 from luigi311/dev
Add markfile, Fix documentation, Add default variant, Non-root docker container
2023-11-18 04:05:28 -07:00
Luigi311
9cc1f96eea Merge pull request #113 from luigi311/user
User
2023-11-13 03:51:27 -07:00
Luigi311
2a65c4b5ca Action: Add default variant 2023-11-13 03:48:05 -07:00
Luigi311
e1ef6615cc README: Change configuration to point to .env.sample 2023-11-13 03:39:29 -07:00
Luigi311
d607c9c821 Use non root for containers 2023-11-13 03:36:10 -07:00
Luigi311
f6b2186824 Docker-compose: Add markfile. Add user mapping ex 2023-11-13 02:49:14 -07:00
Luigi311
a3fc53059c MARKFILE match LOGFILE 2023-11-13 02:30:11 -07:00
Luigi311
6afe123947 Docker: Add RUN_ONLY_ONCE and MARKFILE 2023-11-13 02:28:40 -07:00
Luigi311
7e9c6bb338 Add unraid to type 2023-11-13 02:05:47 -07:00
Luigi311
89a2768fc9 Jellyfin: Remove headers append 2023-11-13 01:59:18 -07:00
Luigi311
9ff3bdf302 Add MARK/DRYRUN logger levels 2023-11-13 01:48:07 -07:00
Luigi311
2c48e89435 Add mark list support 2023-11-13 01:12:08 -07:00
Luigi311
6ccb68aeb3 Add example baseurl/token to docker-compose 2023-11-13 01:09:11 -07:00
Luigi311
032243de0a Pin to 3.11 due to 3.12 issues 2023-11-13 00:16:44 -07:00
Luigi311
5b1b9ec222 Merge pull request #112 from neofright/dev
Typos / formatting
2023-11-03 19:57:12 -06:00
neofright
375c6b23a5 Update README.md
Remove another unnecessary captialisation.
2023-11-03 13:21:41 +00:00
neofright
b378dff0dc Improve README.md
- Inprogress is not a word in English, but two separate words.
- Many words are unnecessarily captialised as they are not names or at the beginning of the sentences.
- Prefer 'usernames' to 'usersnames'
2023-11-03 13:21:07 +00:00
neofright
23f2d287d6 Typo in .env.sample 2023-11-03 13:14:46 +00:00
Luigi311
3cd73e54a1 Merge pull request #109 from luigi311/dev
Update Deps, Add max_threads
2023-09-28 20:12:43 -06:00
Luigi311
bf5d875079 Print server info
Signed-off-by: Luigi311 <git@luigi311.com>
2023-09-28 20:00:47 -06:00
Luigi311
aef884523b Merge branch 'main' into dev 2023-09-28 19:24:04 -06:00
Luigi311
2a59f38faf Add docker compose to types
Signed-off-by: Luigi311 <git@luigi311.com>
2023-09-28 10:45:02 -06:00
Luigi311
3a0e60c772 Add max_threads
Signed-off-by: Luigi311 <git@luigi311.com>
2023-09-28 10:00:07 -06:00
Luigi311
fb657d41db Update apis
Signed-off-by: Luigi311 <git@luigi311.com>
2023-09-28 09:47:34 -06:00
Luigi311
ac7f389563 Timeout issues (#103)
* Add timeout support for jellyfin requests

Signed-off-by: Luigi311 <git@luigi311.com>
2023-09-25 01:59:16 -06:00
Luigi311
237e82eceb Merge pull request #96 from luigi311/dev
Jellyfin: Remove isPlayed, Use get for name
2023-08-16 19:16:35 -06:00
Luigi311
8fab4304a4 Jellyfin: Remove isPlayed, Use get for name
Signed-off-by: Luigi311 <git@luigi311.com>
2023-08-16 19:00:17 -06:00
Luigi311
971c9e9147 Merge pull request #94 from luigi311/dependabot/pip/aiohttp-3.8.5
Bump aiohttp from 3.8.4 to 3.8.5
2023-07-24 15:09:10 -06:00
dependabot[bot]
cacbca5a07 Bump aiohttp from 3.8.4 to 3.8.5
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.8.4 to 3.8.5.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/v3.8.5/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.8.4...v3.8.5)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-20 16:25:18 +00:00
Luigi311
e4dbd8adfb Merge pull request #93 from luigi311/dev 2023-07-19 12:07:40 -06:00
Luigi311
19f77c89e7 Jellyfin: Fix locations logic
Signed-off-by: Luigi311 <git@luigi311.com>
2023-07-18 16:27:13 -06:00
Luigi311
ce1b922f71 Merge pull request #90 from luigi311/dev
Fix missing paths and providers
2023-06-30 19:41:16 -06:00
Luigi311
81e967864d Disable fast fail
Signed-off-by: Luigi311 <git@luigi311.com>
2023-06-28 16:55:56 -06:00
Luigi311
29f55104bc Jellyfin: Check for provider_source in episode
Signed-off-by: Luigi311 <git@luigi311.com>
2023-06-28 16:52:23 -06:00
Luigi311
ff2e2deb20 Jellyfin: Handle missing paths
Signed-off-by: Luigi311 <git@luigi311.com>
2023-06-28 16:21:07 -06:00
Luigi311
3fa55cb41b Merge pull request #80 from jianglai/patch-1 2023-06-08 13:41:24 -06:00
Lai Jiang
aa5d97a0d5 Fix a type 2023-05-29 21:08:12 -04:00
Luigi311
89c4f15ae8 Merge pull request #79 from luigi311/dev
Jellyfin: Fix errors with missing matches
2023-05-23 15:39:29 -06:00
Luigi311
1351bfc1cf Jellyfin: Remove reassigning jellyfin_episode_id
Signed-off-by: Luigi311 <git@luigi311.com>
2023-05-23 14:33:42 -06:00
Luigi311
32cc76f043 Merge pull request #78 from luigi311/dependabot/pip/requests-2.31.0
Bump requests from 2.28.2 to 2.31.0
2023-05-23 14:26:43 -06:00
dependabot[bot]
968cb2091d Bump requests from 2.28.2 to 2.31.0
Bumps [requests](https://github.com/psf/requests) from 2.28.2 to 2.31.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.28.2...v2.31.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-23 06:45:31 +00:00
Luigi311
8986c1037b Jellyfin: Fix errors with missing matches
Signed-off-by: Luigi311 <git@luigi311.com>
2023-05-22 01:22:34 -06:00
Luigi311
87b4a950f1 Merge pull request #75 from luigi311/dev
Variants, Pin versions, CI, Plex usernames
2023-05-17 13:38:25 -06:00
Luigi311
9f61c7338d Plex: Cleanup username_title 2023-05-17 13:22:00 -06:00
Luigi311
ffc81dad69 CI: Add back in dev based on alpine 2023-05-15 15:12:25 -06:00
Luigi311
7eba46b5cb plex: Fix username/title 2023-05-15 14:57:46 -06:00
Luigi311
aa177666a5 Plex: Fix username/title selection 2023-05-15 11:17:28 -06:00
Luigi311
7de7b42fd2 Users: Default to username and fall back to title 2023-05-15 11:10:03 -06:00
Luigi311
03d1fd8019 Log both servers users instead of exiting immediately 2023-05-15 10:44:30 -06:00
Luigi311
485ec5fe2d Add docker-compose file 2023-04-29 20:31:24 -06:00
Luigi311
59bfbd9811 Merge pull request #71 from luigi311/fix-docker-build/push
Do not publish on PR, fix condition check on build
2023-04-13 13:02:55 -06:00
Luigi311
1e485b37f8 Do not publish on PR, fix condition check on build 2023-04-13 12:56:52 -06:00
Luigi311
4adf94f24b Update ci.yml
Action: Use github.repository and github.actor instead
2023-04-13 10:28:01 -06:00
Luigi311
1a0fab36d3 Merge pull request #66 from Nicba1010/main
General build improvements
2023-04-13 09:50:59 -06:00
Roberto Banić
a1ef3b5a8d Add conditional to DockerHub login 2023-04-13 16:45:05 +02:00
Luigi311
0c47ee7119 Merge pull request #68 from Nicba1010/refactor-black-white
Refactor black/whitelist processing
2023-04-13 08:37:38 -06:00
Roberto Banić
e51cf6e482 Refactor black/whitelist processing 2023-04-13 12:56:28 +02:00
Roberto Banić
24d5de813d Remove DOCKER_USERNAME environment variable from docker_meta step 2023-04-13 11:23:32 +02:00
Roberto Banić
9921b2a355 Change is_default_branch to other default branch check 2023-04-13 11:21:28 +02:00
Roberto Banić
faa378c75e Add is_default_branch conditional to latest tag 2023-04-13 11:20:19 +02:00
Roberto Banić
26199100dc Update tags 2023-04-13 11:19:56 +02:00
Roberto Banić
bee854f059 Exclude DockerHub in case there is no username set 2023-04-13 10:48:03 +02:00
Roberto Banić
73c1ebf3ed Pin pytest version 2023-04-13 02:26:12 +02:00
Roberto Banić
397dd17429 Specify Python version 2023-04-13 02:26:11 +02:00
Roberto Banić
73d18dad92 Rename Dockerfile to Dockerfile.alpine 2023-04-13 02:26:10 +02:00
Roberto Banić
94d63a3fdb Add ghcr.io image name to the docker metadata action step 2023-04-13 02:26:09 +02:00
Roberto Banić
120d89e8be Add dashes to tags 2023-04-13 02:26:08 +02:00
Roberto Banić
eb5534c61c Add ghcr.io registry 2023-04-13 02:26:07 +02:00
Roberto Banić
99d217e8f1 Update ci.yml to perform a multi-variant build 2023-04-13 02:26:05 +02:00
Roberto Banić
f7e3f8ae2a Update Dockerfile to use the alpine Python 3 base image 2023-04-13 02:26:04 +02:00
Roberto Banić
2cebd2d73d Pin dependency versions to enable reproducible builds 2023-04-13 02:25:13 +02:00
Luigi311
18df322c41 Merge pull request #65 from luigi311/dev
Dev
2023-04-11 09:29:08 -06:00
Luigi311
fc80f50560 Fix codeql issues 2023-04-11 08:57:49 -06:00
Luigi311
4870ff9e7a Cleanup 2023-04-11 08:48:30 -06:00
Luigi311
58337bd38c Test: Use is None 2023-04-10 23:05:22 -06:00
Luigi311
e6d1e0933a Merge pull request #64 from luigi311/fix_indexing
Fix indexing with check_remove_entry
2023-04-10 17:20:36 -06:00
Luigi311
68e3f25ba4 Fix indexing 2023-04-10 16:59:54 -06:00
Luigi311
c981426db6 Merge pull request #62 from agustinmorantes/dev
Add "RUN_ONLY_ONCE" option
2023-04-10 11:54:17 -06:00
Agustín Morantes
916b16b12c Add "RUN_ONLY_ONCE" option 2023-04-10 14:39:28 -03:00
Luigi311
a178d230de Jellfyfin: Fix more issues with ids 2023-04-07 17:31:25 -06:00
Luigi311
fffb04728a Jellfyin: Fix issue with ids. Do not show marked for partial 2023-04-07 15:17:00 -06:00
Luigi311
658361383a Update README.md 2023-04-07 13:41:39 -06:00
Luigi311
3330026de6 Merge pull request #57 from luigi311/partial_watch
Partially implement in progress syncing
2023-03-31 12:14:53 -06:00
Luigi311
25fe426720 Plex: Implement partial play syncing 2023-03-26 23:55:56 -06:00
Luigi311
8d53b5b8c0 Take into account comparing two partially watched/one watched video 2023-03-23 22:50:13 -06:00
Luigi311
0774735f0f Plex: Add title to episode_guids 2023-03-23 22:49:14 -06:00
Luigi311
a5540b94d5 Gather partially watched movie/episodes with todo for processing. 2023-03-22 19:48:19 -06:00
Luigi311
c69d59858d Merge pull request #54 from luigi311/dev
Fix variable overwrites, Fix errors when plex user has no access
2023-03-22 11:29:36 -06:00
Luigi311
962b1149ad Plex: Use token, Check for token on mark 2023-03-18 12:15:59 -06:00
Luigi311
a8edee0354 Jellyfin: Fix user_watched_temp overwrite issues. 2023-03-18 12:12:12 -06:00
Luigi311
3627dde64d Plex: Do not error if user has no access 2023-03-18 11:56:56 -06:00
Luigi311
80ec0e42c2 Dockerfile: Add sync directions to ENV 2023-03-16 14:57:57 -06:00
Luigi311
fd64088bde Merge pull request #51 from luigi311/dev
Add sync direction flags, seperate out functions, better logging for jellyfin queries
2023-03-09 12:52:24 -07:00
Luigi311
7832e41a3b Add sync from to to readme 2023-03-09 01:32:27 -07:00
Luigi311
cadd65d69b Update issue templates (#50)
* Update issue templates
2023-03-09 01:29:11 -07:00
Luigi311
9f004797fc Force format on save in vscode 2023-03-09 00:53:07 -07:00
Luigi311
9041fee7ad Format 2023-03-09 00:48:29 -07:00
Luigi311
9af6c9057c Simplify plex update_user_watched 2023-03-09 00:36:55 -07:00
Luigi311
757ce91138 Merge pull request #49 from luigi311/seperate_functions
Seperate functions
2023-03-08 23:55:53 -07:00
Luigi311
98f96ed5c7 Fix user being added when shouldnt. Add test_users 2023-03-08 23:48:54 -07:00
Luigi311
3e15120e2a Fix library whitelist, add library tests 2023-03-08 23:17:54 -07:00
Luigi311
5824e6c0cc cleanup 2023-03-08 22:21:40 -07:00
Luigi311
7087d75efb Fix exception 2023-03-08 22:15:03 -07:00
Luigi311
b2a06b8fd3 Add tests for black_white and watched 2023-03-08 22:05:32 -07:00
Luigi311
1ee055faf5 format 2023-03-08 22:05:32 -07:00
Luigi311
404089dfca Seperate generate_library_guids_dict 2023-03-08 22:05:32 -07:00
Luigi311
ed24948dee Better logging on library skip 2023-03-08 22:05:32 -07:00
Luigi311
1f16fcb8eb Seperate check_skip_logic, append reasons 2023-03-08 22:05:32 -07:00
Luigi311
03de3affd7 Cleanup, seperate black/white lists setup 2023-03-08 22:05:32 -07:00
Luigi311
2bad887659 Seperate out functions to seperate scripts. 2023-03-08 22:04:48 -07:00
Luigi311
796be47a63 Move lots of setup_users to functions 2023-03-08 22:03:48 -07:00
Luigi311
dc1fe11590 Check for response status 200 on jellyfin query 2023-03-08 21:49:56 -07:00
Luigi311
13b4ff3215 Merge pull request #48 from JChris246/main
[Feature] Add flags to control the direction of syncing between the servers
2023-03-08 20:46:57 -07:00
JChris246
dca54cf4fb feat:add flags to control the direction of syncing 2023-03-08 21:30:28 -04:00
Luigi311
a4365e59f3 Merge pull request #44 from luigi311/dev
Fix issues with certain libraries failing
2023-02-26 13:32:26 -07:00
Luigi311
b960bccb86 Plex: Fix guids error on mark 2023-02-25 18:42:07 -07:00
Luigi311
218037200c Jellyfin: Fix tv show searching for watched 2023-02-25 18:27:01 -07:00
Luigi311
4ac670e837 Plex: Do not error if guids can not be gathered. Parallelize show processing for get watched. 2023-02-25 16:58:57 -07:00
Luigi311
96eff65c3e Do not error if failed to get library watched 2023-02-25 15:03:27 -07:00
Luigi311
45471607c8 Merge pull request #43 from JChris246/chore/spelling
Correct some spelling issues
2023-02-22 09:51:42 -07:00
JChris246
14885744b1 fix: correct some spelling issues 2023-02-22 00:09:30 -04:00
Luigi311
d1fd61f1d1 Merge pull request #38 from luigi311/dev
Fix issue with nested folders
2023-01-31 16:27:54 -07:00
Luigi311
6c1ee4a7dc Log server users 2023-01-30 11:56:27 -07:00
Luigi311
9a8e799e68 Recursive all the things. Use includeItemType 2023-01-30 11:46:12 -07:00
Luigi311
ffec4e2f28 Support multiple library types 2023-01-28 16:33:36 -07:00
Luigi311
00102891a5 Catch None for types 2023-01-27 23:45:03 -07:00
Luigi311
aa76b83428 Use isinstance instead of type 2023-01-27 12:21:38 -07:00
Luigi311
a644189ea5 Use isinstance instead of type 2023-01-27 12:18:15 -07:00
Luigi311
c5d987a8c9 Update .env.sample and README 2023-01-27 11:23:58 -07:00
Luigi311
bdd68ad68d If user is type str get plex object 2023-01-27 11:02:15 -07:00
Luigi311
2d86bca781 Update github actions 2023-01-27 10:48:52 -07:00
Luigi311
1b01ff6ec2 Log if multiple types and continue instead of error 2023-01-27 10:45:46 -07:00
Luigi311
f08ec43507 Skip library before erroring for multiple types. 2023-01-27 10:43:50 -07:00
Luigi311
7f9424260a Format 2023-01-26 14:03:13 -07:00
Luigi311
5f21943353 Exclude folders, use recursive. 2023-01-26 13:55:50 -07:00
Luigi311
a5a795f43c Exclude Folders from list 2023-01-26 13:42:35 -07:00
Luigi311
fcb6d7625f Fix invalid library types, raise mixed types 2023-01-26 13:31:57 -07:00
Luigi311
fd2179998f Fix ssl_bypass for plex 2023-01-26 11:23:47 -07:00
Luigi311
654e7f20e1 Merge pull request #33 from luigi311/dev
Lots of fixes and simplification
2022-12-23 23:13:22 -07:00
Luigi311
1eb92cf7c1 black formatting 2022-12-23 23:11:38 -07:00
Luigi311
111e284cc8 Cleanup 2022-12-23 23:10:51 -07:00
Luigi311
1a4e3f4ec4 Move setup_black_white_list to functions. Fix trailing slash on jellyfin baseurl 2022-12-23 23:02:53 -07:00
Luigi311
4066228e57 Add more debug logging. Do not enable debug by default 2022-12-19 14:07:56 -07:00
Luigi311
59c6d278e3 Add more logging to debug 2022-12-19 13:57:20 -07:00
Luigi311
39b33f3d43 Fix missing logging when using debug level 2022-12-19 13:22:42 -07:00
Luigi311
e8faf52b2b Do not mark shows/movies that do not exist 2022-12-19 01:35:16 -07:00
Luigi311
370e9bac63 change get user watched name to avoid mistakes 2022-12-18 22:39:03 -07:00
Luigi311
d0746cec5a Fix server 2 always running async runner. Speedup plex get watched 2022-12-18 22:27:42 -07:00
Luigi311
251937431b Move cleanup_watched to functions and simplify 2022-12-18 01:50:45 -07:00
Luigi311
50faf061af Remove dockerfile defaults 2022-11-21 18:21:29 -07:00
Luigi311
9ffbc49ad3 Merge pull request #30 from luigi311/dev
Add ssl_bypass to skip hostname validation.
2022-11-21 17:39:00 -07:00
Luigi311
644dc8e3af Merge pull request #29 from lgtm-migrator/codeql
Add CodeQL workflow for GitHub code scanning
2022-11-21 17:38:45 -07:00
Luigi311
47bc4e94dc Fix dockerfile 2022-11-21 17:31:47 -07:00
LGTM Migrator
f17d39fe17 Add CodeQL workflow for GitHub code scanning 2022-11-10 14:41:07 +00:00
Luigi311
966dcacf8d Add ssl_bypass to skip hostname validation. 2022-09-25 14:16:01 -06:00
Luigi311
9afc00443c Merge pull request #27 from luigi311/dev
Cleanup issues
2022-08-18 00:46:00 -06:00
Luigi311
3ec177ea64 rename test_main 2022-08-18 00:17:32 -06:00
Luigi311
b360c9fd0b Remove unnecessary deepcopy 2022-08-18 00:15:42 -06:00
Luigi311
1ed791b1ed Fix jellyfin 2022-08-17 23:49:05 -06:00
Luigi311
f19b1a3063 Cleanup length and functions instead of methods 2022-08-17 23:34:45 -06:00
Luigi311
190a72bd3c Cleanup 2022-08-17 22:53:27 -06:00
Luigi311
c848106ce7 Black cleanup 2022-08-17 22:31:23 -06:00
Luigi311
dd319271bd Cleanup 2022-08-17 22:09:11 -06:00
Luigi311
16879cc728 Merge pull request #26 from luigi311/dev
Use async for jellyfin
2022-08-17 21:49:34 -06:00
Luigi311
942ec3533f Cleanup log file on runs 2022-08-17 21:43:51 -06:00
Luigi311
9f6edfc91a Merge branch 'main' into dev 2022-08-17 21:40:25 -06:00
Luigi311
827ace2e97 cleanup 2022-08-17 21:20:28 -06:00
Luigi311
f6b57a1b4d Update README.md 2022-07-10 01:38:42 -06:00
Luigi311
88a7526721 Use async for jellyfin (#23)
* Use async

* Massive jellyfin watched speedup

Co-authored-by: Luigi311 <luigi311.lg@gmail.com>
2022-07-10 01:30:12 -06:00
luigi311
1efb4d8543 Fix debug 2022-07-06 17:22:35 -06:00
Luigi311
7571e9a343 Merge pull request #22 from luigi311/dev
Fix errors on certain edge cases
2022-07-05 21:23:14 -06:00
Luigi311
7640e9ee03 fix typo 2022-07-05 19:26:58 -06:00
Luigi311
50ed3d6400 Fix user_name in plex 2022-07-05 19:26:22 -06:00
Luigi311
c9a373851f Remove indexnumber from logging 2022-07-05 19:16:25 -06:00
Luigi311
a3f3db8f4e Use generate_library_guids_dict instead of library type 2022-07-05 18:09:08 -06:00
Luigi311
de619de923 Add more logging, fix username in jellyfin mark. 2022-07-05 16:35:22 -06:00
47 changed files with 6383 additions and 2447 deletions

View File

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

View File

@@ -1,42 +1,116 @@
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "True"
DEBUG = "False"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "False"
## How often to run the script in seconds
SLEEP_DURATION = "3600"
## Log file where all output will be written to
LOGFILE = "log.log"
## Map usernames between 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" }
## Mark file where all shows/movies that have been marked as played will be written to
MARK_FILE = "mark.log"
## 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"
## Timeout for requests for jellyfin
REQUEST_TIMEOUT = 300
## Generate guids
## Generating guids is a slow process, so this is a way to speed up the process
## by using the location only, useful when using same files on multiple servers
GENERATE_GUIDS = "True"
## 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"
## Generate locations
## Generating locations is a slow process, so this is a way to speed up the process
## by using the guid only, useful when using different files on multiple servers
GENERATE_LOCATIONS = "True"
## Max threads for processing
MAX_THREADS = 2
## Map usernames between servers in the event that they are different, order does not matter
## Comma separated for multiple options
#USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" }
## 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 = ""
#BLACKLIST_LIBRARY_TYPE = ""
#WHITELIST_LIBRARY_TYPE = ""
#BLACKLIST_USERS = ""
WHITELIST_USERS = "testuser1,testuser2"
# Plex
## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers
## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly
## Comma separated list for multiple servers
PLEX_BASEURL = "http://localhost:32400, https://nas:32400"
## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
## Comma separated list for multiple servers
PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2"
## If not using plex token then use username and password of the server admin along with the servername
## Comma separated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "False"
# 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 = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

15
.github/FUNDING.yml vendored Normal file
View 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
View 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.

View 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.

View File

@@ -1,86 +1,220 @@
name: CI
on:
push:
paths-ignore:
- .gitignore
- "*.md"
pull_request:
paths-ignore:
- .gitignore
- "*.md"
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: "Install dependencies"
run: pip install -r requirements.txt && pip install -r test/requirements.txt
- name: "Run tests"
run: pytest -vvv
docker:
runs-on: ubuntu-latest
needs: pytest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Docker meta
id: docker_meta
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
if: "${{ env.DOCKER_USERNAME != '' }}"
uses: docker/metadata-action@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
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build
id: build
if: "${{ steps.docker_meta.outcome == 'skipped' }}"
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: false
tags: jellyplex-watched:action
- name: Build Push
id: build_push
if: "${{ steps.docker_meta.outcome == 'success' }}"
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
# Echo digest so users can validate their image
- name: Image digest
if: "${{ steps.docker_meta.outcome == 'success' }}"
run: echo "${{ steps.build_push.outputs.digest }}"
name: CI
on:
workflow_dispatch:
push:
paths-ignore:
- .gitignore
- "*.md"
pull_request:
paths-ignore:
- .gitignore
- "*.md"
env:
PYTHON_VERSION: '3.13'
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: "Install dependencies"
run: uv sync --all-extras --dev
- name: "Run tests"
run: uv run pytest -vvv
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: "Install dependencies"
run: |
uv sync --all-extras --dev
sudo apt update && sudo apt install -y docker-compose
- name: "Checkout JellyPlex-Watched-CI"
uses: actions/checkout@v4
with:
repository: luigi311/JellyPlex-Watched-CI
path: JellyPlex-Watched-CI
- name: "Start containers"
run: |
JellyPlex-Watched-CI/start_containers.sh
# Wait for containers to start
sleep 10
for FOLDER in $(find "JellyPlex-Watched-CI" -type f -name "docker-compose.yml" -exec dirname {} \;); do
docker compose -f "${FOLDER}/docker-compose.yml" logs
done
- name: "Test Plex"
run: |
mv test/ci_plex.env .env
uv run main.py
uv run test/validate_ci_marklog.py --plex
rm mark.log
- name: "Test Jellyfin"
run: |
mv test/ci_jellyfin.env .env
uv run main.py
uv run test/validate_ci_marklog.py --jellyfin
rm mark.log
- name: "Test Emby"
run: |
mv test/ci_emby.env .env
uv run main.py
uv run test/validate_ci_marklog.py --emby
rm mark.log
- name: "Test Guids"
run: |
mv test/ci_guids.env .env
uv run main.py
uv run test/validate_ci_marklog.py --guids
rm mark.log
- name: "Test Locations"
run: |
mv test/ci_locations.env .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
mv test/ci_write.env .env
uv run main.py
# Test again to test if it can handle existing data
uv run main.py
uv run test/validate_ci_marklog.py --write
rm mark.log
docker:
runs-on: ubuntu-latest
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@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/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registry
if: "${{ steps.docker_meta.outcome == 'success' }}"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
id: build
if: "${{ steps.docker_meta.outputs.tags == '' }}"
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: false
tags: jellyplex-watched:action
- name: Build Push
id: build_push
if: "${{ steps.docker_meta.outputs.tags != '' }}"
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
# Echo digest so users can validate their image
- name: Image digest
if: "${{ steps.docker_meta.outcome == 'success' }}"
run: echo "${{ steps.build_push.outputs.digest }}"

41
.github/workflows/codeql.yml vendored Normal file
View 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 }}"

261
.gitignore vendored
View File

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

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

43
.vscode/launch.json vendored
View File

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

@@ -0,0 +1,7 @@
{
"[python]" : {
"editor.formatOnSave": true,
},
"python.formatting.provider": "black",
}

View File

@@ -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
View 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
View 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"]

1348
LICENSE

File diff suppressed because it is too large Load Diff

104
README.md
View File

@@ -1,72 +1,108 @@
# JellyPlex-Watched
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=luigi311/JellyPlex-Watched&amp;utm_campaign=Badge_Grade)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&utm_medium=referral&utm_content=luigi311/JellyPlex-Watched&utm_campaign=Badge_Grade)
Sync watched between jellyfin and plex
Sync watched between jellyfin, plex and emby locally
## 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.
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
- Setup virtualenv of your choice
- [Install uv](https://docs.astral.sh/uv/getting-started/installation/)
- Install dependencies
- 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.)
```bash
pip install -r requirements.txt
```
- Run
- Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens
- Run
```bash
python main.py
```
```bash
uv run main.py
```
### Docker
- Build docker image
- Build docker image
```bash
docker build -t jellyplex-watched .
```
```bash
docker build -t jellyplex-watched .
```
- or use pre-built image
- or use pre-built image
```bash
docker pull luigi311/jellyplex-watched:latest
```
```bash
docker pull luigi311/jellyplex-watched:latest
```
#### With variables
- Run
- Run
```bash
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
```
```bash
docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest
```
#### With .env
- Create a .env file similar to .env.sample and set the MNEMONIC variable to your seed phrase
- Create a .env file similar to .env.sample and set the variables to match your setup
- Run
- Run
```bash
docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest
```
```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 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.
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

11
docker-compose.yml Normal file
View 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
View 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

21
main.py
View File

@@ -1,10 +1,11 @@
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")
sys.exit(1)
from src.main import main
main()
import sys
if __name__ == "__main__":
# 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
main()

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "jellyplex-watched"
version = "6.1.2"
description = "Sync watched between media servers locally"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"loguru>=0.7.3",
"packaging==24.2",
"plexapi==4.16.1",
"pydantic==2.10.6",
"python-dotenv==1.0.0",
"requests==2.32.3",
]
[dependency-groups]
lint = [
"ruff>=0.9.6",
]
dev = [
"pytest>=8.3.4",
]

View File

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

85
src/black_white.py Normal file
View 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,
):
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

122
src/connection.py Normal file
View File

@@ -0,0 +1,122 @@
import os
from typing import Literal
from dotenv import load_dotenv
from loguru import logger
from src.functions import str_to_bool
from src.plex import Plex
from src.jellyfin import Jellyfin
from src.emby import Emby
load_dotenv(override=True)
def jellyfin_emby_server_connection(
server_baseurl: 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, baseurl in enumerate(server_baseurls):
baseurl = baseurl.strip()
if baseurl[-1] == "/":
baseurl = baseurl[:-1]
if server_type == "jellyfin":
server = Jellyfin(baseurl=baseurl, token=server_tokens[i].strip())
servers.append(server)
elif server_type == "emby":
server = Emby(baseurl=baseurl, 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() -> list[Plex | Jellyfin | Emby]:
servers: list[Plex | Jellyfin | Emby] = []
plex_baseurl_str: str | None = os.getenv("PLEX_BASEURL", None)
plex_token_str: str | None = os.getenv("PLEX_TOKEN", None)
plex_username_str: str | None = os.getenv("PLEX_USERNAME", None)
plex_password_str: str | None = os.getenv("PLEX_PASSWORD", None)
plex_servername_str: str | None = os.getenv("PLEX_SERVERNAME", None)
ssl_bypass = str_to_bool(os.getenv("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(
baseurl=url.strip(),
token=plex_token[i].strip(),
username=None,
password=None,
servername=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(
baseurl=None,
token=None,
username=username.strip(),
password=plex_password[i].strip(),
servername=plex_servername[i].strip(),
ssl_bypass=ssl_bypass,
)
logger.debug(f"Plex Server {i} info: {server.info()}")
servers.append(server)
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None)
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
if jellyfin_baseurl and jellyfin_token:
servers.extend(
jellyfin_emby_server_connection(
jellyfin_baseurl, jellyfin_token, "jellyfin"
)
)
emby_baseurl = os.getenv("EMBY_BASEURL", None)
emby_token = os.getenv("EMBY_TOKEN", None)
if emby_baseurl and emby_token:
servers.extend(
jellyfin_emby_server_connection(emby_baseurl, emby_token, "emby")
)
return servers

25
src/emby.py Normal file
View File

@@ -0,0 +1,25 @@
from src.jellyfin_emby import JellyfinEmby
from packaging.version import parse, Version
class Emby(JellyfinEmby):
def __init__(self, baseurl, token):
authorization = (
"Emby , "
'Client="JellyPlex-Watched", '
'Device="script", '
'DeviceId="script", '
'Version="6.0.2"'
)
headers = {
"Accept": "application/json",
"X-Emby-Token": token,
"X-Emby-Authorization": authorization,
}
super().__init__(
server_type="Emby", baseurl=baseurl, token=token, headers=headers
)
def is_partial_update_supported(self, server_version: Version) -> bool:
return server_version > parse("4.4")

View File

@@ -1,150 +1,118 @@
import os
from concurrent.futures import ThreadPoolExecutor
from dotenv import load_dotenv
load_dotenv(override=True)
logfile = os.getenv("LOGFILE","log.log")
def logger(message: str, log_type=0):
debug = str_to_bool(os.getenv("DEBUG", "True"))
debug_level = os.getenv("DEBUG_LEVEL", "info").lower()
output = str(message)
if log_type == 0:
pass
elif log_type == 1 and (debug or 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}"
else:
output = None
if output is not None:
print(output)
file = open(logfile, "a", encoding="utf-8")
file.write(output + "\n")
# 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:
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):
if key_value in dictionary.keys():
return dictionary[key_value]
elif key_value.lower() in dictionary.keys():
return dictionary[key_value.lower()]
elif key_value in dictionary.values():
return list(dictionary.keys())[list(dictionary.values()).index(key_value)]
elif key_value.lower() in dictionary.values():
return list(dictionary.keys())[list(dictionary.values()).index(key_value.lower())]
else:
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
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
def generate_library_guids_dict(user_list: dict, generate_output: int):
# if generate_output is 0 then only generate shows, if 1 then only generate episodes, if 2 then generate movies, if 3 then generate shows and episodes
show_output_dict = {}
episode_output_dict = {}
movies_output_dict = {}
if generate_output in (0, 3):
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())
if generate_output in (1, 3):
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())
if generate_output == 2:
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())
return show_output_dict, episode_output_dict, movies_output_dict
def future_thread_executor(args: list, workers: int = -1):
futures_list = []
results = []
if workers == -1:
workers = min(32, os.cpu_count()*1.25)
with ThreadPoolExecutor(max_workers=workers) as executor:
for arg in args:
# * arg unpacks the list into actual arguments
futures_list.append(executor.submit(*arg))
for future in futures_list:
try:
result = future.result()
results.append(result)
except Exception as e:
raise Exception(e)
return results
import os
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any, Callable
from dotenv import load_dotenv
load_dotenv(override=True)
mark_file = os.getenv("MARK_FILE", os.getenv("MARKFILE", "mark.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,
):
output = f"{server_type}/{server_name}/{username}/{library}/{movie_show}"
if episode:
output += f"/{episode}"
if duration:
output += f"/{duration}"
with open(f"{mark_file}", "a", encoding="utf-8") as file:
file.write(output + "\n")
# 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: str) -> 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[str, str], key_value: str) -> str | None:
if key_value in dictionary.keys():
return dictionary[key_value]
elif key_value.lower() in dictionary.keys():
return dictionary[key_value.lower()]
elif key_value in dictionary.values():
return list(dictionary.keys())[list(dictionary.values()).index(key_value)]
elif key_value.lower() in dictionary.values():
return list(dictionary.keys())[
list(dictionary.values()).index(key_value.lower())
]
else:
return 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)
return output
def future_thread_executor(
args: list[tuple[Callable[..., Any], ...]],
threads: int | None = None,
override_threads: bool = False,
) -> list[Any]:
results: list[Any] = []
# Determine the number of workers, defaulting to 1 if os.cpu_count() returns None
max_threads_env: int = int(os.getenv("MAX_THREADS", 32))
cpu_threads: int = os.cpu_count() or 1 # Default to 1 if os.cpu_count() is None
workers: int = min(max_threads_env, cpu_threads * 2)
# 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
# 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 out in futures_list:
try:
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

View File

@@ -1,319 +1,25 @@
import requests
from src.functions import logger, search_mapping, str_to_bool, check_skip_logic, generate_library_guids_dict, future_thread_executor
from src.jellyfin_emby import JellyfinEmby
from packaging.version import parse, Version
class Jellyfin():
class Jellyfin(JellyfinEmby):
def __init__(self, baseurl, token):
self.baseurl = baseurl
self.token = token
self.session = requests.Session()
authorization = (
"MediaBrowser , "
'Client="JellyPlex-Watched", '
'Device="script", '
'DeviceId="script", '
'Version="6.0.2", '
f'Token="{token}"'
)
headers = {
"Accept": "application/json",
"Authorization": authorization,
}
if not self.baseurl:
raise Exception("Jellyfin baseurl not set")
super().__init__(
server_type="Jellyfin", baseurl=baseurl, token=token, headers=headers
)
if not self.token:
raise Exception("Jellyfin token not set")
self.users = self.get_users()
def query(self, query, query_type):
try:
response = None
headers = {
"Accept": "application/json",
"X-Emby-Token": self.token
}
if query_type == "get":
response = self.session.get(self.baseurl + query, headers=headers)
elif query_type == "post":
authorization = (
'MediaBrowser , '
'Client="other", '
'Device="script", '
'DeviceId="script", '
'Version="0.0.0"'
)
headers["X-Emby-Authorization"] = authorization
response = self.session.post(self.baseurl + query, headers=headers)
return response.json()
except Exception as e:
logger(f"Jellyfin: Query failed {e}", 2)
raise Exception(e)
def get_users(self):
try:
users = {}
query = "/Users"
response = self.query(query, "get")
# If reponse is not empty
if response:
for user in response:
users[user["Name"]] = user["Id"]
return users
except Exception as e:
logger(f"Jellyfin: Get users failed {e}", 2)
raise Exception(e)
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
if library_type == "Movie":
user_watched[user_name][library_title] = []
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&Fields=ItemCounts,ProviderIds,MediaSources", "get")
for movie in watched["Items"]:
if movie["UserData"]["Played"] == True:
movie_guids = {}
movie_guids["title"] = movie["Name"]
if movie["ProviderIds"]:
# Lowercase movie["ProviderIds"] keys
movie_guids = {k.lower(): v for k, v in movie["ProviderIds"].items()}
if movie["MediaSources"]:
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 == "Episode":
user_watched[user_name][library_title] = {}
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Fields=ItemCounts,ProviderIds,Path", "get")
watched_shows = [x for x in watched["Items"] if x["Type"] == "Series"]
for show in watched_shows:
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())
seasons = self.query(f"/Shows/{show['Id']}/Seasons?userId={user_id}&Fields=ItemCounts,ProviderIds", "get")
if len(seasons["Items"]) > 0:
for season in seasons["Items"]:
episodes = self.query(f"/Shows/{show['Id']}/Episodes?seasonId={season['Id']}&userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", "get")
if len(episodes["Items"]) > 0:
for episode in episodes["Items"]:
if episode["UserData"]["Played"] == True:
if episode["ProviderIds"] or episode["MediaSources"]:
if show_guids not in user_watched[user_name][library_title]:
user_watched[user_name][library_title][show_guids] = {}
if season["Name"] not in user_watched[user_name][library_title][show_guids]:
user_watched[user_name][library_title][show_guids][season["Name"]] = []
# Lowercase episode["ProviderIds"] keys
episode_guids = {}
if episode["ProviderIds"]:
episode_guids = {k.lower(): v for k, v in episode["ProviderIds"].items()}
if episode["MediaSources"]:
episode_guids["locations"] = tuple([x["Path"].split("/")[-1] for x in episode["MediaSources"]])
user_watched[user_name][library_title][show_guids][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)
def get_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping=None):
try:
users_watched = {}
args = []
for user_name, user_id in users.items():
# Get all libraries
user_name = user_name.lower()
libraries = self.query(f"/Users/{user_id}/Views", "get")["Items"]
for library in libraries:
library_title = library["Name"]
library_id = library["Id"]
watched = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&Filters=IsPlayed&limit=1", "get")
if len(watched["Items"]) == 0:
logger(f"Jellyfin: No watched items found in library {library_title}", 1)
continue
else:
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
args.append([self.get_user_watched, user_name, user_id, library_type, library_id, library_title])
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)
return users_watched
except Exception as e:
logger(f"Jellyfin: Failed to get watched, Error: {e}", 2)
raise Exception(e)
def update_user_watched(self, user, user_id, library, library_id, videos, dryrun):
try:
logger(f"Jellyfin: Updating watched for {user} in library {library}", 1)
library_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&ParentId={library_id}&limit=1", "get")
library_type = library_search["Items"][0]["Type"]
# Movies
if library_type == "Movie":
_, _, videos_movies_ids = generate_library_guids_dict(videos, 2)
jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources", "get")
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} in {library} for Jellyfin"
if not dryrun:
logger(f"Marking {msg}", 0)
self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", "post")
else:
logger(f"Dryrun {msg}", 0)
# TV Shows
if library_type == "Episode":
videos_shows_ids, videos_episode_ids, _ = generate_library_guids_dict(videos, 3)
jellyfin_search = self.query(f"/Users/{user_id}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=false&ParentId={library_id}&isPlayed=false&Fields=ItemCounts,ProviderIds,Path", "get")
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for jellyfin_show in jellyfin_shows:
show_found = False
if jellyfin_show["Name"] == "The 13 Ghosts of Scooby-Doo":
print(jellyfin_show)
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:
jellyfin_show_id = jellyfin_show["Id"]
jellyfin_episodes = self.query(f"/Shows/{jellyfin_show_id}/Episodes?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", "get")
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_episode_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_episode_ids:
if episode_provider_id.lower() in videos_episode_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['IndexNumber']} {jellyfin_episode['Name']} as watched for {user} in {library} for Jellyfin"
if not dryrun:
logger(f"Marked {msg}", 0)
self.query(f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", "post")
else:
logger(f"Dryrun {msg}", 0)
except Exception as e:
logger(f"Jellyfin: Error updating watched for {user} in library {library}", 2)
raise Exception(e)
def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False):
try:
args = []
for user, libraries in watched_list.items():
user_other = None
if 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]
break
elif user_other and user_other.lower() == key.lower():
user_id = self.users[key]
break
if not user_id:
logger(f"{user} {user_other} not found in Jellyfin", 2)
continue
jellyfin_libraries = self.query(f"/Users/{user_id}/Views", "get")["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:
args.append([self.update_user_watched, user, user_id, library, library_id, videos, dryrun])
future_thread_executor(args)
except Exception as e:
logger(f"Jellyfin: Error updating watched", 2)
raise Exception(e)
def is_partial_update_supported(self, server_version: Version) -> bool:
return server_version >= parse("10.9.0")

712
src/jellyfin_emby.py Normal file
View File

@@ -0,0 +1,712 @@
# Functions for Jellyfin and Emby
import requests
import traceback
import os
from math import floor
from typing import Any, Literal
from dotenv import load_dotenv
from packaging.version import parse, Version
from loguru import logger
from src.functions import (
search_mapping,
log_marked,
str_to_bool,
)
from src.watched import (
LibraryData,
MediaIdentifiers,
MediaItem,
WatchedStatus,
Series,
UserData,
check_same_identifiers,
)
load_dotenv(override=True)
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
def extract_identifiers_from_item(server_type, item: dict) -> MediaIdentifiers:
title = item.get("Name", None)
id = None
if not title:
id = item.get("Id")
logger.info(f"{server_type}: Name not found in {id}")
guids = {}
if generate_guids:
guids = {k.lower(): v for k, v in item["ProviderIds"].items()}
if not guids:
logger.info(
f"{server_type}: {title if title else id} has no guids",
)
locations = tuple()
if generate_locations:
if "Path" in item:
locations = tuple([item.get("Path").split("/")[-1]])
elif "MediaSources" in item:
locations = tuple(
[x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x]
)
if not locations:
logger.info(f"{server_type}: {title if title else id} has no locations")
return MediaIdentifiers(
title=title,
locations=locations,
imdb_id=guids.get("imdb", None),
tvdb_id=guids.get("tvdb", None),
tmdb_id=guids.get("tmdb", None),
)
def get_mediaitem(server_type, item: dict) -> MediaItem:
return MediaItem(
identifiers=extract_identifiers_from_item(server_type, item),
status=WatchedStatus(
completed=item["UserData"]["Played"],
time=floor(item["UserData"]["PlaybackPositionTicks"] / 10000),
),
)
class JellyfinEmby:
def __init__(
self,
server_type: Literal["Jellyfin", "Emby"],
baseurl: str,
token: str,
headers: dict[str, str],
):
if server_type not in ["Jellyfin", "Emby"]:
raise Exception(f"Server type {server_type} not supported")
self.server_type = server_type
self.baseurl = baseurl
self.token = token
self.headers = headers
self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300))
if not self.baseurl:
raise Exception(f"{self.server_type} baseurl not set")
if not self.token:
raise Exception(f"{self.server_type} token not set")
self.session = requests.Session()
self.users = self.get_users()
self.server_name = self.info(name_only=True)
def query(
self,
query: str,
query_type: Literal["get", "post"],
identifiers: dict[str, str] | None = None,
json: dict[str, float] | None = None,
) -> dict[str, Any] | list[dict[str, Any]] | None:
try:
results: (
dict[str, list[Any] | dict[str, str]] | list[dict[str, Any]] | None
) = None
if query_type == "get":
response = self.session.get(
self.baseurl + query, headers=self.headers, timeout=self.timeout
)
if response.status_code not in [200, 204]:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
if response.status_code == 204:
results = None
else:
results = response.json()
elif query_type == "post":
response = self.session.post(
self.baseurl + query,
headers=self.headers,
json=json,
timeout=self.timeout,
)
if response.status_code not in [200, 204]:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
if response.status_code == 204:
results = None
else:
results = response.json()
if results is not None:
if not isinstance(results, list) and not isinstance(results, dict):
raise Exception("Query result is not of type list or dict")
# append identifiers to results
if identifiers and results:
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: dict[str, Any] = self.query(query_string, "get")
if response:
if name_only:
return response["ServerName"]
elif version_only:
return parse(response["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: list[dict[str, str | bool]] = self.query(query_string, "get")
# If response is not empty
if response:
for user in response:
if isinstance(user["Name"], str) and isinstance(user["Id"], str):
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 = {}
# 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: dict = self.query(f"/Users/{user_id}/Views", "get")
logger.debug(f"{self.server_type}: All Libraries for {user_name} {[library.get("Name") for library in user_libraries["Items"]]}")
for library in user_libraries["Items"]:
library_title = library["Name"]
library_type = library.get("CollectionType")
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, user_id, library_type, library_id, library_title
) -> 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 == "Movie":
watched_items = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
).get("Items", [])
in_progress_items = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
).get("Items", [])
for movie in watched_items + in_progress_items:
# Skip if theres no user data which means the movie has not been watched
if "UserData" not in movie:
continue
# Skip if theres no media tied to the movie
if "MediaSources" not in movie or movie["MediaSources"] == {}:
continue
# Skip if not watched or watched less than a minute
if (
movie["UserData"]["Played"] == True
or movie["UserData"]["PlaybackPositionTicks"] > 600000000
):
watched.movies.append(get_mediaitem(self.server_type, movie))
# TV Shows
if library_type in ["Series", "Episode"]:
# Retrieve a list of watched TV shows
watched_shows = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
"get",
).get("Items", [])
# Filter the list of shows to only include those that have been partially or fully watched
watched_shows_filtered = []
for show in watched_shows:
if "UserData" not in show:
continue
if "PlayedPercentage" in show["UserData"]:
if show["UserData"]["PlayedPercentage"] > 0:
watched_shows_filtered.append(show)
# Retrieve the watched/partially watched list of episodes of each watched show
for show in watched_shows_filtered:
show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()}
show_locations = (
tuple([show["Path"].split("/")[-1]])
if "Path" in show
else tuple()
)
show_episodes = self.query(
f"/Shows/{show['Id']}/Episodes"
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources",
"get",
).get("Items", [])
# Iterate through the episodes
# Create a list to store the episodes
episode_mediaitem = []
for episode in show_episodes:
if "UserData" not in episode:
continue
if (
"MediaSources" not in episode
or episode["MediaSources"] == {}
):
continue
# If watched or watched more than a minute
if (
episode["UserData"]["Played"] == True
or episode["UserData"]["PlaybackPositionTicks"] > 600000000
):
episode_mediaitem.append(
get_mediaitem(self.server_type, episode)
)
if episode_mediaitem:
watched.series.append(
Series(
identifiers=MediaIdentifiers(
title=show.get("Name"),
locations=show_locations,
imdb_id=show_guids.get("imdb", None),
tvdb_id=show_guids.get("tvdb", None),
tmdb_id=show_guids.get("tmdb", None),
),
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 {}
def get_watched(
self, users: dict[str, str], sync_libraries: list[str]
) -> dict[str, UserData]:
try:
users_watched: dict[str, UserData] = {}
for user_name, user_id in users.items():
libraries = []
all_libraries = self.query(f"/Users/{user_id}/Views", "get")
for library in all_libraries["Items"]:
library_id = library["Id"]
library_title = library["Name"]
if library_title not in sync_libraries:
continue
identifiers: dict[str, str] = {
"library_id": library_id,
"library_title": library_title,
}
libraries.append(
self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
"get",
identifiers=identifiers,
)
)
for library in libraries:
if len(library["Items"]) == 0:
continue
library_id: str = library["Identifiers"]["library_id"]
library_title: str = library["Identifiers"]["library_title"]
# Get all library types excluding "Folder"
types = set(
[
x["Type"]
for x in library["Items"]
if x["Type"] in ["Movie", "Series", "Episode"]
]
)
for library_type in types:
# Get watched for user
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}")
raise Exception(e)
def update_user_watched(
self,
user_name: str,
user_id: str,
library_data: LibraryData,
library_name: str,
library_id: str,
update_partial: bool,
dryrun: bool,
):
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",
)
for jellyfin_video in jellyfin_search["Items"]:
jelly_identifiers = extract_identifiers_from_item(
self.server_type, jellyfin_video
)
# 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["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"),
)
elif 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 = {
"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),
)
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",
)
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for jellyfin_show in jellyfin_shows:
jellyfin_show_identifiers = extract_identifiers_from_item(
self.server_type, jellyfin_show
)
# 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.info(
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["Id"]
jellyfin_episodes = self.query(
f"/Shows/{jellyfin_show_id}/Episodes"
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
for jellyfin_episode in jellyfin_episodes["Items"]:
jellyfin_episode_identifiers = (
extract_identifiers_from_item(
self.server_type, jellyfin_episode
)
)
for stored_ep in stored_series.episodes:
if check_same_identifiers(
jellyfin_episode_identifiers,
stored_ep.identifiers,
):
jellyfin_episode_id = jellyfin_episode["Id"]
if stored_ep.status.completed:
msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as watched for {user_name} in {library_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"),
)
elif update_partial:
msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as partially watched for {floor(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
),
)
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}",
)
logger.error(traceback.format_exc())
raise Exception(e)
def update_watched(
self,
watched_list: dict[str, UserData],
user_mapping=None,
library_mapping=None,
dryrun=False,
):
try:
server_version = self.info(version_only=True)
update_partial = self.is_partial_update_supported(server_version)
if not update_partial:
logger.info(
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
)
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:
logger.info(f"{user} {user_other} not found in Jellyfin")
continue
jellyfin_libraries = self.query(
f"/Users/{user_id}/Views",
"get",
)
jellyfin_libraries = [x for x in jellyfin_libraries["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"] == library_name:
library_id = jellyfin_library["Id"]
continue
if library_id:
self.update_user_watched(
user_name,
user_id,
library_data,
library_name,
library_id,
update_partial,
dryrun,
)
except Exception as e:
logger.error(f"{self.server_type}: Error updating watched, {e}")
raise Exception(e)

203
src/library.py Normal file
View File

@@ -0,0 +1,203 @@
from loguru import logger
from src.functions import (
match_list,
search_mapping,
)
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,
):
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,
):
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,
server_2,
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

View File

@@ -1,441 +1,285 @@
import copy, os, traceback, json
from dotenv import load_dotenv
from time import sleep
from src.functions import logger, str_to_bool, search_mapping, generate_library_guids_dict, future_thread_executor
from src.plex import Plex
from src.jellyfin import Jellyfin
load_dotenv(override=True)
def cleanup_watched(watched_list_1, watched_list_2, user_mapping=None, library_mapping=None):
modified_watched_list_1 = copy.deepcopy(watched_list_1)
# remove entries from plex_watched that are in jellyfin_watched
for user_1 in watched_list_1:
user_other = None
if user_mapping:
user_other = search_mapping(user_mapping, user_1)
if user_1 in modified_watched_list_1:
if user_1 in watched_list_2:
user_2 = user_1
elif user_other in watched_list_2:
user_2 = user_other
else:
logger(f"User {user_1} and {user_other} not found in watched list 2", 1)
continue
for library_1 in watched_list_1[user_1]:
library_other = None
if library_mapping:
library_other = search_mapping(library_mapping, library_1)
if library_1 in modified_watched_list_1[user_1]:
if library_1 in watched_list_2[user_2]:
library_2 = library_1
elif library_other in watched_list_2[user_2]:
library_2 = library_other
else:
logger(f"library {library_1} and {library_other} not found in watched list 2", 1)
continue
# Movies
if isinstance(watched_list_1[user_1][library_1], list):
_, _, movies_watched_list_2_keys_dict = generate_library_guids_dict(watched_list_2[user_2][library_2], 2)
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":
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
show_watched_list_2_keys_dict, episode_watched_list_2_keys_dict, _ = generate_library_guids_dict(watched_list_2[user_2][library_2], 3)
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":
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 {show_key_dict['title']} {episode} from {library_1}", 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 {library_1} because it is empty", 3)
del modified_watched_list_1[user_1][library_1][show_key_1][season]
# If the show is empty, remove the show
if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0:
if show_key_1 in modified_watched_list_1[user_1][library_1]:
logger(f"Removing {show_key_dict['title']} from {library_1} because it is empty", 1)
del modified_watched_list_1[user_1][library_1][show_key_1]
for user_1 in watched_list_1:
for library_1 in watched_list_1[user_1]:
if library_1 in modified_watched_list_1[user_1]:
# If library is empty then remove it
if len(modified_watched_list_1[user_1][library_1]) == 0:
logger(f"Removing {library_1} from {user_1} because it is empty", 1)
del modified_watched_list_1[user_1][library_1]
if user_1 in modified_watched_list_1:
# If user is empty delete user
if len(modified_watched_list_1[user_1]) == 0:
logger(f"Removing {user_1} from watched list 1 because it is empty", 1)
del modified_watched_list_1[user_1]
return modified_watched_list_1
def setup_black_white_lists(blacklist_library: str, whitelist_library: str, blacklist_library_type: str, whitelist_library_type: str, blacklist_users: str, whitelist_users: str, library_mapping=None, user_mapping=None):
if blacklist_library:
if len(blacklist_library) > 0:
blacklist_library = blacklist_library.split(",")
blacklist_library = [x.strip() for x in blacklist_library]
if library_mapping:
temp_library = []
for library in blacklist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
blacklist_library = blacklist_library + temp_library
else:
blacklist_library = []
logger(f"Blacklist Library: {blacklist_library}", 1)
if whitelist_library:
if len(whitelist_library) > 0:
whitelist_library = whitelist_library.split(",")
whitelist_library = [x.strip() for x in whitelist_library]
if library_mapping:
temp_library = []
for library in whitelist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
whitelist_library = whitelist_library + temp_library
else:
whitelist_library = []
logger(f"Whitelist Library: {whitelist_library}", 1)
if blacklist_library_type:
if len(blacklist_library_type) > 0:
blacklist_library_type = blacklist_library_type.split(",")
blacklist_library_type = [x.lower().strip() for x in blacklist_library_type]
else:
blacklist_library_type = []
logger(f"Blacklist Library Type: {blacklist_library_type}", 1)
if whitelist_library_type:
if len(whitelist_library_type) > 0:
whitelist_library_type = whitelist_library_type.split(",")
whitelist_library_type = [x.lower().strip() for x in whitelist_library_type]
else:
whitelist_library_type = []
logger(f"Whitelist Library Type: {whitelist_library_type}", 1)
if blacklist_users:
if len(blacklist_users) > 0:
blacklist_users = blacklist_users.split(",")
blacklist_users = [x.lower().strip() for x in blacklist_users]
if user_mapping:
temp_users = []
for user in blacklist_users:
user_other = search_mapping(user_mapping, user)
if user_other:
temp_users.append(user_other)
blacklist_users = blacklist_users + temp_users
else:
blacklist_users = []
logger(f"Blacklist Users: {blacklist_users}", 1)
if whitelist_users:
if len(whitelist_users) > 0:
whitelist_users = whitelist_users.split(",")
whitelist_users = [x.lower().strip() for x in whitelist_users]
if user_mapping:
temp_users = []
for user in whitelist_users:
user_other = search_mapping(user_mapping, user)
if user_other:
temp_users.append(user_other)
whitelist_users = whitelist_users + temp_users
else:
whitelist_users = []
else:
whitelist_users = []
logger(f"Whitelist Users: {whitelist_users}", 1)
return blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users
def setup_users(server_1, server_2, blacklist_users, whitelist_users, user_mapping=None):
# generate list of users from server 1 and server 2
server_1_type = server_1[0]
server_1_connection = server_1[1]
server_2_type = server_2[0]
server_2_connection = server_2[1]
server_1_users = []
if server_1_type == "plex":
server_1_users = [ x.title.lower() for x in server_1_connection.users ]
elif server_1_type == "jellyfin":
server_1_users = [ key.lower() for key in server_1_connection.users.keys() ]
server_2_users = []
if server_2_type == "plex":
server_2_users = [ x.title.lower() for x in server_2_connection.users ]
elif server_2_type == "jellyfin":
server_2_users = [ key.lower() for key in server_2_connection.users.keys() ]
# combined list of overlapping users from plex and jellyfin
users = {}
for server_1_user in server_1_users:
if user_mapping:
jellyfin_plex_mapped_user = search_mapping(user_mapping, server_1_user)
if jellyfin_plex_mapped_user:
users[server_1_user] = jellyfin_plex_mapped_user
continue
if server_1_user in server_2_users:
users[server_1_user] = server_1_user
for server_2_user in server_2_users:
if user_mapping:
plex_jellyfin_mapped_user = search_mapping(user_mapping, server_2_user)
if plex_jellyfin_mapped_user:
users[plex_jellyfin_mapped_user] = server_2_user
continue
if server_2_user in server_1_users:
users[server_2_user] = server_2_user
logger(f"User list that exist on both servers {users}", 1)
users_filtered = {}
for user in users:
# whitelist_user is not empty and user lowercase is not in whitelist lowercase
if len(whitelist_users) > 0:
if user not in whitelist_users and users[user] not in whitelist_users:
logger(f"{user} or {users[user]} is not in whitelist", 1)
continue
if user not in blacklist_users and users[user] not in blacklist_users:
users_filtered[user] = users[user]
logger(f"Filtered user list {users_filtered}", 1)
if server_1_type == "plex":
output_server_1_users = []
for plex_user in server_1_connection.users:
if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values():
output_server_1_users.append(plex_user)
elif server_1_type == "jellyfin":
output_server_1_users = {}
for jellyfin_user, jellyfin_id in server_1_connection.users.items():
if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values():
output_server_1_users[jellyfin_user] = jellyfin_id
if server_2_type == "plex":
output_server_2_users = []
for plex_user in server_2_connection.users:
if plex_user.title.lower() in users_filtered.keys() or plex_user.title.lower() in users_filtered.values():
output_server_2_users.append(plex_user)
elif server_2_type == "jellyfin":
output_server_2_users = {}
for jellyfin_user, jellyfin_id in server_2_connection.users.items():
if jellyfin_user.lower() in users_filtered.keys() or jellyfin_user.lower() in users_filtered.values():
output_server_2_users[jellyfin_user] = jellyfin_id
if len(output_server_1_users) == 0:
raise Exception(f"No users found for server 1, users found {users} filtered users {users_filtered}")
if len(output_server_2_users) == 0:
raise Exception(f"No users found for server 2, users found {users} filtered users {users_filtered}")
logger(f"Server 1 users: {output_server_1_users}", 1)
logger(f"Server 2 users: {output_server_2_users}", 1)
return output_server_1_users, output_server_2_users
def generate_server_connections():
servers = []
plex_baseurl = os.getenv("PLEX_BASEURL", None)
plex_token = os.getenv("PLEX_TOKEN", None)
plex_username = os.getenv("PLEX_USERNAME", None)
plex_password = os.getenv("PLEX_PASSWORD", None)
plex_servername = os.getenv("PLEX_SERVERNAME", None)
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)))
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())))
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None)
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
if jellyfin_baseurl and jellyfin_token:
jellyfin_baseurl = jellyfin_baseurl.split(",")
jellyfin_token = jellyfin_token.split(",")
if len(jellyfin_baseurl) != len(jellyfin_token):
raise Exception("JELLYFIN_BASEURL and JELLYFIN_TOKEN must have the same number of entries")
for i, baseurl in enumerate(jellyfin_baseurl):
servers.append(("jellyfin", Jellyfin(baseurl=baseurl.strip(), token=jellyfin_token[i].strip())))
return servers
def main_loop():
logfile = os.getenv("LOGFILE","log.log")
# Delete logfile if it exists
if os.path.exists(logfile):
os.remove(logfile)
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
logger(f"Dryrun: {dryrun}", 1)
user_mapping = os.getenv("USER_MAPPING")
if user_mapping:
user_mapping = json.loads(user_mapping.lower())
logger(f"User Mapping: {user_mapping}", 1)
library_mapping = os.getenv("LIBRARY_MAPPING")
if library_mapping:
library_mapping = json.loads(library_mapping)
logger(f"Library Mapping: {library_mapping}", 1)
# Create (black/white)lists
logger("Creating (black/white)lists", 1)
blacklist_library = os.getenv("BLACKLIST_LIBRARY", None)
whitelist_library = os.getenv("WHITELIST_LIBRARY", None)
blacklist_library_type = os.getenv("BLACKLIST_LIBRARY_TYPE", None)
whitelist_library_type = os.getenv("WHITELIST_LIBRARY_TYPE", None)
blacklist_users = os.getenv("BLACKLIST_USERS", None)
whitelist_users = os.getenv("WHITELIST_USERS", None)
blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users, library_mapping, user_mapping)
# Create server connections
logger("Creating server connections", 1)
servers = generate_server_connections()
for server_1 in servers:
# If server is the final server in the list, then we are done with the loop
if server_1 == servers[-1]:
break
# Start server_2 at the next server in the list
for server_2 in servers[servers.index(server_1) + 1:]:
server_1_connection = server_1[1]
server_2_connection = server_2[1]
# Create users list
logger("Creating users list", 1)
server_1_users, server_2_users = setup_users(server_1, server_2, blacklist_users, whitelist_users, user_mapping)
logger("Creating watched lists", 1)
args = [[server_1_connection.get_watched, server_1_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping]
, [server_2_connection.get_watched, server_2_users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping]]
results = future_thread_executor(args)
server_1_watched = results[0]
server_2_watched = results[1]
logger(f"Server 1 watched: {server_1_watched}", 3)
logger(f"Server 2 watched: {server_2_watched}", 3)
# clone watched so it isnt modified in the cleanup function so all duplicates are actually removed
server_1_watched_filtered = copy.deepcopy(server_1_watched)
server_2_watched_filtered = copy.deepcopy(server_2_watched)
logger("Cleaning Server 1 Watched", 1)
server_1_watched_filtered = cleanup_watched(server_1_watched, server_2_watched, user_mapping, library_mapping)
logger("Cleaning Server 2 Watched", 1)
server_2_watched_filtered = cleanup_watched(server_2_watched, server_1_watched, user_mapping, library_mapping)
logger(f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}", 1)
logger(f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}", 1)
args= [[server_1_connection.update_watched, server_2_watched_filtered, user_mapping, library_mapping, dryrun]
, [server_2_connection.update_watched, server_1_watched_filtered, user_mapping, library_mapping, dryrun]]
future_thread_executor(args)
def main():
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
while(True):
try:
main_loop()
logger(f"Looping in {sleep_duration}")
sleep(sleep_duration)
except Exception as error:
if isinstance(error, list):
for message in error:
logger(message, log_type=2)
else:
logger(error, log_type=2)
logger(traceback.format_exc(), 2)
logger(f"Retrying in {sleep_duration}", log_type=0)
sleep(sleep_duration)
except KeyboardInterrupt:
logger("Exiting", log_type=0)
os._exit(0)
import os
import traceback
import json
import sys
from dotenv import load_dotenv
from time import sleep, perf_counter
from loguru import logger
from src.emby import Emby
from src.jellyfin import Jellyfin
from src.plex import Plex
from src.library import setup_libraries
from src.functions import (
parse_string_to_list,
str_to_bool,
)
from src.users import setup_users
from src.watched import (
cleanup_watched,
)
from src.black_white import setup_black_white_lists
from src.connection import generate_server_connections
load_dotenv(override=True)
log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
level = os.getenv("DEBUG_LEVEL", "INFO").upper()
def configure_logger():
# Remove default logger to configure our own
logger.remove()
# Choose log level based on environment
# If in debug mode with a "debug" level, use DEBUG; otherwise, default to INFO.
if level not in ["INFO", "DEBUG", "TRACE"]:
logger.add(sys.stdout)
raise Exception("Invalid DEBUG_LEVEL, please choose between INFO, DEBUG, TRACE")
# Add a sink for file logging and the console.
logger.add(log_file, level=level, mode="w")
logger.add(sys.stdout, level=level)
def should_sync_server(
server_1: Plex | Jellyfin | Emby,
server_2: Plex | Jellyfin | Emby,
) -> bool:
sync_from_plex_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True")
)
sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True"))
sync_from_plex_to_emby = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_EMBY", "True"))
sync_from_jelly_to_plex = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True")
)
sync_from_jelly_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True")
)
sync_from_jelly_to_emby = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_EMBY", "True")
)
sync_from_emby_to_plex = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_PLEX", "True"))
sync_from_emby_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_EMBY_TO_JELLYFIN", "True")
)
sync_from_emby_to_emby = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_EMBY", "True"))
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
if isinstance(server_2, Emby) and not sync_from_plex_to_emby:
logger.info("Sync from plex -> emby is disabled")
return False
if isinstance(server_2, Plex) and not sync_from_plex_to_plex:
logger.info("Sync from plex -> plex is disabled")
return False
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
if isinstance(server_2, Jellyfin) and not sync_from_jelly_to_jellyfin:
logger.info("Sync from jellyfin -> jellyfin is disabled")
return False
if isinstance(server_2, Emby) and not sync_from_jelly_to_emby:
logger.info("Sync from jellyfin -> emby is disabled")
return False
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 isinstance(server_2, Jellyfin) and not sync_from_emby_to_jellyfin:
logger.info("Sync from emby -> jellyfin is disabled")
return False
if isinstance(server_2, Emby) and not sync_from_emby_to_emby:
logger.info("Sync from emby -> emby is disabled")
return False
return True
def main_loop():
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
logger.info(f"Dryrun: {dryrun}")
user_mapping = os.getenv("USER_MAPPING", None)
if user_mapping:
user_mapping = json.loads(user_mapping.lower())
logger.info(f"User Mapping: {user_mapping}")
library_mapping = os.getenv("LIBRARY_MAPPING", None)
if library_mapping:
library_mapping = json.loads(library_mapping)
logger.info(f"Library Mapping: {library_mapping}")
# Create (black/white)lists
logger.info("Creating (black/white)lists")
blacklist_library = parse_string_to_list(os.getenv("BLACKLIST_LIBRARY", None))
whitelist_library = parse_string_to_list(os.getenv("WHITELIST_LIBRARY", None))
blacklist_library_type = parse_string_to_list(
os.getenv("BLACKLIST_LIBRARY_TYPE", None)
)
whitelist_library_type = parse_string_to_list(
os.getenv("WHITELIST_LIBRARY_TYPE", None)
)
blacklist_users = parse_string_to_list(os.getenv("BLACKLIST_USERS", None))
whitelist_users = parse_string_to_list(os.getenv("WHITELIST_USERS", None))
(
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
blacklist_users,
whitelist_users,
) = setup_black_white_lists(
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
blacklist_users,
whitelist_users,
library_mapping,
user_mapping,
)
# Create server connections
logger.info("Creating server connections")
servers = generate_server_connections()
for server_1 in servers:
# If server is the final server in the list, then we are done with the loop
if server_1 == servers[-1]:
break
# Start server_2 at the next server in the list
for server_2 in servers[servers.index(server_1) + 1 :]:
# Check if server 1 and server 2 are going to be synced in either direction, skip if not
if not should_sync_server(server_1, server_2) and not should_sync_server(
server_2, server_1
):
continue
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.info("Creating users list")
server_1_users, server_2_users = setup_users(
server_1, server_2, blacklist_users, whitelist_users, user_mapping
)
server_1_libraries, server_2_libraries = setup_libraries(
server_1,
server_2,
blacklist_library,
blacklist_library_type,
whitelist_library,
whitelist_library_type,
library_mapping,
)
logger.info(f"Server 1 syncing libraries: {server_1_libraries}")
logger.info(f"Server 2 syncing libraries: {server_2_libraries}")
logger.info("Creating watched lists", 1)
server_1_watched = server_1.get_watched(server_1_users, server_1_libraries)
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.info("Cleaning Server 2 Watched", 1)
server_2_watched_filtered = cleanup_watched(
server_2_watched, server_1_watched, user_mapping, library_mapping
)
logger.debug(
f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}",
)
logger.debug(
f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}",
)
if should_sync_server(server_2, server_1):
logger.info(f"Syncing {server_2.info()} -> {server_1.info()}")
server_1.update_watched(
server_2_watched_filtered,
user_mapping,
library_mapping,
dryrun,
)
if should_sync_server(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,
)
@logger.catch
def main():
run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False"))
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
times: list[float] = []
while True:
try:
start = perf_counter()
# Reconfigure the logger on each loop so the logs are rotated on each run
configure_logger()
main_loop()
end = perf_counter()
times.append(end - start)
if len(times) > 0:
logger.info(f"Average time: {sum(times) / len(times)}")
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.error(message)
else:
logger.error(error)
logger.error(traceback.format_exc())
if run_only_once:
break
logger.info(f"Retrying in {sleep_duration}")
sleep(sleep_duration)
except KeyboardInterrupt:
if len(times) > 0:
logger.info(f"Average time: {sum(times) / len(times)}")
logger.info("Exiting")
os._exit(0)

View File

@@ -1,305 +1,505 @@
import re
from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount
from src.functions import logger, search_mapping, check_skip_logic, generate_library_guids_dict, future_thread_executor
# 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):
self.baseurl = baseurl
self.token = token
self.username = username
self.password = password
self.servername = servername
self.plex = self.login()
self.admin_user = self.plex.myPlexAccount()
self.users = self.get_users()
def login(self):
try:
if self.baseurl and self.token:
# Login via token
plex = PlexServer(self.baseurl, self.token)
elif self.username and self.password and self.servername:
# Login via plex account
account = MyPlexAccount(self.username, self.password)
plex = account.resource(self.servername).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)
else:
logger(f"Plex: Failed to login, Error: {e}", 2)
raise Exception(e)
def get_users(self):
try:
users = 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)
raise Exception(e)
def get_user_watched(self, 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)
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
movie_guids["title"] = video.title
movie_guids["locations"] = tuple([x.split("/")[-1] for x in video.locations])
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)
def get_watched(self, users, blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, library_mapping):
try:
# Get all libraries
users_watched = {}
args = []
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))
libraries = user_plex.library.sections()
for library in libraries:
library_title = library.title
library_type = library.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"Plex: Skipping library {library_title} {skip_reason}", 1)
continue
args.append([self.get_user_watched, user, 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)
return users_watched
except Exception as e:
logger(f"Plex: Failed to get watched, Error: {e}", 2)
raise Exception(e)
def update_user_watched (self, user, user_plex, library, videos, dryrun):
try:
logger(f"Plex: Updating watched for {user.title} in library {library}", 1)
library_videos = user_plex.library.section(library)
if library_videos.type == "movie":
_, _, videos_movies_ids = generate_library_guids_dict(videos, 2)
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:
if movies_search.viewCount == 0:
msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex"
if not dryrun:
logger(f"Marked {msg}", 0)
movies_search.markWatched()
else:
logger(f"Dryrun {msg}", 0)
elif library_videos.type == "show":
videos_shows_ids, videos_episode_ids, _ = generate_library_guids_dict(videos, 3)
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_episode_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_episode_ids exactly, then the episode is in the list
if episode_guid_source in videos_episode_ids.keys():
if episode_guid_id in videos_episode_ids[episode_guid_source]:
episode_found = True
break
if episode_found:
if episode_search.viewCount == 0:
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)
except Exception as e:
logger(f"Plex: Failed to update watched for {user.title} in library {library}, Error: {e}", 2)
raise Exception(e)
def update_watched(self, watched_list, user_mapping=None, library_mapping=None, dryrun=False):
try:
args = []
for user, libraries in watched_list.items():
user_other = None
# If type of user is dict
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
for index, value in enumerate(self.users):
if user.lower() == value.title.lower():
user = self.users[index]
break
elif user_other and user_other.lower() == value.title.lower():
user = self.users[index]
break
if self.admin_user == user:
user_plex = self.plex
else:
user_plex = PlexServer(self.plex._baseurl, user.get_token(self.plex.machineIdentifier))
for library, videos in libraries.items():
library_other = None
if library_mapping:
if library in library_mapping.keys():
library_other = library_mapping[library]
elif library in library_mapping.values():
library_other = search_mapping(library_mapping, library)
# if library in plex library list
library_list = user_plex.library.sections()
if library.lower() not in [x.title.lower() for x in library_list]:
if library_other:
if library_other.lower() in [x.title.lower() for x in library_list]:
logger(f"Plex: Library {library} not found, but {library_other} found, using {library_other}", 1)
library = library_other
else:
logger(f"Plex: Library {library} or {library_other} not found in library list", 2)
continue
else:
logger(f"Plex: Library {library} not found in library list", 2)
continue
args.append([self.update_user_watched, user, user_plex, library, videos, dryrun])
future_thread_executor(args)
except Exception as e:
logger(f"Plex: Failed to update watched, Error: {e}", 2)
raise Exception(e)
import os
import requests
from dotenv import load_dotenv
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 src.functions import (
search_mapping,
log_marked,
str_to_bool,
)
from src.watched import (
LibraryData,
MediaIdentifiers,
MediaItem,
WatchedStatus,
Series,
UserData,
check_same_identifiers,
)
load_dotenv(override=True)
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
# Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
class HostNameIgnoringAdapter(RequestsHTTPAdapter):
def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs):
self.poolmanager = PoolManager(
num_pools=connections,
maxsize=maxsize,
block=block,
assert_hostname=False,
**pool_kwargs,
)
def extract_guids_from_item(item: Movie | Show | Episode) -> dict[str, str]:
# If GENERATE_GUIDS is set to False, then return an empty dict
if not generate_guids:
return {}
guids: dict[str, str] = dict(
guid.id.split("://")
for guid in item.guids
if guid.id is not None and len(guid.id.strip()) > 0
)
return guids
def extract_identifiers_from_item(item: Movie | Show | Episode) -> MediaIdentifiers:
guids = extract_guids_from_item(item)
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", None),
tvdb_id=guids.get("tvdb", None),
tmdb_id=guids.get("tmdb", None),
)
def get_mediaitem(item: Movie | Episode, completed=True) -> MediaItem:
return MediaItem(
identifiers=extract_identifiers_from_item(item),
status=WatchedStatus(completed=completed, time=item.viewOffset),
)
def update_user_watched(
user: MyPlexAccount,
user_plex: PlexServer,
library_data: LibraryData,
library_name: str,
dryrun: bool,
):
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"Plex: Updating watched for {user.title} in library {library_name}"
)
library_section = user_plex.library.section(library_name)
# 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)
# 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:
plex_movie.markWatched()
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_movie.title,
None,
None,
)
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:
plex_movie.updateTimeline(stored_movie.status.time)
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,
)
# 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)
# 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.info(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
)
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:
plex_episode.markWatched()
logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_show.title,
plex_episode.title,
)
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:
plex_episode.updateTimeline(
stored_ep.status.time
)
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,
)
break # Found a matching episode.
break # Found a matching show.
except Exception as e:
logger.error(
f"Plex: Failed to update watched for {user.title} in library {library_name}, Error: {e}",
2,
)
raise e
# 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,
session=None,
):
self.server_type = "Plex"
self.baseurl = baseurl
self.token = token
self.username = username
self.password = password
self.servername = servername
self.ssl_bypass = 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 = self.login(self.baseurl, self.token)
self.admin_user = self.plex.myPlexAccount()
self.users = self.get_users()
def login(self, baseurl, token):
try:
if baseurl and token:
plex = PlexServer(baseurl, token, session=self.session)
elif self.username and self.password and self.servername:
# Login via plex account
account = MyPlexAccount(self.username, self.password)
plex = account.resource(self.servername).connect()
else:
raise Exception("No complete plex credentials provided")
return plex
except Exception as e:
if self.username:
msg = f"Failed to login via plex account {self.username}"
logger.error(f"Plex: Failed to login, {msg}, Error: {e}")
else:
logger.error(f"Plex: Failed to login, Error: {e}")
raise Exception(e)
def info(self) -> str:
return f"Plex {self.plex.friendlyName}: {self.plex.version}"
def get_users(self):
try:
users = self.plex.myPlexAccount().users()
# append self to users
users.append(self.plex.myPlexAccount())
return users
except Exception as e:
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, user_plex, library) -> 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))
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)
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)
)
if episode_mediaitem:
watched.series.append(
Series(
identifiers=MediaIdentifiers(
title=show.title,
locations=(
tuple(
[
location.split("/")[-1]
for location in show.locations
]
)
if generate_locations
else tuple()
),
imdb_id=show_guids.get("imdb", None),
tvdb_id=show_guids.get("tvdb", None),
tmdb_id=show_guids.get("tmdb", None),
),
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, sync_libraries) -> dict[str, UserData]:
try:
users_watched: dict[str, UserData] = {}
for user in users:
if self.admin_user == user:
user_plex = self.plex
else:
token = user.get_token(self.plex.machineIdentifier)
if token:
user_plex = self.login(
self.plex._baseurl,
token,
)
else:
logger.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:
if library.title not in sync_libraries:
continue
library_data = self.get_user_library_watched(
user_name, user_plex, library
)
if user_name not in users_watched:
users_watched[user_name] = UserData()
users_watched[user_name].libraries[library.title] = library_data
return users_watched
except Exception as e:
logger.error(f"Plex: Failed to get watched, Error: {e}")
raise Exception(e)
def update_watched(
self,
watched_list: dict[str, UserData],
user_mapping=None,
library_mapping=None,
dryrun=False,
):
try:
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 index, value in enumerate(self.users):
username_title = (
value.username.lower()
if value.username
else value.title.lower()
)
if user.lower() == username_title:
user = self.users[index]
break
elif user_other and user_other.lower() == username_title:
user = self.users[index]
break
if self.admin_user == user:
user_plex = self.plex
else:
if isinstance(user, str):
logger.warning(
f"Plex: {user} is not a plex object, attempting to get object for user",
)
user = self.plex.myPlexAccount().user(user)
token = user.get_token(self.plex.machineIdentifier)
if token:
user_plex = PlexServer(
self.plex._baseurl,
token,
session=self.session,
)
else:
logger.error(
f"Plex: Failed to get token for {user.title}, skipping",
)
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.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
update_user_watched(
user,
user_plex,
library_data,
library_name,
dryrun,
)
except Exception as e:
logger.error(f"Plex: Failed to update watched, Error: {e}")
raise Exception(e)

149
src/users.py Normal file
View File

@@ -0,0 +1,149 @@
from plexapi.myplex import MyPlexAccount
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] | dict[str, str], list[MyPlexAccount] | 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

209
src/watched.py Normal file
View File

@@ -0,0 +1,209 @@
import copy
from pydantic import BaseModel, Field
from loguru import logger
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 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=None,
library_mapping=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, object_1, object_2):
if object_1 in watched_list:
return object_1
elif object_2 in watched_list:
return object_2
else:
logger.info(f"{object_1} and {object_2} not found in watched list 2")
return None

113
test/ci_emby.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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"

View File

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

87
test/test_black_white.py Normal file
View File

@@ -0,0 +1,87 @@
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.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"]
(
results_blacklist_library,
return_whitelist_library,
return_blacklist_library_type,
return_whitelist_library_type,
return_blacklist_users,
return_whitelist_users,
) = setup_black_white_lists(
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
blacklist_users,
whitelist_users,
)
assert results_blacklist_library == ["library1", "library2"]
assert return_whitelist_library == ["library1", "library2"]
assert return_blacklist_library_type == ["library_type1", "library_type2"]
assert return_whitelist_library_type == ["library_type1", "library_type2"]
assert return_blacklist_users == ["user1", "user2"]
assert return_whitelist_users == ["user1", "user2"]
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"}
(
results_blacklist_library,
return_whitelist_library,
return_blacklist_library_type,
return_whitelist_library_type,
return_blacklist_users,
return_whitelist_users,
) = setup_black_white_lists(
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
blacklist_users,
whitelist_users,
library_mapping,
user_mapping,
)
assert results_blacklist_library == ["library1", "library2", "library3"]
assert return_whitelist_library == ["library1", "library2", "library3"]
assert return_blacklist_library_type == ["library_type1", "library_type2"]
assert return_whitelist_library_type == ["library_type1", "library_type2"]
assert return_blacklist_users == ["user1", "user2", "user3"]
assert return_whitelist_users == ["user1", "user2", "user3"]

278
test/test_library.py Normal file
View 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

View File

@@ -1,47 +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 setup_black_white_lists
def test_setup_black_white_lists():
# Simple
blacklist_library = 'library1, library2'
whitelist_library = 'library1, library2'
blacklist_library_type = 'library_type1, library_type2'
whitelist_library_type = 'library_type1, library_type2'
blacklist_users = 'user1, user2'
whitelist_users = 'user1, user2'
results_blacklist_library, return_whitelist_library, return_blacklist_library_type, return_whitelist_library_type, return_blacklist_users, return_whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users)
assert results_blacklist_library == ['library1', 'library2']
assert return_whitelist_library == ['library1', 'library2']
assert return_blacklist_library_type == ['library_type1', 'library_type2']
assert return_whitelist_library_type == ['library_type1', 'library_type2']
assert return_blacklist_users == ['user1', 'user2']
assert return_whitelist_users == ['user1', 'user2']
# Library Mapping and user mapping
library_mapping = { "library1": "library3" }
user_mapping = { "user1": "user3" }
results_blacklist_library, return_whitelist_library, return_blacklist_library_type, return_whitelist_library_type, return_blacklist_users, return_whitelist_users = setup_black_white_lists(blacklist_library, whitelist_library, blacklist_library_type, whitelist_library_type, blacklist_users, whitelist_users, library_mapping, user_mapping)
assert results_blacklist_library == ['library1', 'library2', 'library3']
assert return_whitelist_library == ['library1', 'library2', 'library3']
assert return_blacklist_library_type == ['library_type1', 'library_type2']
assert return_whitelist_library_type == ['library_type1', 'library_type2']
assert return_blacklist_users == ['user1', 'user2', 'user3']
assert return_whitelist_users == ['user1', 'user2', 'user3']

View File

@@ -1,176 +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
View 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
View 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
View 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()

305
uv.lock generated Normal file
View File

@@ -0,0 +1,305 @@
version = 1
revision = 1
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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "certifi"
version = "2025.1.31"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "jellyplex-watched"
version = "6.1.2"
source = { virtual = "." }
dependencies = [
{ name = "loguru" },
{ name = "packaging" },
{ name = "plexapi" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "requests" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
lint = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "packaging", specifier = "==24.2" },
{ name = "plexapi", specifier = "==4.16.1" },
{ name = "pydantic", specifier = "==2.10.6" },
{ name = "python-dotenv", specifier = "==1.0.0" },
{ name = "requests", specifier = "==2.32.3" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.3.4" }]
lint = [{ name = "ruff", specifier = ">=0.9.6" }]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
]
[[package]]
name = "packaging"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
[[package]]
name = "plexapi"
version = "4.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/1c/beeaf8dd489dad13ca4310a7bd9c601da6c6831e7c8ac61a45aadccb742d/plexapi-4.16.1.tar.gz", hash = "sha256:8e62d727e67b69994770196cd83a57783e9194d735aa347f682b4534ce6f0565", size = 153460 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/07/20b8a50b8b78374a49685dfe12739c27a9cd440b13913c2cbeeb50470d1e/PlexAPI-4.16.1-py3-none-any.whl", hash = "sha256:87432226c4cd682b5780b01e8def313285c52bdd57c8e72f66a5cef73ce64530", size = 165325 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]]
name = "pydantic"
version = "2.10.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 },
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 },
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 },
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 },
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 },
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 },
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 },
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 },
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 },
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 },
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 },
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 },
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 },
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 },
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 },
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 },
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 },
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 },
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 },
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 },
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 },
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 },
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 },
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 },
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 },
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 },
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 },
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
]
[[package]]
name = "pytest"
version = "8.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
]
[[package]]
name = "python-dotenv"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/06/1ef763af20d0572c032fa22882cfbfb005fba6e7300715a37840858c919e/python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", size = 37399 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "ruff"
version = "0.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/e1/e265aba384343dd8ddd3083f5e33536cd17e1566c41453a5517b5dd443be/ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9", size = 3639454 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/e3/3d2c022e687e18cf5d93d6bfa2722d46afc64eaa438c7fbbdd603b3597be/ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba", size = 11714128 },
{ url = "https://files.pythonhosted.org/packages/e1/22/aff073b70f95c052e5c58153cba735748c9e70107a77d03420d7850710a0/ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504", size = 11682539 },
{ url = "https://files.pythonhosted.org/packages/75/a7/f5b7390afd98a7918582a3d256cd3e78ba0a26165a467c1820084587cbf9/ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83", size = 11132512 },
{ url = "https://files.pythonhosted.org/packages/a6/e3/45de13ef65047fea2e33f7e573d848206e15c715e5cd56095589a7733d04/ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc", size = 11929275 },
{ url = "https://files.pythonhosted.org/packages/7d/f2/23d04cd6c43b2e641ab961ade8d0b5edb212ecebd112506188c91f2a6e6c/ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b", size = 11466502 },
{ url = "https://files.pythonhosted.org/packages/b5/6f/3a8cf166f2d7f1627dd2201e6cbc4cb81f8b7d58099348f0c1ff7b733792/ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e", size = 12676364 },
{ url = "https://files.pythonhosted.org/packages/f5/c4/db52e2189983c70114ff2b7e3997e48c8318af44fe83e1ce9517570a50c6/ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666", size = 13335518 },
{ url = "https://files.pythonhosted.org/packages/66/44/545f8a4d136830f08f4d24324e7db957c5374bf3a3f7a6c0bc7be4623a37/ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5", size = 12823287 },
{ url = "https://files.pythonhosted.org/packages/c5/26/8208ef9ee7431032c143649a9967c3ae1aae4257d95e6f8519f07309aa66/ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5", size = 14592374 },
{ url = "https://files.pythonhosted.org/packages/31/70/e917781e55ff39c5b5208bda384fd397ffd76605e68544d71a7e40944945/ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217", size = 12500173 },
{ url = "https://files.pythonhosted.org/packages/84/f5/e4ddee07660f5a9622a9c2b639afd8f3104988dc4f6ba0b73ffacffa9a8c/ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6", size = 11906555 },
{ url = "https://files.pythonhosted.org/packages/f1/2b/6ff2fe383667075eef8656b9892e73dd9b119b5e3add51298628b87f6429/ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897", size = 11538958 },
{ url = "https://files.pythonhosted.org/packages/3c/db/98e59e90de45d1eb46649151c10a062d5707b5b7f76f64eb1e29edf6ebb1/ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08", size = 12117247 },
{ url = "https://files.pythonhosted.org/packages/ec/bc/54e38f6d219013a9204a5a2015c09e7a8c36cedcd50a4b01ac69a550b9d9/ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656", size = 12554647 },
{ url = "https://files.pythonhosted.org/packages/a5/7d/7b461ab0e2404293c0627125bb70ac642c2e8d55bf590f6fce85f508f1b2/ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d", size = 9949214 },
{ url = "https://files.pythonhosted.org/packages/ee/30/c3cee10f915ed75a5c29c1e57311282d1a15855551a64795c1b2bbe5cf37/ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa", size = 10999914 },
{ url = "https://files.pythonhosted.org/packages/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[package]]
name = "urllib3"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
]