207 Commits

Author SHA1 Message Date
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
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
32 changed files with 3276 additions and 1912 deletions

View File

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

View File

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

View File

@@ -24,7 +24,9 @@ A clear and concise description of what you expected to happen.
If applicable, add logs to help explain your problem ideally with DEBUG set to true, be sure to remove sensitive information If applicable, add logs to help explain your problem ideally with DEBUG set to true, be sure to remove sensitive information
**Type:** **Type:**
- [ ] Docker - [ ] Docker Compose
- [ ] Docker
- [ ] Unraid
- [ ] Native - [ ] Native
**Additional context** **Additional context**

View File

@@ -1,5 +1,6 @@
name: CI name: CI
on: on:
workflow_dispatch:
push: push:
paths-ignore: paths-ignore:
- .gitignore - .gitignore
@@ -8,12 +9,12 @@ on:
paths-ignore: paths-ignore:
- .gitignore - .gitignore
- "*.md" - "*.md"
jobs: jobs:
pytest: pytest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: "Install dependencies" - name: "Install dependencies"
run: pip install -r requirements.txt && pip install -r test/requirements.txt run: pip install -r requirements.txt && pip install -r test/requirements.txt
@@ -21,60 +22,174 @@ jobs:
- name: "Run tests" - name: "Run tests"
run: pytest -vvv run: pytest -vvv
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "Install dependencies"
run: |
pip install -r requirements.txt
sudo apt update && sudo apt install -y docker-compose
- name: "Checkout JellyPlex-Watched-CI"
uses: actions/checkout@v4
with:
repository: luigi311/JellyPlex-Watched-CI
path: JellyPlex-Watched-CI
- name: "Start containers"
run: |
JellyPlex-Watched-CI/start_containers.sh
# Wait for containers to start
sleep 10
for FOLDER in $(find "JellyPlex-Watched-CI" -type f -name "docker-compose.yml" -exec dirname {} \;); do
docker-compose -f "${FOLDER}/docker-compose.yml" logs
done
- name: "Test Plex"
run: |
mv test/ci_plex.env .env
python main.py
python test/validate_ci_marklog.py --plex
rm mark.log
- name: "Test Jellyfin"
run: |
mv test/ci_jellyfin.env .env
python main.py
python test/validate_ci_marklog.py --jellyfin
rm mark.log
- name: "Test Emby"
run: |
mv test/ci_emby.env .env
python main.py
python test/validate_ci_marklog.py --emby
rm mark.log
- name: "Test Guids"
run: |
mv test/ci_guids.env .env
python main.py
python test/validate_ci_marklog.py --dry
rm mark.log
- name: "Test Locations"
run: |
mv test/ci_locations.env .env
python main.py
python test/validate_ci_marklog.py --dry
rm mark.log
- name: "Test writing to the servers"
run: |
# Test writing to the servers
mv test/ci_write.env .env
python main.py
# Test again to test if it can handle existing data
python main.py
python test/validate_ci_marklog.py --write
rm mark.log
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: pytest needs:
- pytest
- test
env:
DEFAULT_VARIANT: alpine
strategy:
fail-fast: false
matrix:
include:
- dockerfile: Dockerfile.alpine
variant: alpine
- dockerfile: Dockerfile.slim
variant: slim
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Docker meta - name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/jellyplex-watched,enable=${{ secrets.DOCKER_USERNAME != '' }}
# Do not push to ghcr.io on PRs due to permission issues, only push if the owner is luigi311 so it doesnt fail on forks
ghcr.io/${{ github.repository }},enable=${{ github.event_name != 'pull_request' && github.repository_owner == 'luigi311'}}
flavor: latest=false
tags: |
type=raw,value=latest,enable=${{ matrix.variant == env.DEFAULT_VARIANT && startsWith(github.ref, 'refs/tags/') }}
type=raw,value=latest,suffix=-${{ matrix.variant }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=ref,event=branch,suffix=-${{ matrix.variant }}
type=ref,event=branch,enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
type=ref,event=pr,suffix=-${{ matrix.variant }}
type=ref,event=pr,enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
type=semver,pattern={{ version }},suffix=-${{ matrix.variant }}
type=semver,pattern={{ version }},enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
type=semver,pattern={{ major }}.{{ minor }},suffix=-${{ matrix.variant }}
type=semver,pattern={{ major }}.{{ minor }},enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
type=sha,suffix=-${{ matrix.variant }}
type=sha,enable=${{ matrix.variant == env.DEFAULT_VARIANT }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
env: env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
if: "${{ env.DOCKER_USERNAME != '' }}" if: "${{ env.DOCKER_USERNAME != '' }}"
uses: docker/metadata-action@v4 uses: docker/login-action@v3
with:
images: ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched # list of Docker images to use as base name for tags
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: "${{ steps.docker_meta.outcome == 'success' }}"
uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registry
if: "${{ steps.docker_meta.outcome == 'success' }}"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build - name: Build
id: build id: build
if: "${{ steps.docker_meta.outcome == 'skipped' }}" if: "${{ steps.docker_meta.outputs.tags == '' }}"
uses: docker/build-push-action@v3 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: false push: false
tags: jellyplex-watched:action tags: jellyplex-watched:action
- name: Build Push - name: Build Push
id: build_push id: build_push
if: "${{ steps.docker_meta.outcome == 'success' }}" if: "${{ steps.docker_meta.outputs.tags != '' }}"
uses: docker/build-push-action@v3 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
.env **.env*
*.prof *.prof
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files

11
.vscode/launch.json vendored
View File

@@ -11,6 +11,17 @@
"program": "main.py", "program": "main.py",
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": true "justMyCode": true
},
{
"name": "Pytest",
"type": "python",
"request": "launch",
"module": "pytest",
"args": [
"-vv"
],
"console": "integratedTerminal",
"justMyCode": true
} }
] ]
} }

View File

@@ -1,46 +1,53 @@
FROM python:3-slim FROM python:3.11-alpine
ENV DRYRUN 'True' ENV DRYRUN 'True'
ENV DEBUG 'True' ENV DEBUG 'True'
ENV DEBUG_LEVEL 'INFO' ENV DEBUG_LEVEL 'INFO'
ENV SLEEP_DURATION '3600' ENV RUN_ONLY_ONCE 'False'
ENV LOGFILE 'log.log' ENV SLEEP_DURATION '3600'
ENV LOGFILE 'log.log'
ENV USER_MAPPING '' ENV MARKFILE 'mark.log'
ENV LIBRARY_MAPPING ''
ENV USER_MAPPING ''
ENV PLEX_BASEURL '' ENV LIBRARY_MAPPING ''
ENV PLEX_TOKEN ''
ENV PLEX_USERNAME '' ENV PLEX_BASEURL ''
ENV PLEX_PASSWORD '' ENV PLEX_TOKEN ''
ENV PLEX_SERVERNAME '' ENV PLEX_USERNAME ''
ENV PLEX_PASSWORD ''
ENV JELLYFIN_BASEURL '' ENV PLEX_SERVERNAME ''
ENV JELLYFIN_TOKEN ''
ENV JELLYFIN_BASEURL ''
ENV SYNC_FROM_PLEX_TO_JELLYFIN 'True' ENV JELLYFIN_TOKEN ''
ENV SYNC_FROM_JELLYFIN_TO_PLEX 'True'
ENV SYNC_FROM_PLEX_TO_PLEX 'True' ENV SYNC_FROM_PLEX_TO_JELLYFIN 'True'
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN 'True' ENV SYNC_FROM_JELLYFIN_TO_PLEX 'True'
ENV SYNC_FROM_PLEX_TO_PLEX 'True'
ENV BLACKLIST_LIBRARY '' ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN 'True'
ENV WHITELIST_LIBRARY ''
ENV BLACKLIST_LIBRARY_TYPE '' ENV BLACKLIST_LIBRARY ''
ENV WHITELIST_LIBRARY_TYPE '' ENV WHITELIST_LIBRARY ''
ENV BLACKLIST_USERS '' ENV BLACKLIST_LIBRARY_TYPE ''
ENV WHITELIST_USERS '' ENV WHITELIST_LIBRARY_TYPE ''
ENV BLACKLIST_USERS ''
WORKDIR /app ENV WHITELIST_USERS ''
RUN apt-get update && \
apt-get install -y --no-install-recommends \ RUN apk add --no-cache tini && \
build-essential && \ addgroup --system jellyplex_user && \
apt-get clean && \ adduser --system --no-create-home jellyplex_user --ingroup jellyplex_user && \
rm -rf /var/lib/apt/lists/* mkdir -p /app && \
chown -R jellyplex_user:jellyplex_user /app
COPY ./requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt WORKDIR /app
COPY . . COPY --chown=jellyplex_user:jellyplex_user ./requirements.txt ./
CMD ["python", "-u", "main.py"] RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=jellyplex_user:jellyplex_user . .
USER jellyplex_user
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["python", "-u", "main.py"]

56
Dockerfile.slim Normal file
View File

@@ -0,0 +1,56 @@
FROM python:3.11-slim
ENV DRYRUN 'True'
ENV DEBUG 'True'
ENV DEBUG_LEVEL 'INFO'
ENV RUN_ONLY_ONCE 'False'
ENV SLEEP_DURATION '3600'
ENV LOGFILE 'log.log'
ENV MARKFILE 'mark.log'
ENV USER_MAPPING ''
ENV LIBRARY_MAPPING ''
ENV PLEX_BASEURL ''
ENV PLEX_TOKEN ''
ENV PLEX_USERNAME ''
ENV PLEX_PASSWORD ''
ENV PLEX_SERVERNAME ''
ENV JELLYFIN_BASEURL ''
ENV JELLYFIN_TOKEN ''
ENV SYNC_FROM_PLEX_TO_JELLYFIN 'True'
ENV SYNC_FROM_JELLYFIN_TO_PLEX 'True'
ENV SYNC_FROM_PLEX_TO_PLEX 'True'
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN 'True'
ENV BLACKLIST_LIBRARY ''
ENV WHITELIST_LIBRARY ''
ENV BLACKLIST_LIBRARY_TYPE ''
ENV WHITELIST_LIBRARY_TYPE ''
ENV BLACKLIST_USERS ''
ENV WHITELIST_USERS ''
RUN apt-get update && \
apt-get install tini --yes --no-install-recommends && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
addgroup --system jellyplex_user && \
adduser --system --no-create-home jellyplex_user --ingroup jellyplex_user && \
mkdir -p /app && \
chown -R jellyplex_user:jellyplex_user /app
WORKDIR /app
COPY --chown=jellyplex_user:jellyplex_user ./requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=jellyplex_user:jellyplex_user . .
USER jellyplex_user
ENTRYPOINT ["/bin/tini", "--"]
CMD ["python", "-u", "main.py"]

194
README.md
View File

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

32
docker-compose.yml Normal file
View File

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

View File

@@ -1,9 +1,9 @@
import sys import sys
if __name__ == "__main__": if __name__ == "__main__":
# Check python version 3.6 or higher # Check python version 3.9 or higher
if not (3, 6) <= tuple(map(int, sys.version_info[:2])): if not (3, 9) <= tuple(map(int, sys.version_info[:2])):
print("This script requires Python 3.6 or higher") print("This script requires Python 3.9 or higher")
sys.exit(1) sys.exit(1)
from src.main import main from src.main import main

Binary file not shown.

View File

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

21
src/emby.py Normal file
View File

@@ -0,0 +1,21 @@
from src.jellyfin_emby import JellyfinEmby
class Emby(JellyfinEmby):
def __init__(self, baseurl, token):
authorization = (
"Emby , "
'Client="JellyPlex-Watched", '
'Device="script", '
'DeviceId="script", '
'Version="0.0.0"'
)
headers = {
"Accept": "application/json",
"X-Emby-Token": token,
"X-Emby-Authorization": authorization,
}
super().__init__(
server_type="Emby", baseurl=baseurl, token=token, headers=headers
)

View File

@@ -5,6 +5,7 @@ from dotenv import load_dotenv
load_dotenv(override=True) load_dotenv(override=True)
logfile = os.getenv("LOGFILE", "log.log") logfile = os.getenv("LOGFILE", "log.log")
markfile = os.getenv("MARKFILE", "mark.log")
def logger(message: str, log_type=0): def logger(message: str, log_type=0):
@@ -22,6 +23,10 @@ def logger(message: str, log_type=0):
output = f"[DEBUG]: {output}" output = f"[DEBUG]: {output}"
elif log_type == 4: elif log_type == 4:
output = f"[WARNING]: {output}" output = f"[WARNING]: {output}"
elif log_type == 5:
output = f"[MARK]: {output}"
elif log_type == 6:
output = f"[DRYRUN]: {output}"
else: else:
output = None output = None
@@ -31,6 +36,24 @@ def logger(message: str, log_type=0):
file.write(output + "\n") file.write(output + "\n")
def log_marked(
username: str, library: str, movie_show: str, episode: str = None, duration=None
):
if markfile is None:
return
output = f"{username}/{library}/{movie_show}"
if episode:
output += f"/{episode}"
if duration:
output += f"/{duration}"
file = open(f"{markfile}", "a", encoding="utf-8")
file.write(output + "\n")
# Reimplementation of distutils.util.strtobool due to it being deprecated # Reimplementation of distutils.util.strtobool due to it being deprecated
# Source: https://github.com/PostHog/posthog/blob/01e184c29d2c10c43166f1d40a334abbc3f99d8a/posthog/utils.py#L668 # Source: https://github.com/PostHog/posthog/blob/01e184c29d2c10c43166f1d40a334abbc3f99d8a/posthog/utils.py#L668
def str_to_bool(value: any) -> bool: def str_to_bool(value: any) -> bool:
@@ -41,9 +64,16 @@ def str_to_bool(value: any) -> bool:
# Search for nested element in list # Search for nested element in list
def contains_nested(element, lst): def contains_nested(element, lst):
if lst is None:
return None
for i, item in enumerate(lst): for i, item in enumerate(lst):
if item is None:
continue
if element in item: if element in item:
return i return i
elif element == item:
return i
return None return None
@@ -63,12 +93,25 @@ def search_mapping(dictionary: dict, key_value: str):
return None return None
def future_thread_executor(args: list, workers: int = -1): def future_thread_executor(
args: list, threads: int = None, override_threads: bool = False
):
futures_list = [] futures_list = []
results = [] results = []
if workers == -1: workers = min(int(os.getenv("MAX_THREADS", 32)), os.cpu_count() * 2)
workers = min(32, os.cpu_count() * 2) if threads:
workers = min(threads, workers)
if override_threads:
workers = threads
# If only one worker, run in main thread to avoid overhead
if workers == 1:
results = []
for arg in args:
results.append(arg[0](*arg[1:]))
return results
with ThreadPoolExecutor(max_workers=workers) as executor: with ThreadPoolExecutor(max_workers=workers) as executor:
for arg in args: for arg in args:

View File

@@ -1,893 +1,21 @@
import asyncio, aiohttp, traceback from src.jellyfin_emby import JellyfinEmby
from math import floor
from src.functions import logger, search_mapping, contains_nested class Jellyfin(JellyfinEmby):
from src.library import ( def __init__(self, baseurl, token):
check_skip_logic, authorization = (
generate_library_guids_dict, "MediaBrowser , "
) 'Client="JellyPlex-Watched", '
from src.watched import ( 'Device="script", '
combine_watched_dicts, 'DeviceId="script", '
) 'Version="5.2.0", '
f'Token="{token}"'
)
def get_movie_guids(movie): headers = {
if "ProviderIds" in movie: "Accept": "application/json",
logger( "Authorization": authorization,
f"Jellyfin: {movie['Name']} {movie['ProviderIds']} {movie['MediaSources']}", }
3,
) super().__init__(
else: server_type="Jellyfin", baseurl=baseurl, token=token, headers=headers
logger( )
f"Jellyfin: {movie['Name']} {movie['MediaSources']['Path']}",
3,
)
# Create a dictionary for the movie with its title
movie_guids = {"title": movie["Name"]}
# If the movie has provider IDs, add them to the dictionary
if "ProviderIds" in movie:
movie_guids.update({k.lower(): v for k, v in movie["ProviderIds"].items()})
# If the movie has media sources, add them to the dictionary
if "MediaSources" in movie:
movie_guids["locations"] = tuple(
[x["Path"].split("/")[-1] for x in movie["MediaSources"]]
)
movie_guids["status"] = {
"completed": movie["UserData"]["Played"],
# Convert ticks to milliseconds to match Plex
"time": floor(movie["UserData"]["PlaybackPositionTicks"] / 10000),
}
return movie_guids
def get_episode_guids(episode):
# Create a dictionary for the episode with its provider IDs and media sources
episode_dict = {k.lower(): v for k, v in episode["ProviderIds"].items()}
episode_dict["title"] = episode["Name"]
episode_dict["locations"] = tuple(
[x["Path"].split("/")[-1] for x in episode["MediaSources"]]
)
episode_dict["status"] = {
"completed": episode["UserData"]["Played"],
"time": floor(episode["UserData"]["PlaybackPositionTicks"] / 10000),
}
return episode_dict
class Jellyfin:
def __init__(self, baseurl, token):
self.baseurl = baseurl
self.token = token
if not self.baseurl:
raise Exception("Jellyfin baseurl not set")
if not self.token:
raise Exception("Jellyfin token not set")
self.users = asyncio.run(self.get_users())
async def query(self, query, query_type, session, identifiers=None):
try:
results = None
headers = {"Accept": "application/json", "X-Emby-Token": self.token}
authorization = (
"MediaBrowser , "
'Client="other", '
'Device="script", '
'DeviceId="script", '
'Version="0.0.0"'
)
headers["X-Emby-Authorization"] = authorization
if query_type == "get":
async with session.get(
self.baseurl + query, headers=headers
) as response:
if response.status != 200:
raise Exception(
f"Query failed with status {response.status} {response.reason}"
)
results = await response.json()
elif query_type == "post":
async with session.post(
self.baseurl + query, headers=headers
) as response:
if response.status != 200:
raise Exception(
f"Query failed with status {response.status} {response.reason}"
)
results = await response.json()
if not isinstance(results, list) and not isinstance(results, dict):
raise Exception("Query result is not of type list or dict")
# append identifiers to results
if identifiers:
results["Identifiers"] = identifiers
return results
except Exception as e:
logger(f"Jellyfin: Query {query_type} {query}\nResults {results}\n{e}", 2)
raise Exception(e)
async def get_users(self):
try:
users = {}
query_string = "/Users"
async with aiohttp.ClientSession() as session:
response = await self.query(query_string, "get", session)
# If response is not empty
if response:
for user in response:
users[user["Name"]] = user["Id"]
return users
except Exception as e:
logger(f"Jellyfin: Get users failed {e}", 2)
raise Exception(e)
async def get_user_library_watched(
self, user_name, user_id, library_type, library_id, library_title
):
try:
user_name = user_name.lower()
user_watched = {}
user_watched[user_name] = {}
logger(
f"Jellyfin: Generating watched for {user_name} in library {library_title}",
0,
)
async with aiohttp.ClientSession() as session:
# Movies
if library_type == "Movie":
user_watched[user_name][library_title] = []
watched = await self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
session,
)
in_progress = await self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
session,
)
for movie in watched["Items"]:
if "MediaSources" in movie and movie["MediaSources"] != {}:
logger(
f"Jellyfin: Adding {movie['Name']} to {user_name} watched list",
3,
)
# Get the movie's GUIDs
movie_guids = get_movie_guids(movie)
# Append the movie dictionary to the list for the given user and library
user_watched[user_name][library_title].append(movie_guids)
logger(
f"Jellyfin: Added {movie_guids} to {user_name} watched list",
3,
)
# Get all partially watched movies greater than 1 minute
for movie in in_progress["Items"]:
if "MediaSources" in movie and movie["MediaSources"] != {}:
if movie["UserData"]["PlaybackPositionTicks"] < 600000000:
continue
logger(
f"Jellyfin: Adding {movie['Name']} to {user_name} watched list",
3,
)
# Get the movie's GUIDs
movie_guids = get_movie_guids(movie)
# Append the movie dictionary to the list for the given user and library
user_watched[user_name][library_title].append(movie_guids)
logger(
f"Jellyfin: Added {movie_guids} to {user_name} watched list",
3,
)
# TV Shows
if library_type in ["Series", "Episode"]:
# Initialize an empty dictionary for the given user and library
user_watched[user_name][library_title] = {}
# Retrieve a list of watched TV shows
watched_shows = await self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
"get",
session,
)
# Filter the list of shows to only include those that have been partially or fully watched
watched_shows_filtered = []
for show in watched_shows["Items"]:
if "PlayedPercentage" in show["UserData"]:
if show["UserData"]["PlayedPercentage"] > 0:
watched_shows_filtered.append(show)
# Create a list of tasks to retrieve the seasons of each watched show
seasons_tasks = []
for show in watched_shows_filtered:
logger(
f"Jellyfin: Adding {show['Name']} to {user_name} watched list",
3,
)
show_guids = {
k.lower(): v for k, v in show["ProviderIds"].items()
}
show_guids["title"] = show["Name"]
show_guids["locations"] = tuple([show["Path"].split("/")[-1]])
show_guids = frozenset(show_guids.items())
show_identifiers = {
"show_guids": show_guids,
"show_id": show["Id"],
}
season_task = asyncio.ensure_future(
self.query(
f"/Shows/{show['Id']}/Seasons"
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,RecursiveItemCount",
"get",
session,
frozenset(show_identifiers.items()),
)
)
seasons_tasks.append(season_task)
# Retrieve the seasons for each watched show
seasons_watched = await asyncio.gather(*seasons_tasks)
# Filter the list of seasons to only include those that have been partially or fully watched
seasons_watched_filtered = []
for seasons in seasons_watched:
seasons_watched_filtered_dict = {}
seasons_watched_filtered_dict["Identifiers"] = seasons[
"Identifiers"
]
seasons_watched_filtered_dict["Items"] = []
for season in seasons["Items"]:
if "PlayedPercentage" in season["UserData"]:
if season["UserData"]["PlayedPercentage"] > 0:
seasons_watched_filtered_dict["Items"].append(
season
)
if seasons_watched_filtered_dict["Items"]:
seasons_watched_filtered.append(
seasons_watched_filtered_dict
)
# Create a list of tasks to retrieve the episodes of each watched season
episodes_tasks = []
for seasons in seasons_watched_filtered:
if len(seasons["Items"]) > 0:
for season in seasons["Items"]:
season_identifiers = dict(seasons["Identifiers"])
season_identifiers["season_id"] = season["Id"]
season_identifiers["season_name"] = season["Name"]
watched_task = asyncio.ensure_future(
self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsPlayed&Fields=ProviderIds,MediaSources",
"get",
session,
frozenset(season_identifiers.items()),
)
)
in_progress_task = asyncio.ensure_future(
self.query(
f"/Shows/{season_identifiers['show_id']}/Episodes"
+ f"?seasonId={season['Id']}&userId={user_id}&isPlaceHolder=false&Filters=IsResumable&Fields=ProviderIds,MediaSources",
"get",
session,
frozenset(season_identifiers.items()),
)
)
episodes_tasks.append(watched_task)
episodes_tasks.append(in_progress_task)
# Retrieve the episodes for each watched season
watched_episodes = await asyncio.gather(*episodes_tasks)
# Iterate through the watched episodes
for episodes in watched_episodes:
# If the season has any watched episodes
if len(episodes["Items"]) > 0:
# Create a dictionary for the season with its identifier and episodes
season_dict = {}
season_dict["Identifiers"] = dict(episodes["Identifiers"])
season_dict["Episodes"] = []
for episode in episodes["Items"]:
if (
"MediaSources" in episode
and episode["MediaSources"] is not {}
):
# If watched or watched more than a minute
if (
episode["UserData"]["Played"] == True
or episode["UserData"]["PlaybackPositionTicks"]
> 600000000
):
episode_dict = get_episode_guids(episode)
# Add the episode dictionary to the season's list of episodes
season_dict["Episodes"].append(episode_dict)
# Add the season dictionary to the show's list of seasons
if (
season_dict["Identifiers"]["show_guids"]
not in user_watched[user_name][library_title]
):
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
] = {}
if (
season_dict["Identifiers"]["season_name"]
not in user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
]
):
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
][season_dict["Identifiers"]["season_name"]] = []
user_watched[user_name][library_title][
season_dict["Identifiers"]["show_guids"]
][season_dict["Identifiers"]["season_name"]] = season_dict[
"Episodes"
]
logger(
f"Jellyfin: Added {season_dict['Episodes']} to {user_name} {season_dict['Identifiers']['show_guids']} watched list",
1,
)
logger(
f"Jellyfin: Got watched for {user_name} in library {library_title}", 1
)
if library_title in user_watched[user_name]:
logger(f"Jellyfin: {user_watched[user_name][library_title]}", 3)
return user_watched
except Exception as e:
logger(
f"Jellyfin: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
2,
)
logger(traceback.format_exc(), 2)
return {}
async def get_users_watched(
self,
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
):
try:
# Get all libraries
user_name = user_name.lower()
tasks_watched = []
tasks_libraries = []
async with aiohttp.ClientSession() as session:
libraries = await self.query(f"/Users/{user_id}/Views", "get", session)
for library in libraries["Items"]:
library_id = library["Id"]
library_title = library["Name"]
identifiers = {
"library_id": library_id,
"library_title": library_title,
}
task = asyncio.ensure_future(
self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
"get",
session,
identifiers=identifiers,
)
)
tasks_libraries.append(task)
libraries = await asyncio.gather(
*tasks_libraries, return_exceptions=True
)
for watched in libraries:
if len(watched["Items"]) == 0:
continue
library_id = watched["Identifiers"]["library_id"]
library_title = watched["Identifiers"]["library_title"]
# Get all library types excluding "Folder"
types = set(
[
x["Type"]
for x in watched["Items"]
if x["Type"] in ["Movie", "Series", "Episode"]
]
)
skip_reason = check_skip_logic(
library_title,
types,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
if skip_reason:
logger(
f"Jellyfin: Skipping library {library_title}: {skip_reason}",
1,
)
continue
# If there are multiple types in library raise error
if types is None or len(types) < 1:
all_types = set([x["Type"] for x in watched["Items"]])
logger(
f"Jellyfin: Skipping Library {library_title} found types: {types}, all types: {all_types}",
1,
)
continue
for library_type in types:
# Get watched for user
task = asyncio.ensure_future(
self.get_user_library_watched(
user_name,
user_id,
library_type,
library_id,
library_title,
)
)
tasks_watched.append(task)
watched = await asyncio.gather(*tasks_watched, return_exceptions=True)
return watched
except Exception as e:
logger(f"Jellyfin: Failed to get users watched, Error: {e}", 2)
raise Exception(e)
async def get_watched(
self,
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping=None,
):
try:
users_watched = {}
watched = []
for user_name, user_id in users.items():
watched.append(
self.get_users_watched(
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
)
watched = await asyncio.gather(*watched, return_exceptions=True)
for user_watched in watched:
user_watched_combine = combine_watched_dicts(user_watched)
for user, user_watched_temp in user_watched_combine.items():
if user not in users_watched:
users_watched[user] = {}
users_watched[user].update(user_watched_temp)
return users_watched
except Exception as e:
logger(f"Jellyfin: Failed to get watched, Error: {e}", 2)
raise Exception(e)
async def update_user_watched(
self, user_name, user_id, library, library_id, videos, dryrun
):
try:
logger(
f"Jellyfin: Updating watched for {user_name} in library {library}", 1
)
(
videos_shows_ids,
videos_episodes_ids,
videos_movies_ids,
) = generate_library_guids_dict(videos)
logger(
f"Jellyfin: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
1,
)
async with aiohttp.ClientSession() as session:
if videos_movies_ids:
jellyfin_search = await self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie",
"get",
session,
)
for jellyfin_video in jellyfin_search["Items"]:
movie_status = None
if "MediaSources" in jellyfin_video:
for movie_location in jellyfin_video["MediaSources"]:
if (
contains_nested(
movie_location["Path"].split("/")[-1],
videos_movies_ids["locations"],
)
is not None
):
for video in videos:
if (
contains_nested(
movie_location["Path"].split("/")[-1],
video["locations"],
)
is not None
):
movie_status = video["status"]
break
break
if not movie_status:
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()
]
):
for video in videos:
if (
movie_provider_id.lower()
in video[movie_provider_source.lower()]
):
movie_status = video["status"]
break
break
if movie_status:
jellyfin_video_id = jellyfin_video["Id"]
if movie_status["completed"]:
msg = f"{jellyfin_video['Name']} as watched for {user_name} in {library} for Jellyfin"
if not dryrun:
logger(f"Marking {msg}", 0)
await self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
"post",
session,
)
else:
logger(f"Dryrun {msg}", 0)
else:
# TODO add support for partially watched movies
msg = f"{jellyfin_video['Name']} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin"
if not dryrun:
pass
# logger(f"Marked {msg}", 0)
else:
pass
# logger(f"Dryrun {msg}", 0)
else:
logger(
f"Jellyfin: Skipping movie {jellyfin_video['Name']} as it is not in mark list for {user_name}",
1,
)
# TV Shows
if videos_shows_ids and videos_episodes_ids:
jellyfin_search = await self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series",
"get",
session,
)
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for jellyfin_show in jellyfin_shows:
show_found = False
if "Path" in jellyfin_show:
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
videos_shows_ids["locations"],
)
is not None
):
show_found = True
episode_videos = []
for show, seasons in videos.items():
show = {k: v for k, v in show}
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
show["locations"],
)
is not None
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
if not show_found:
for show_provider_source, show_provider_id in jellyfin_show[
"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
episode_videos = []
for show, seasons in videos.items():
show = {k: v for k, v in show}
if (
show_provider_id.lower()
in show[show_provider_source.lower()]
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
if show_found:
logger(
f"Jellyfin: Updating watched for {user_name} in library {library} for show {jellyfin_show['Name']}",
1,
)
jellyfin_show_id = jellyfin_show["Id"]
jellyfin_episodes = await self.query(
f"/Shows/{jellyfin_show_id}/Episodes"
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
session,
)
for jellyfin_episode in jellyfin_episodes["Items"]:
episode_status = None
if "MediaSources" in jellyfin_episode:
for episode_location in jellyfin_episode[
"MediaSources"
]:
if (
contains_nested(
episode_location["Path"].split("/")[-1],
videos_episodes_ids["locations"],
)
is not None
):
for episode in episode_videos:
if (
contains_nested(
episode_location["Path"].split(
"/"
)[-1],
episode["locations"],
)
is not None
):
episode_status = episode["status"]
break
break
if not episode_status:
for (
episode_provider_source,
episode_provider_id,
) in jellyfin_episode["ProviderIds"].items():
if (
episode_provider_source.lower()
in videos_episodes_ids
):
if (
episode_provider_id.lower()
in videos_episodes_ids[
episode_provider_source.lower()
]
):
for episode in episode_videos:
if (
episode_provider_id.lower()
in episode[
episode_provider_source.lower()
]
):
episode_status = episode[
"status"
]
break
break
if episode_status:
if episode_status["completed"]:
jellyfin_episode_id = jellyfin_episode["Id"]
msg = (
f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']}"
+ f" as watched for {user_name} in {library} for Jellyfin"
)
if not dryrun:
logger(f"Marked {msg}", 0)
await self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
"post",
session,
)
else:
logger(f"Dryrun {msg}", 0)
else:
# TODO add support for partially watched episodes
jellyfin_episode_id = jellyfin_episode["Id"]
msg = (
f"{jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode['IndexNumber']} {jellyfin_episode['Name']}"
+ f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library} for Jellyfin"
)
if not dryrun:
pass
# logger(f"Marked {msg}", 0)
else:
pass
# logger(f"Dryrun {msg}", 0)
else:
logger(
f"Jellyfin: Skipping episode {jellyfin_episode['Name']} as it is not in mark list for {user_name}",
3,
)
else:
logger(
f"Jellyfin: Skipping show {jellyfin_show['Name']} as it is not in mark list for {user_name}",
3,
)
if (
not videos_movies_ids
and not videos_shows_ids
and not videos_episodes_ids
):
logger(
f"Jellyfin: No videos to mark as watched for {user_name} in library {library}",
1,
)
except Exception as e:
logger(
f"Jellyfin: Error updating watched for {user_name} in library {library}, {e}",
2,
)
logger(traceback.format_exc(), 2)
raise Exception(e)
async def update_watched(
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
):
try:
tasks = []
async with aiohttp.ClientSession() as session:
for user, libraries in watched_list.items():
logger(f"Jellyfin: Updating for entry {user}, {libraries}", 1)
user_other = None
user_name = None
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
user_id = None
for key in self.users.keys():
if user.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
elif user_other and user_other.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
if not user_id:
logger(f"{user} {user_other} not found in Jellyfin", 2)
continue
jellyfin_libraries = await self.query(
f"/Users/{user_id}/Views", "get", session
)
jellyfin_libraries = [x for x in jellyfin_libraries["Items"]]
for library, videos in libraries.items():
library_other = None
if library_mapping:
if library in library_mapping.keys():
library_other = library_mapping[library]
elif library in library_mapping.values():
library_other = search_mapping(library_mapping, library)
if library.lower() not in [
x["Name"].lower() for x in jellyfin_libraries
]:
if library_other:
if library_other.lower() in [
x["Name"].lower() for x in jellyfin_libraries
]:
logger(
f"Jellyfin: Library {library} not found, but {library_other} found, using {library_other}",
1,
)
library = library_other
else:
logger(
f"Jellyfin: Library {library} or {library_other} not found in library list",
1,
)
continue
else:
logger(
f"Jellyfin: Library {library} not found in library list",
1,
)
continue
library_id = None
for jellyfin_library in jellyfin_libraries:
if jellyfin_library["Name"] == library:
library_id = jellyfin_library["Id"]
continue
if library_id:
task = self.update_user_watched(
user_name, user_id, library, library_id, videos, dryrun
)
tasks.append(task)
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger(f"Jellyfin: Error updating watched, {e}", 2)
raise Exception(e)

834
src/jellyfin_emby.py Normal file
View File

@@ -0,0 +1,834 @@
# Functions for Jellyfin and Emby
import traceback, os
from math import floor
from dotenv import load_dotenv
import requests
from packaging import version
from src.functions import (
logger,
search_mapping,
contains_nested,
log_marked,
str_to_bool,
)
from src.library import (
check_skip_logic,
generate_library_guids_dict,
)
from src.watched import (
combine_watched_dicts,
)
load_dotenv(override=True)
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
def get_guids(server_type, item):
if item.get("Name"):
guids = {"title": item.get("Name")}
else:
logger(f"{server_type}: Name not found in {item.get('Id')}", 1)
guids = {"title": None}
if "ProviderIds" in item:
guids.update({k.lower(): v for k, v in item["ProviderIds"].items()})
else:
logger(f"{server_type}: ProviderIds not found in {item.get('Name')}", 1)
if "MediaSources" in item:
guids["locations"] = tuple(
[x["Path"].split("/")[-1] for x in item["MediaSources"] if "Path" in x]
)
else:
logger(f"{server_type}: MediaSources not found in {item.get('Name')}", 1)
guids["locations"] = tuple()
if "UserData" in item:
guids["status"] = {
"completed": item["UserData"]["Played"],
# Convert ticks to milliseconds to match Plex
"time": floor(item["UserData"]["PlaybackPositionTicks"] / 10000),
}
else:
logger(f"{server_type}: UserData not found in {item.get('Name')}", 1)
guids["status"] = {}
return guids
def get_video_status(server_video, videos_ids, videos):
video_status = None
if generate_locations:
if "MediaSources" in server_video:
for video_location in server_video["MediaSources"]:
if "Path" in video_location:
if (
contains_nested(
video_location["Path"].split("/")[-1],
videos_ids["locations"],
)
is not None
):
for video in videos:
if (
contains_nested(
video_location["Path"].split("/")[-1],
video["locations"],
)
is not None
):
video_status = video["status"]
break
break
if generate_guids:
if not video_status:
for (
video_provider_source,
video_provider_id,
) in server_video["ProviderIds"].items():
if video_provider_source.lower() in videos_ids:
if (
video_provider_id.lower()
in videos_ids[video_provider_source.lower()]
):
for video in videos:
if video_provider_id.lower() in video.get(
video_provider_source.lower(), []
):
video_status = video["status"]
break
break
return video_status
class JellyfinEmby:
def __init__(self, server_type, baseurl, token, headers):
if server_type not in ["Jellyfin", "Emby"]:
raise Exception(f"Server type {server_type} not supported")
self.server_type = server_type
self.baseurl = baseurl
self.token = token
self.headers = headers
self.timeout = int(os.getenv("REQUEST_TIMEOUT", 300))
if not self.baseurl:
raise Exception(f"{self.server_type} baseurl not set")
if not self.token:
raise Exception(f"{self.server_type} token not set")
self.session = requests.Session()
self.version = version.parse(self.info(version=True))
self.users = self.get_users()
def query(self, query, query_type, identifiers=None, json=None):
try:
results = None
if query_type == "get":
response = self.session.get(
self.baseurl + query, headers=self.headers, timeout=self.timeout
)
if response.status_code not in [200, 204]:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
if response.status_code == 204:
results = None
else:
results = response.json()
elif query_type == "post":
response = self.session.post(
self.baseurl + query,
headers=self.headers,
json=json,
timeout=self.timeout,
)
if response.status_code not in [200, 204]:
raise Exception(
f"Query failed with status {response.status_code} {response.reason}"
)
if response.status_code == 204:
results = None
else:
results = response.json()
if results is not None:
if not isinstance(results, list) and not isinstance(results, dict):
raise Exception("Query result is not of type list or dict")
# append identifiers to results
if identifiers:
results["Identifiers"] = identifiers
return results
except Exception as e:
logger(
f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}",
2,
)
raise Exception(e)
def info(self, version=False) -> str:
try:
query_string = "/System/Info/Public"
response = self.query(query_string, "get")
if response:
# Return version only if requested
if version:
return response['Version']
return f"{self.server_type} {response['ServerName']}: {response['Version']}"
else:
return None
except Exception as e:
logger(f"{self.server_type}: Get server name failed {e}", 2)
raise Exception(e)
def get_users(self):
try:
users = {}
query_string = "/Users"
response = self.query(query_string, "get")
# If response is not empty
if response:
for user in response:
users[user["Name"]] = user["Id"]
return users
except Exception as e:
logger(f"{self.server_type}: Get users failed {e}", 2)
raise Exception(e)
def get_user_library_watched(
self, user_name, user_id, library_type, library_id, library_title
):
try:
user_name = user_name.lower()
user_watched = {}
user_watched[user_name] = {}
logger(
f"{self.server_type}: 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"
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
).get("Items", [])
in_progress = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
).get("Items", [])
for movie in watched + in_progress:
# Skip if theres no user data which means the movie has not been watched
if "UserData" not in movie:
continue
# Skip if theres no media tied to the movie
if "MediaSources" not in movie or movie["MediaSources"] == {}:
continue
# Skip if not watched or watched less than a minute
if (
movie["UserData"]["Played"] == True
or movie["UserData"]["PlaybackPositionTicks"] > 600000000
):
logger(
f"{self.server_type}: Adding {movie.get('Name')} to {user_name} watched list",
3,
)
# Get the movie's GUIDs
movie_guids = get_guids(self.server_type, movie)
# Append the movie dictionary to the list for the given user and library
user_watched[user_name][library_title].append(movie_guids)
logger(
f"{self.server_type}: Added {movie_guids} to {user_name} watched list",
3,
)
# TV Shows
if library_type in ["Series", "Episode"]:
# Initialize an empty dictionary for the given user and library
user_watched[user_name][library_title] = {}
# Retrieve a list of watched TV shows
watched_shows = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount",
"get",
).get("Items", [])
# Filter the list of shows to only include those that have been partially or fully watched
watched_shows_filtered = []
for show in watched_shows:
if "UserData" not in show:
continue
if "PlayedPercentage" in show["UserData"]:
if show["UserData"]["PlayedPercentage"] > 0:
watched_shows_filtered.append(show)
# Retrieve the watched/partially watched list of episodes of each watched show
for show in watched_shows_filtered:
logger(
f"{self.server_type}: Adding {show.get('Name')} to {user_name} watched list",
3,
)
show_guids = {k.lower(): v for k, v in show["ProviderIds"].items()}
show_guids["title"] = show["Name"]
show_guids["locations"] = (
tuple([show["Path"].split("/")[-1]])
if "Path" in show
else tuple()
)
show_display_name = (
show_guids["title"]
if show_guids["title"]
else show_guids["locations"]
)
show_guids = frozenset(show_guids.items())
show_episodes = self.query(
f"/Shows/{show['Id']}/Episodes"
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources",
"get",
).get("Items", [])
# Iterate through the episodes
# Create a list to store the episodes
mark_episodes_list = []
for episode in show_episodes:
if "UserData" not in episode:
continue
if (
"MediaSources" not in episode
or episode["MediaSources"] == {}
):
continue
# If watched or watched more than a minute
if (
episode["UserData"]["Played"] == True
or episode["UserData"]["PlaybackPositionTicks"] > 600000000
):
episode_guids = get_guids(self.server_type, episode)
mark_episodes_list.append(episode_guids)
if mark_episodes_list:
# Add the show dictionary to the user's watched list
if show_guids not in user_watched[user_name][library_title]:
user_watched[user_name][library_title][show_guids] = []
user_watched[user_name][library_title][
show_guids
] = mark_episodes_list
for episode in mark_episodes_list:
logger(
f"{self.server_type}: Added {episode} to {user_name} {show_display_name} watched list",
1,
)
logger(
f"{self.server_type}: Got watched for {user_name} in library {library_title}",
1,
)
if library_title in user_watched[user_name]:
logger(
f"{self.server_type}: {user_watched[user_name][library_title]}", 3
)
return user_watched
except Exception as e:
logger(
f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}",
2,
)
logger(traceback.format_exc(), 2)
return {}
def get_users_watched(
self,
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
):
try:
# Get all libraries
user_name = user_name.lower()
watched = []
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"]
identifiers = {
"library_id": library_id,
"library_title": library_title,
}
libraries.append(
self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&Recursive=True&excludeItemTypes=Folder&limit=100",
"get",
identifiers=identifiers,
)
)
for library in libraries:
if len(library["Items"]) == 0:
continue
library_id = library["Identifiers"]["library_id"]
library_title = library["Identifiers"]["library_title"]
# Get all library types excluding "Folder"
types = set(
[
x["Type"]
for x in library["Items"]
if x["Type"] in ["Movie", "Series", "Episode"]
]
)
skip_reason = check_skip_logic(
library_title,
types,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
if skip_reason:
logger(
f"{self.server_type}: Skipping library {library_title}: {skip_reason}",
1,
)
continue
# If there are multiple types in library raise error
if types is None or len(types) < 1:
all_types = set([x["Type"] for x in library["Items"]])
logger(
f"{self.server_type}: Skipping Library {library_title} found types: {types}, all types: {all_types}",
1,
)
continue
for library_type in types:
# Get watched for user
watched.append(
self.get_user_library_watched(
user_name,
user_id,
library_type,
library_id,
library_title,
)
)
return watched
except Exception as e:
logger(f"{self.server_type}: Failed to get users watched, 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 = {}
watched = []
for user_name, user_id in users.items():
watched.append(
self.get_users_watched(
user_name,
user_id,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
)
for user_watched in watched:
user_watched_combine = combine_watched_dicts(user_watched)
for user, user_watched_temp in user_watched_combine.items():
if user not in users_watched:
users_watched[user] = {}
users_watched[user].update(user_watched_temp)
return users_watched
except Exception as e:
logger(f"{self.server_type}: Failed to get watched, Error: {e}", 2)
raise Exception(e)
def update_user_watched(
self, user_name, user_id, library, library_id, videos, dryrun
):
try:
logger(
f"{self.server_type}: Updating watched for {user_name} in library {library}",
1,
)
(
videos_shows_ids,
videos_episodes_ids,
videos_movies_ids,
) = generate_library_guids_dict(videos)
if (
not videos_movies_ids
and not videos_shows_ids
and not videos_episodes_ids
):
logger(
f"{self.server_type}: No videos to mark as watched for {user_name} in library {library}",
1,
)
return
logger(
f"{self.server_type}: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
1,
)
if videos_movies_ids:
jellyfin_search = self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie",
"get",
)
for jellyfin_video in jellyfin_search["Items"]:
movie_status = get_video_status(
jellyfin_video, videos_movies_ids, videos
)
if movie_status:
jellyfin_video_id = jellyfin_video["Id"]
if movie_status["completed"]:
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library}"
if not dryrun:
logger(msg, 5)
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
"post",
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_video.get("Name"),
)
else:
# Handle partially watched movies not supported in jellyfin < 10.9.0
if self.server_type == "Jellyfin" and self.version < version.parse("10.9.0"):
logger(f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as partially watched not supported in Jellyfin < 10.9.0", 4)
else:
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(movie_status['time'] / 60_000)} minutes for {user_name} in {library}"
if not dryrun:
logger(msg, 5)
playback_position_payload = {
"PlaybackPositionTicks": movie_status["time"]
* 10_000,
}
self.query(
f"/Users/{user_id}/Items/{jellyfin_video_id}/UserData",
"post",
json=playback_position_payload,
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_video.get("Name"),
duration=floor(movie_status["time"] / 60_000),
)
else:
logger(
f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}",
3,
)
# TV Shows
if videos_shows_ids and videos_episodes_ids:
jellyfin_search = self.query(
f"/Users/{user_id}/Items"
+ f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}"
+ "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series",
"get",
)
jellyfin_shows = [x for x in jellyfin_search["Items"]]
for jellyfin_show in jellyfin_shows:
show_found = False
episode_videos = []
if generate_locations:
if "Path" in jellyfin_show:
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
videos_shows_ids["locations"],
)
is not None
):
show_found = True
for shows, episodes in videos.items():
show = {k: v for k, v in shows}
if (
contains_nested(
jellyfin_show["Path"].split("/")[-1],
show["locations"],
)
is not None
):
for episode in episodes:
episode_videos.append(episode)
break
if generate_guids:
if not show_found:
for show_provider_source, show_provider_id in jellyfin_show[
"ProviderIds"
].items():
if show_provider_source.lower() in videos_shows_ids:
if (
show_provider_id.lower()
in videos_shows_ids[
show_provider_source.lower()
]
):
show_found = True
for show, episodes in videos.items():
show = {k: v for k, v in show}
if show_provider_id.lower() in show.get(
show_provider_source.lower(), []
):
for episode in episodes:
episode_videos.append(episode)
break
if show_found:
logger(
f"{self.server_type}: Updating watched for {user_name} in library {library} for show {jellyfin_show.get('Name')}",
1,
)
jellyfin_show_id = jellyfin_show["Id"]
jellyfin_episodes = self.query(
f"/Shows/{jellyfin_show_id}/Episodes"
+ f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources",
"get",
)
for jellyfin_episode in jellyfin_episodes["Items"]:
episode_status = get_video_status(
jellyfin_episode, videos_episodes_ids, episode_videos
)
if episode_status:
jellyfin_episode_id = jellyfin_episode["Id"]
if episode_status["completed"]:
msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as watched for {user_name} in {library}"
)
if not dryrun:
logger(msg, 5)
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
"post",
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_episode.get("SeriesName"),
jellyfin_episode.get("Name"),
)
else:
# Handle partially watched episodes not supported in jellyfin < 10.9.0
if self.server_type == "Jellyfin" and self.version < version.parse("10.9.0"):
logger(f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as partially watched not supported in Jellyfin < 10.9.0", 4)
else:
msg = (
f"{self.server_type}: {jellyfin_episode['SeriesName']} {jellyfin_episode['SeasonName']} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
+ f" as partially watched for {floor(episode_status['time'] / 60_000)} minutes for {user_name} in {library}"
)
if not dryrun:
logger(msg, 5)
playback_position_payload = {
"PlaybackPositionTicks": episode_status[
"time"
]
* 10_000,
}
self.query(
f"/Users/{user_id}/Items/{jellyfin_episode_id}/UserData",
"post",
json=playback_position_payload,
)
else:
logger(msg, 6)
log_marked(
user_name,
library,
jellyfin_episode.get("SeriesName"),
jellyfin_episode.get("Name"),
duration=floor(episode_status["time"] / 60_000),
)
else:
logger(
f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}",
3,
)
else:
logger(
f"{self.server_type}: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}",
3,
)
except Exception as e:
logger(
f"{self.server_type}: Error updating watched for {user_name} in library {library}, {e}",
2,
)
logger(traceback.format_exc(), 2)
raise Exception(e)
def update_watched(
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
):
try:
for user, libraries in watched_list.items():
logger(f"{self.server_type}: Updating for entry {user}, {libraries}", 1)
user_other = None
user_name = None
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
user_id = None
for key in self.users:
if user.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
elif user_other and user_other.lower() == key.lower():
user_id = self.users[key]
user_name = key
break
if not user_id:
logger(f"{user} {user_other} not found in Jellyfin", 2)
continue
jellyfin_libraries = self.query(
f"/Users/{user_id}/Views",
"get",
)
jellyfin_libraries = [x for x in jellyfin_libraries["Items"]]
for library, videos in libraries.items():
library_other = None
if library_mapping:
if library in library_mapping.keys():
library_other = library_mapping[library]
elif library in library_mapping.values():
library_other = search_mapping(library_mapping, library)
if library.lower() not in [
x["Name"].lower() for x in jellyfin_libraries
]:
if library_other:
if library_other.lower() in [
x["Name"].lower() for x in jellyfin_libraries
]:
logger(
f"{self.server_type}: Library {library} not found, but {library_other} found, using {library_other}",
1,
)
library = library_other
else:
logger(
f"{self.server_type}: Library {library} or {library_other} not found in library list",
1,
)
continue
else:
logger(
f"{self.server_type}: Library {library} not found in library list",
1,
)
continue
library_id = None
for jellyfin_library in jellyfin_libraries:
if jellyfin_library["Name"] == library:
library_id = jellyfin_library["Id"]
continue
if library_id:
self.update_user_watched(
user_name, user_id, library, library_id, videos, dryrun
)
except Exception as e:
logger(f"{self.server_type}: Error updating watched, {e}", 2)
raise Exception(e)

View File

@@ -158,7 +158,7 @@ def show_title_dict(user_list: dict):
return show_output_dict return show_output_dict
except Exception: except Exception:
logger("Generating show_output_dict failed, skipping", 1) logger("Skipping show_output_dict ", 1)
return {} return {}
@@ -168,52 +168,58 @@ def episode_title_dict(user_list: dict):
episode_output_dict["completed"] = [] episode_output_dict["completed"] = []
episode_output_dict["time"] = [] episode_output_dict["time"] = []
episode_output_dict["locations"] = [] episode_output_dict["locations"] = []
episode_output_dict["show"] = []
episode_counter = 0 # Initialize a counter for the current episode position episode_counter = 0 # Initialize a counter for the current episode position
# Iterate through the shows, seasons, and episodes in user_list # Iterate through the shows and episodes in user_list
for show in user_list: for show in user_list:
for season in user_list[show]:
for episode in user_list[show][season]:
# Iterate through the keys and values in each episode
for episode_key, episode_value in episode.items():
# If the key is not "status", add the key to episode_output_dict if it doesn't exist
if episode_key != "status":
if episode_key.lower() not in episode_output_dict:
# Initialize the list with None values up to the current episode position
episode_output_dict[episode_key.lower()] = [
None
] * episode_counter
# If the key is "locations", append each location to the list for episode in user_list[show]:
if episode_key == "locations": # Add the show title to the episode_output_dict if it doesn't exist
episode_output_dict[episode_key.lower()].append( if "show" not in episode_output_dict:
episode_value episode_output_dict["show"] = [None] * episode_counter
)
# If the key is "status", append the "completed" and "time" values # Add the show title to the episode_output_dict
elif episode_key == "status": episode_output_dict["show"].append(dict(show))
episode_output_dict["completed"].append(
episode_value["completed"]
)
episode_output_dict["time"].append(episode_value["time"])
# For other keys, append the value to the list # Iterate through the keys and values in each episode
else: for episode_key, episode_value in episode.items():
episode_output_dict[episode_key.lower()].append( # If the key is not "status", add the key to episode_output_dict if it doesn't exist
episode_value.lower() if episode_key != "status":
) if episode_key.lower() not in episode_output_dict:
# Initialize the list with None values up to the current episode position
episode_output_dict[episode_key.lower()] = [
None
] * episode_counter
# Increment the episode_counter # If the key is "locations", append each location to the list
episode_counter += 1 if episode_key == "locations":
episode_output_dict[episode_key.lower()].append(episode_value)
# Extend the lists in episode_output_dict with None values to match the current episode_counter # If the key is "status", append the "completed" and "time" values
for key in episode_output_dict: elif episode_key == "status":
if len(episode_output_dict[key]) < episode_counter: episode_output_dict["completed"].append(
episode_output_dict[key].append(None) episode_value["completed"]
)
episode_output_dict["time"].append(episode_value["time"])
# For other keys, append the value to the list
else:
episode_output_dict[episode_key.lower()].append(
episode_value.lower()
)
# Increment the episode_counter
episode_counter += 1
# Extend the lists in episode_output_dict with None values to match the current episode_counter
for key in episode_output_dict:
if len(episode_output_dict[key]) < episode_counter:
episode_output_dict[key].append(None)
return episode_output_dict return episode_output_dict
except Exception: except Exception:
logger("Generating episode_output_dict failed, skipping", 1) logger("Skipping episode_output_dict", 1)
return {} return {}
@@ -246,7 +252,7 @@ def movies_title_dict(user_list: dict):
return movies_output_dict return movies_output_dict
except Exception: except Exception:
logger("Generating movies_output_dict failed, skipping", 1) logger("Skipping movies_output_dict failed", 1)
return {} return {}

View File

@@ -1,4 +1,4 @@
import os, traceback, json, asyncio import os, traceback, json
from dotenv import load_dotenv from dotenv import load_dotenv
from time import sleep, perf_counter from time import sleep, perf_counter
@@ -19,6 +19,7 @@ from src.black_white import setup_black_white_lists
from src.plex import Plex from src.plex import Plex
from src.jellyfin import Jellyfin from src.jellyfin import Jellyfin
from src.emby import Emby
load_dotenv(override=True) load_dotenv(override=True)
@@ -28,6 +29,8 @@ def setup_users(
): ):
server_1_users = generate_user_list(server_1) server_1_users = generate_user_list(server_1)
server_2_users = generate_user_list(server_2) server_2_users = generate_user_list(server_2)
logger(f"Server 1 users: {server_1_users}", 1)
logger(f"Server 2 users: {server_2_users}", 1)
users = combine_user_lists(server_1_users, server_2_users, user_mapping) users = combine_user_lists(server_1_users, server_2_users, user_mapping)
logger(f"User list that exist on both servers {users}", 1) logger(f"User list that exist on both servers {users}", 1)
@@ -40,21 +43,70 @@ def setup_users(
# Check if users is none or empty # Check if users is none or empty
if output_server_1_users is None or len(output_server_1_users) == 0: if output_server_1_users is None or len(output_server_1_users) == 0:
raise Exception( logger(
f"No users found for server 1 {server_1[0]}, users found {users}, filtered users {users_filtered}, server 1 users {server_1[1].users}" f"No users found for server 1 {server_1[0]}, users: {server_1_users}, overlapping users {users}, filtered users {users_filtered}, server 1 users {server_1[1].users}"
) )
if output_server_2_users is None or len(output_server_2_users) == 0: if output_server_2_users is None or len(output_server_2_users) == 0:
raise Exception( logger(
f"No users found for server 2 {server_2[0]}, users found {users} filtered users {users_filtered}, server 2 users {server_2[1].users}" f"No users found for server 2 {server_2[0]}, users: {server_2_users}, overlapping users {users} filtered users {users_filtered}, server 2 users {server_2[1].users}"
) )
if (
output_server_1_users is None
or len(output_server_1_users) == 0
or output_server_2_users is None
or len(output_server_2_users) == 0
):
raise Exception("No users found for one or both servers")
logger(f"Server 1 users: {output_server_1_users}", 1) logger(f"Server 1 users: {output_server_1_users}", 1)
logger(f"Server 2 users: {output_server_2_users}", 1) logger(f"Server 2 users: {output_server_2_users}", 1)
return output_server_1_users, output_server_2_users return output_server_1_users, output_server_2_users
def jellyfin_emby_server_connection(server_baseurl, server_token, server_type):
servers = []
server_baseurl = server_baseurl.split(",")
server_token = server_token.split(",")
if len(server_baseurl) != len(server_token):
raise Exception(
f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries"
)
for i, baseurl in enumerate(server_baseurl):
baseurl = baseurl.strip()
if baseurl[-1] == "/":
baseurl = baseurl[:-1]
if server_type == "jellyfin":
server = Jellyfin(baseurl=baseurl, token=server_token[i].strip())
servers.append(
(
"jellyfin",
server,
)
)
elif server_type == "emby":
server = Emby(baseurl=baseurl, token=server_token[i].strip())
servers.append(
(
"emby",
server,
)
)
else:
raise Exception("Unknown server type")
logger(f"{server_type} Server {i} info: {server.info()}", 3)
return servers
def generate_server_connections(): def generate_server_connections():
servers = [] servers = []
@@ -75,17 +127,21 @@ def generate_server_connections():
) )
for i, url in enumerate(plex_baseurl): for i, url in enumerate(plex_baseurl):
server = Plex(
baseurl=url.strip(),
token=plex_token[i].strip(),
username=None,
password=None,
servername=None,
ssl_bypass=ssl_bypass,
)
logger(f"Plex Server {i} info: {server.info()}", 3)
servers.append( servers.append(
( (
"plex", "plex",
Plex( server,
baseurl=url.strip(),
token=plex_token[i].strip(),
username=None,
password=None,
servername=None,
ssl_bypass=ssl_bypass,
),
) )
) )
@@ -102,17 +158,20 @@ def generate_server_connections():
) )
for i, username in enumerate(plex_username): for i, username in enumerate(plex_username):
server = Plex(
baseurl=None,
token=None,
username=username.strip(),
password=plex_password[i].strip(),
servername=plex_servername[i].strip(),
ssl_bypass=ssl_bypass,
)
logger(f"Plex Server {i} info: {server.info()}", 3)
servers.append( servers.append(
( (
"plex", "plex",
Plex( server,
baseurl=None,
token=None,
username=username.strip(),
password=plex_password[i].strip(),
servername=plex_servername[i].strip(),
ssl_bypass=ssl_bypass,
),
) )
) )
@@ -120,121 +179,84 @@ def generate_server_connections():
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None) jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
if jellyfin_baseurl and jellyfin_token: if jellyfin_baseurl and jellyfin_token:
jellyfin_baseurl = jellyfin_baseurl.split(",") servers.extend(
jellyfin_token = jellyfin_token.split(",") jellyfin_emby_server_connection(
jellyfin_baseurl, jellyfin_token, "jellyfin"
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): emby_baseurl = os.getenv("EMBY_BASEURL", None)
baseurl = baseurl.strip() emby_token = os.getenv("EMBY_TOKEN", None)
if baseurl[-1] == "/":
baseurl = baseurl[:-1] if emby_baseurl and emby_token:
servers.append( servers.extend(
( jellyfin_emby_server_connection(emby_baseurl, emby_token, "emby")
"jellyfin", )
Jellyfin(baseurl=baseurl, token=jellyfin_token[i].strip()),
)
)
return servers return servers
def get_server_watched(
server_connection: list,
users: dict,
blacklist_library: list,
whitelist_library: list,
blacklist_library_type: list,
whitelist_library_type: list,
library_mapping: dict,
):
if server_connection[0] == "plex":
return server_connection[1].get_watched(
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
elif server_connection[0] == "jellyfin":
return asyncio.run(
server_connection[1].get_watched(
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
)
def update_server_watched(
server_connection: list,
server_watched_filtered: dict,
user_mapping: dict,
library_mapping: dict,
dryrun: bool,
):
if server_connection[0] == "plex":
server_connection[1].update_watched(
server_watched_filtered, user_mapping, library_mapping, dryrun
)
elif server_connection[0] == "jellyfin":
asyncio.run(
server_connection[1].update_watched(
server_watched_filtered, user_mapping, library_mapping, dryrun
)
)
def should_sync_server(server_1_type, server_2_type): def should_sync_server(server_1_type, server_2_type):
sync_from_plex_to_jellyfin = str_to_bool( sync_from_plex_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True") os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True")
) )
sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True"))
sync_from_plex_to_emby = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_EMBY", "True"))
sync_from_jelly_to_plex = str_to_bool( sync_from_jelly_to_plex = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True") os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True")
) )
sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True"))
sync_from_jelly_to_jellyfin = str_to_bool( sync_from_jelly_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True") os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True")
) )
sync_from_jelly_to_emby = str_to_bool(
os.getenv("SYNC_FROM_JELLYFIN_TO_EMBY", "True")
)
if ( sync_from_emby_to_plex = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_PLEX", "True"))
server_1_type == "plex" sync_from_emby_to_jellyfin = str_to_bool(
and server_2_type == "plex" os.getenv("SYNC_FROM_EMBY_TO_JELLYFIN", "True")
and not sync_from_plex_to_plex )
): sync_from_emby_to_emby = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_EMBY", "True"))
logger("Sync between plex and plex is disabled", 1)
return False
if ( if server_1_type == "plex":
server_1_type == "plex" if server_2_type == "jellyfin" and not sync_from_plex_to_jellyfin:
and server_2_type == "jellyfin" logger("Sync from plex -> jellyfin is disabled", 1)
and not sync_from_jelly_to_plex return False
):
logger("Sync from jellyfin to plex disabled", 1)
return False
if ( if server_2_type == "emby" and not sync_from_plex_to_emby:
server_1_type == "jellyfin" logger("Sync from plex -> emby is disabled", 1)
and server_2_type == "jellyfin" return False
and not sync_from_jelly_to_jellyfin
):
logger("Sync between jellyfin and jellyfin is disabled", 1)
return False
if ( if server_2_type == "plex" and not sync_from_plex_to_plex:
server_1_type == "jellyfin" logger("Sync from plex -> plex is disabled", 1)
and server_2_type == "plex" return False
and not sync_from_plex_to_jellyfin
): if server_1_type == "jellyfin":
logger("Sync from plex to jellyfin is disabled", 1) if server_2_type == "plex" and not sync_from_jelly_to_plex:
return False logger("Sync from jellyfin -> plex is disabled", 1)
return False
if server_2_type == "jellyfin" and not sync_from_jelly_to_jellyfin:
logger("Sync from jellyfin -> jellyfin is disabled", 1)
return False
if server_2_type == "emby" and not sync_from_jelly_to_emby:
logger("Sync from jellyfin -> emby is disabled", 1)
return False
if server_1_type == "emby":
if server_2_type == "plex" and not sync_from_emby_to_plex:
logger("Sync from emby -> plex is disabled", 1)
return False
if server_2_type == "jellyfin" and not sync_from_emby_to_jellyfin:
logger("Sync from emby -> jellyfin is disabled", 1)
return False
if server_2_type == "emby" and not sync_from_emby_to_emby:
logger("Sync from emby -> emby is disabled", 1)
return False
return True return True
@@ -296,6 +318,15 @@ def main_loop():
# Start server_2 at the next server in the list # Start server_2 at the next server in the list
for server_2 in servers[servers.index(server_1) + 1 :]: for server_2 in servers[servers.index(server_1) + 1 :]:
# Check if server 1 and server 2 are going to be synced in either direction, skip if not
if not should_sync_server(
server_1[0], server_2[0]
) and not should_sync_server(server_2[0], server_1[0]):
continue
logger(f"Server 1: {server_1[0].capitalize()}: {server_1[1].info()}", 0)
logger(f"Server 2: {server_2[0].capitalize()}: {server_2[1].info()}", 0)
# Create users list # Create users list
logger("Creating users list", 1) logger("Creating users list", 1)
server_1_users, server_2_users = setup_users( server_1_users, server_2_users = setup_users(
@@ -303,8 +334,7 @@ def main_loop():
) )
logger("Creating watched lists", 1) logger("Creating watched lists", 1)
server_1_watched = get_server_watched( server_1_watched = server_1[1].get_watched(
server_1,
server_1_users, server_1_users,
blacklist_library, blacklist_library,
whitelist_library, whitelist_library,
@@ -313,8 +343,8 @@ def main_loop():
library_mapping, library_mapping,
) )
logger("Finished creating watched list server 1", 1) logger("Finished creating watched list server 1", 1)
server_2_watched = get_server_watched(
server_2, server_2_watched = server_2[1].get_watched(
server_2_users, server_2_users,
blacklist_library, blacklist_library,
whitelist_library, whitelist_library,
@@ -323,6 +353,7 @@ def main_loop():
library_mapping, library_mapping,
) )
logger("Finished creating watched list server 2", 1) logger("Finished creating watched list server 2", 1)
logger(f"Server 1 watched: {server_1_watched}", 3) logger(f"Server 1 watched: {server_1_watched}", 3)
logger(f"Server 2 watched: {server_2_watched}", 3) logger(f"Server 2 watched: {server_2_watched}", 3)
@@ -345,18 +376,18 @@ def main_loop():
1, 1,
) )
if should_sync_server(server_1[0], server_2[0]): if should_sync_server(server_2[0], server_1[0]):
update_server_watched( logger(f"Syncing {server_2[1].info()} -> {server_1[1].info()}", 0)
server_1, server_1[1].update_watched(
server_2_watched_filtered, server_2_watched_filtered,
user_mapping, user_mapping,
library_mapping, library_mapping,
dryrun, dryrun,
) )
if should_sync_server(server_2[0], server_1[0]): if should_sync_server(server_1[0], server_2[0]):
update_server_watched( logger(f"Syncing {server_1[1].info()} -> {server_2[1].info()}", 0)
server_2, server_2[1].update_watched(
server_1_watched_filtered, server_1_watched_filtered,
user_mapping, user_mapping,
library_mapping, library_mapping,
@@ -400,5 +431,7 @@ def main():
sleep(sleep_duration) sleep(sleep_duration)
except KeyboardInterrupt: except KeyboardInterrupt:
if len(times) > 0:
logger(f"Average time: {sum(times) / len(times)}", 0)
logger("Exiting", log_type=0) logger("Exiting", log_type=0)
os._exit(0) os._exit(0)

View File

@@ -1,7 +1,13 @@
import re, requests, os, traceback import os, requests, traceback
from dotenv import load_dotenv
from typing import Dict, Union, FrozenSet
from urllib3.poolmanager import PoolManager from urllib3.poolmanager import PoolManager
from math import floor from math import floor
from requests.adapters import HTTPAdapter as RequestsHTTPAdapter
from plexapi.video import Show, Episode, Movie
from plexapi.server import PlexServer from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount
@@ -10,6 +16,8 @@ from src.functions import (
search_mapping, search_mapping,
future_thread_executor, future_thread_executor,
contains_nested, contains_nested,
log_marked,
str_to_bool,
) )
from src.library import ( from src.library import (
check_skip_logic, check_skip_logic,
@@ -17,8 +25,14 @@ from src.library import (
) )
load_dotenv(override=True)
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
# Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186 # Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
class HostNameIgnoringAdapter(requests.adapters.HTTPAdapter): class HostNameIgnoringAdapter(RequestsHTTPAdapter):
def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs): def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs):
self.poolmanager = PoolManager( self.poolmanager = PoolManager(
num_pools=connections, num_pools=connections,
@@ -29,107 +43,92 @@ class HostNameIgnoringAdapter(requests.adapters.HTTPAdapter):
) )
def get_movie_guids(video, completed=True): def extract_guids_from_item(item: Union[Movie, Show, Episode]) -> Dict[str, str]:
logger(f"Plex: {video.title} {video.guids} {video.locations}", 3) # If GENERATE_GUIDS is set to False, then return an empty dict
if not generate_guids:
return {}
movie_guids = {} guids: Dict[str, str] = dict(
try: guid.id.split("://")
for guid in video.guids: for guid in item.guids
# Extract source and id from guid.id if guid.id is not None and len(guid.id.strip()) > 0
m = re.match(r"(.*)://(.*)", guid.id) )
guid_source, guid_id = m.group(1).lower(), m.group(2)
movie_guids[guid_source] = guid_id
except Exception:
logger(f"Plex: Failed to get guids for {video.title}, Using location only", 1)
movie_guids["title"] = video.title if len(guids) == 0:
movie_guids["locations"] = tuple([x.split("/")[-1] for x in video.locations])
movie_guids["status"] = {
"completed": completed,
"time": video.viewOffset,
}
return movie_guids
def get_episode_guids(episode, show, completed=True):
episode_guids_temp = {}
try:
for guid in episode.guids:
# Extract after :// from guid.id
m = re.match(r"(.*)://(.*)", guid.id)
guid_source, guid_id = m.group(1).lower(), m.group(2)
episode_guids_temp[guid_source] = guid_id
except Exception:
logger( logger(
f"Plex: Failed to get guids for {episode.title} in {show.title}, Using location only", f"Plex: Failed to get any guids for {item.title}",
1, 1,
) )
episode_guids_temp["title"] = episode.title return guids
episode_guids_temp["locations"] = tuple(
[x.split("/")[-1] for x in episode.locations]
)
episode_guids_temp["status"] = {
"completed": completed,
"time": episode.viewOffset,
}
return episode_guids_temp
def get_user_library_watched_show(show): def get_guids(item: Union[Movie, Episode], completed=True):
if not item.locations:
logger(
f"Plex: {item.title} has no locations",
1,
)
if not item.guids:
logger(
f"Plex: {item.title} has no guids",
1,
)
return {
"title": item.title,
"locations": (
tuple([location.split("/")[-1] for location in item.locations])
if generate_locations
else tuple()
),
"status": {
"completed": completed,
"time": item.viewOffset,
},
} | extract_guids_from_item(
item
) # Merge the metadata and guid dictionaries
def get_user_library_watched_show(show, process_episodes, threads=None):
try: try:
show_guids = {} show_guids: FrozenSet = frozenset(
try: (
for show_guid in show.guids: {
# Extract source and id from guid.id "title": show.title,
m = re.match(r"(.*)://(.*)", show_guid.id) "locations": (
show_guid_source, show_guid_id = m.group(1).lower(), m.group(2) tuple([location.split("/")[-1] for location in show.locations])
show_guids[show_guid_source] = show_guid_id if generate_locations
except Exception: else tuple()
logger( ),
f"Plex: Failed to get guids for {show.title}, Using location only", 1 }
) | extract_guids_from_item(show)
).items() # Merge the metadata and guid dictionaries
)
show_guids["title"] = show.title episode_guids_args = []
show_guids["locations"] = tuple([x.split("/")[-1] for x in show.locations])
show_guids = frozenset(show_guids.items())
# Get all watched episodes for show for episode in process_episodes:
episode_guids = {} episode_guids_args.append([get_guids, episode, episode.isWatched])
watched = show.watched()
for episode in show.episodes(): episode_guids_results = future_thread_executor(
if episode in watched: episode_guids_args, threads=threads
if episode.parentTitle not in episode_guids: )
episode_guids[episode.parentTitle] = []
episode_guids[episode.parentTitle].append( episode_guids = []
get_episode_guids(episode, show, completed=True) for index, episode in enumerate(process_episodes):
) episode_guids.append(episode_guids_results[index])
elif episode.viewOffset > 0:
if episode.parentTitle not in episode_guids:
episode_guids[episode.parentTitle] = []
episode_guids[episode.parentTitle].append(
get_episode_guids(episode, show, completed=False)
)
return show_guids, episode_guids return show_guids, episode_guids
except Exception: except Exception:
return {}, {} return {}, {}
def get_user_library_watched(user, user_plex, library): def get_user_library_watched(user, user_plex, library):
user_name: str = user.username.lower() if user.username else user.title.lower()
try: try:
user_name = user.title.lower()
user_watched = {}
user_watched[user_name] = {}
logger( logger(
f"Plex: Generating watched for {user_name} in library {library.title}", f"Plex: Generating watched for {user_name} in library {library.title}",
0, 0,
@@ -138,60 +137,66 @@ def get_user_library_watched(user, user_plex, library):
library_videos = user_plex.library.section(library.title) library_videos = user_plex.library.section(library.title)
if library.type == "movie": if library.type == "movie":
user_watched[user_name][library.title] = [] watched = []
# Get all watched movies args = [
for video in library_videos.search(unwatched=False): [get_guids, video, video.isWatched]
logger(f"Plex: Adding {video.title} to {user_name} watched list", 3) for video in library_videos.search(unwatched=False)
+ library_videos.search(inProgress=True)
movie_guids = get_movie_guids(video, completed=True) if video.isWatched or video.viewOffset >= 60000
]
user_watched[user_name][library.title].append(movie_guids)
# Get all partially watched movies greater than 1 minute
for video in library_videos.search(inProgress=True):
if video.viewOffset < 60000:
continue
logger(f"Plex: Adding {video.title} to {user_name} watched list", 3)
movie_guids = get_movie_guids(video, completed=False)
user_watched[user_name][library.title].append(movie_guids)
for guid in future_thread_executor(args, threads=len(args)):
logger(f"Plex: Adding {guid['title']} to {user_name} watched list", 3)
watched.append(guid)
elif library.type == "show": elif library.type == "show":
user_watched[user_name][library.title] = {} watched = {}
# Parallelize show processing # Get all watched shows and partially watched shows
args = [] parallel_show_task = []
parallel_episodes_task = []
# Get all watched shows for show in library_videos.search(unwatched=False) + library_videos.search(
for show in library_videos.search(unwatched=False): inProgress=True
args.append([get_user_library_watched_show, show]) ):
process_episodes = []
for episode in show.episodes():
if episode.isWatched or episode.viewOffset >= 60000:
process_episodes.append(episode)
# Get all partially watched shows # Shows with more than 24 episodes has its episodes processed in parallel
for show in library_videos.search(inProgress=True): # Shows with less than 24 episodes has its episodes processed in serial but the shows are processed in parallel
args.append([get_user_library_watched_show, show]) if len(process_episodes) >= 24:
parallel_episodes_task.append(
[
get_user_library_watched_show,
show,
process_episodes,
len(process_episodes),
]
)
else:
parallel_show_task.append(
[get_user_library_watched_show, show, process_episodes, 1]
)
for show_guids, episode_guids in future_thread_executor( for show_guids, episode_guids in future_thread_executor(
args, workers=min(os.cpu_count(), 4) parallel_show_task, threads=len(parallel_show_task)
): ) + future_thread_executor(parallel_episodes_task, threads=1):
if show_guids and episode_guids: if show_guids and episode_guids:
# append show, season, episode watched[show_guids] = episode_guids
if show_guids not in user_watched[user_name][library.title]:
user_watched[user_name][library.title][show_guids] = {}
user_watched[user_name][library.title][show_guids] = episode_guids
logger( logger(
f"Plex: Added {episode_guids} to {user_name} {show_guids} watched list", f"Plex: Added {episode_guids} to {user_name} {show_guids} watched list",
3, 3,
) )
logger(f"Plex: Got watched for {user_name} in library {library.title}", 1) else:
if library.title in user_watched[user_name]: watched = None
logger(f"Plex: {user_watched[user_name][library.title]}", 3)
return user_watched logger(f"Plex: Got watched for {user_name} in library {library.title}", 1)
logger(f"Plex: {watched}", 3)
return {user_name: {library.title: watched} if watched is not None else {}}
except Exception as e: except Exception as e:
logger( logger(
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}", f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
@@ -202,44 +207,48 @@ def get_user_library_watched(user, user_plex, library):
def find_video(plex_search, video_ids, videos=None): def find_video(plex_search, video_ids, videos=None):
try: try:
for location in plex_search.locations: if not generate_guids and not generate_locations:
if ( return False, []
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
episode_videos = []
if videos:
for show, seasons in videos.items():
show = {k: v for k, v in show}
if (
contains_nested(location.split("/")[-1], show["locations"])
is not None
):
for season in seasons.values():
for episode in season:
episode_videos.append(episode)
return True, episode_videos if generate_locations:
for location in plex_search.locations:
for guid in plex_search.guids: if (
guid_source = re.search(r"(.*)://", guid.id).group(1).lower() contains_nested(location.split("/")[-1], video_ids["locations"])
guid_id = re.search(r"://(.*)", guid.id).group(1) is not None
):
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list
if guid_source in video_ids.keys():
if guid_id in video_ids[guid_source]:
episode_videos = [] episode_videos = []
if videos: if videos:
for show, seasons in videos.items(): for show, episodes in videos.items():
show = {k: v for k, v in show} show = {k: v for k, v in show}
if guid_source in show["ids"].keys(): if (
if guid_id in show["ids"][guid_source]: contains_nested(
for season in seasons: location.split("/")[-1], show["locations"]
for episode in season: )
episode_videos.append(episode) is not None
):
for episode in episodes:
episode_videos.append(episode)
return True, episode_videos return True, episode_videos
if generate_guids:
for guid in plex_search.guids:
guid_source, guid_id = guid.id.split("://")
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list
if guid_source in video_ids.keys():
if guid_id in video_ids[guid_source]:
episode_videos = []
if videos:
for show, episodes in videos.items():
show = {k: v for k, v in show}
if guid_source in show.keys():
if guid_id == show[guid_source]:
for episode in episodes:
episode_videos.append(episode)
return True, episode_videos
return False, [] return False, []
except Exception: except Exception:
return False, [] return False, []
@@ -247,29 +256,33 @@ def find_video(plex_search, video_ids, videos=None):
def get_video_status(plex_search, video_ids, videos): def get_video_status(plex_search, video_ids, videos):
try: try:
for location in plex_search.locations: if not generate_guids and not generate_locations:
if ( return None
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
for video in videos:
if (
contains_nested(location.split("/")[-1], video["locations"])
is not None
):
return video["status"]
for guid in plex_search.guids: if generate_locations:
guid_source = re.search(r"(.*)://", guid.id).group(1).lower() for location in plex_search.locations:
guid_id = re.search(r"://(.*)", guid.id).group(1) if (
contains_nested(location.split("/")[-1], video_ids["locations"])
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list is not None
if guid_source in video_ids.keys(): ):
if guid_id in video_ids[guid_source]:
for video in videos: for video in videos:
if guid_source in video["ids"].keys(): if (
if guid_id in video["ids"][guid_source]: contains_nested(location.split("/")[-1], video["locations"])
return video["status"] is not None
):
return video["status"]
if generate_guids:
for guid in plex_search.guids:
guid_source, guid_id = guid.id.split("://")
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list
if guid_source in video_ids.keys():
if guid_id in video_ids[guid_source]:
for video in videos:
if guid_source in video.keys():
if guid_id == video[guid_source]:
return video["status"]
return None return None
except Exception: except Exception:
@@ -297,19 +310,28 @@ def update_user_watched(user, user_plex, library, videos, dryrun):
) )
if video_status: if video_status:
if video_status["completed"]: if video_status["completed"]:
msg = f"{movies_search.title} as watched for {user.title} in {library} for Plex" msg = f"Plex: {movies_search.title} as watched for {user.title} in {library}"
if not dryrun: if not dryrun:
logger(f"Marked {msg}", 0) logger(msg, 5)
movies_search.markWatched() movies_search.markWatched()
else: else:
logger(f"Dryrun {msg}", 0) logger(msg, 6)
log_marked(user.title, library, movies_search.title, None, None)
elif video_status["time"] > 60_000: elif video_status["time"] > 60_000:
msg = f"{movies_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex" msg = f"Plex: {movies_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library}"
if not dryrun: if not dryrun:
logger(f"Marked {msg}", 0) logger(msg, 5)
movies_search.updateProgress(video_status["time"]) movies_search.updateTimeline(video_status["time"])
else: else:
logger(f"Dryrun {msg}", 0) logger(msg, 6)
log_marked(
user.title,
library,
movies_search.title,
duration=video_status["time"],
)
else: else:
logger( logger(
f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}", f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}",
@@ -328,19 +350,34 @@ def update_user_watched(user, user_plex, library, videos, dryrun):
) )
if video_status: if video_status:
if video_status["completed"]: if video_status["completed"]:
msg = f"{show_search.title} {episode_search.title} as watched for {user.title} in {library} for Plex" msg = f"Plex: {show_search.title} {episode_search.title} as watched for {user.title} in {library}"
if not dryrun: if not dryrun:
logger(f"Marked {msg}", 0) logger(msg, 5)
episode_search.markWatched() episode_search.markWatched()
else: else:
logger(f"Dryrun {msg}", 0) logger(msg, 6)
log_marked(
user.title,
library,
show_search.title,
episode_search.title,
)
else: else:
msg = f"{show_search.title} {episode_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library} for Plex" msg = f"Plex: {show_search.title} {episode_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library}"
if not dryrun: if not dryrun:
logger(f"Marked {msg}", 0) logger(msg, 5)
episode_search.updateProgress(video_status["time"]) episode_search.updateTimeline(video_status["time"])
else: else:
logger(f"Dryrun {msg}", 0) logger(msg, 6)
log_marked(
user.title,
library,
show_search.title,
episode_search.title,
video_status["time"],
)
else: else:
logger( logger(
f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}", f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}",
@@ -414,6 +451,9 @@ class Plex:
logger(f"Plex: Failed to login, Error: {e}", 2) logger(f"Plex: Failed to login, Error: {e}", 2)
raise Exception(e) raise Exception(e)
def info(self) -> str:
return f"Plex {self.plex.friendlyName}: {self.plex.version}"
def get_users(self): def get_users(self):
try: try:
users = self.plex.myPlexAccount().users() users = self.plex.myPlexAccount().users()
@@ -438,7 +478,6 @@ class Plex:
try: try:
# Get all libraries # Get all libraries
users_watched = {} users_watched = {}
args = []
for user in users: for user in users:
if self.admin_user == user: if self.admin_user == user:
@@ -480,13 +519,12 @@ class Plex:
) )
continue continue
args.append([get_user_library_watched, user, user_plex, library]) user_watched = get_user_library_watched(user, user_plex, library)
for user_watched in future_thread_executor(args): for user_watched, user_watched_temp in user_watched.items():
for user, user_watched_temp in user_watched.items(): if user_watched not in users_watched:
if user not in users_watched: users_watched[user_watched] = {}
users_watched[user] = {} users_watched[user_watched].update(user_watched_temp)
users_watched[user].update(user_watched_temp)
return users_watched return users_watched
except Exception as e: except Exception as e:
@@ -509,10 +547,16 @@ class Plex:
user_other = search_mapping(user_mapping, user) user_other = search_mapping(user_mapping, user)
for index, value in enumerate(self.users): for index, value in enumerate(self.users):
if user.lower() == value.title.lower(): username_title = (
value.username.lower()
if value.username
else value.title.lower()
)
if user.lower() == username_title:
user = self.users[index] user = self.users[index]
break break
elif user_other and user_other.lower() == value.title.lower(): elif user_other and user_other.lower() == username_title:
user = self.users[index] user = self.users[index]
break break

View File

@@ -11,8 +11,12 @@ def generate_user_list(server):
server_users = [] server_users = []
if server_type == "plex": if server_type == "plex":
server_users = [x.title.lower() for x in server_connection.users] for user in server_connection.users:
elif server_type == "jellyfin": server_users.append(
user.username.lower() if user.username else user.title.lower()
)
elif server_type in ["jellyfin", "emby"]:
server_users = [key.lower() for key in server_connection.users.keys()] server_users = [key.lower() for key in server_connection.users.keys()]
return server_users return server_users
@@ -66,12 +70,16 @@ def generate_server_users(server, users):
if server[0] == "plex": if server[0] == "plex":
server_users = [] server_users = []
for plex_user in server[1].users: for plex_user in server[1].users:
username_title = (
plex_user.username if plex_user.username else plex_user.title
)
if ( if (
plex_user.title.lower() in users.keys() username_title.lower() in users.keys()
or plex_user.title.lower() in users.values() or username_title.lower() in users.values()
): ):
server_users.append(plex_user) server_users.append(plex_user)
elif server[0] == "jellyfin": elif server[0] in ["jellyfin", "emby"]:
server_users = {} server_users = {}
for jellyfin_user, jellyfin_id in server[1].users.items(): for jellyfin_user, jellyfin_id in server[1].users.items():
if ( if (

View File

@@ -6,11 +6,17 @@ from src.library import generate_library_guids_dict
def combine_watched_dicts(dicts: list): def combine_watched_dicts(dicts: list):
# Ensure that the input is a list of dictionaries
if not all(isinstance(d, dict) for d in dicts):
raise ValueError("Input must be a list of dictionaries")
combined_dict = {} combined_dict = {}
for single_dict in dicts: for single_dict in dicts:
for key, value in single_dict.items(): for key, value in single_dict.items():
if key not in combined_dict: if key not in combined_dict:
combined_dict[key] = {} combined_dict[key] = {}
for subkey, subvalue in value.items(): for subkey, subvalue in value.items():
if subkey in combined_dict[key]: if subkey in combined_dict[key]:
# If the subkey already exists in the combined dictionary, # If the subkey already exists in the combined dictionary,
@@ -116,46 +122,27 @@ def cleanup_watched(
for show_key_1 in watched_list_1[user_1][library_1].keys(): for show_key_1 in watched_list_1[user_1][library_1].keys():
show_key_dict = dict(show_key_1) show_key_dict = dict(show_key_1)
for season in watched_list_1[user_1][library_1][show_key_1]: # Filter the episode_watched_list_2_keys_dict dictionary to handle cases
for episode in watched_list_1[user_1][library_1][show_key_1][ # where episode location names are not unique such as S01E01.mkv
season filtered_episode_watched_list_2_keys_dict = (
]: filter_episode_watched_list_2_keys_dict(
episode_index = get_episode_index_in_dict( episode_watched_list_2_keys_dict, show_key_dict
episode, episode_watched_list_2_keys_dict )
) )
if episode_index is not None: for episode in watched_list_1[user_1][library_1][show_key_1]:
if check_remove_entry( episode_index = get_episode_index_in_dict(
episode, episode, filtered_episode_watched_list_2_keys_dict
library_1, )
episode_index, if episode_index is not None:
episode_watched_list_2_keys_dict, if check_remove_entry(
): episode,
modified_watched_list_1[user_1][library_1][ library_1,
show_key_1 episode_index,
][season].remove(episode) episode_watched_list_2_keys_dict,
# 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( modified_watched_list_1[user_1][library_1][
f"Removing {season} from {show_key_dict['title']} because it is empty",
3,
)
del modified_watched_list_1[user_1][library_1][
show_key_1 show_key_1
][season] ].remove(episode)
# Remove empty shows # Remove empty shows
if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0: if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0:
@@ -217,6 +204,61 @@ def get_movie_index_in_dict(movie, movies_watched_list_2_keys_dict):
return None return None
def filter_episode_watched_list_2_keys_dict(
episode_watched_list_2_keys_dict, show_key_dict
):
# If the episode_watched_list_2_keys_dict dictionary is empty, missing show then return an empty dictionary
if (
len(episode_watched_list_2_keys_dict) == 0
or "show" not in episode_watched_list_2_keys_dict.keys()
):
return {}
# Filter the episode_watched_list_2_keys_dict dictionary to only include values for the correct show
filtered_episode_watched_list_2_keys_dict = {}
show_indecies = []
# Iterate through episode_watched_list_2_keys_dict["show"] and find the indecies that match show_key_dict
for show_index, show_value in enumerate(episode_watched_list_2_keys_dict["show"]):
# Iterate through the keys and values of the show_value dictionary and check if they match show_key_dict
for show_key, show_key_value in show_value.items():
if show_key == "locations":
# Iterate through the locations in the show_value dictionary
for location in show_key_value:
# If the location is in the episode_watched_list_2_keys_dict dictionary, return index of the key
if (
contains_nested(location, show_key_dict["locations"])
is not None
):
show_indecies.append(show_index)
break
else:
if show_key in show_key_dict.keys():
if show_key_value == show_key_dict[show_key]:
show_indecies.append(show_index)
break
# lists
indecies = list(set(show_indecies))
# If there are no indecies that match the show, return an empty dictionary
if len(indecies) == 0:
return {}
# Create a copy of the dictionary with indecies that match the show and none that don't
for key, value in episode_watched_list_2_keys_dict.items():
if key not in filtered_episode_watched_list_2_keys_dict:
filtered_episode_watched_list_2_keys_dict[key] = []
for index, _ in enumerate(value):
if index in indecies:
filtered_episode_watched_list_2_keys_dict[key].append(value[index])
else:
filtered_episode_watched_list_2_keys_dict[key].append(None)
return filtered_episode_watched_list_2_keys_dict
def get_episode_index_in_dict(episode, episode_watched_list_2_keys_dict): def get_episode_index_in_dict(episode, episode_watched_list_2_keys_dict):
# Iterate through the keys and values of the episode dictionary # Iterate through the keys and values of the episode dictionary
for episode_key, episode_value in episode.items(): for episode_key, episode_value in episode.items():

116
test/ci_emby.env Normal file
View File

@@ -0,0 +1,116 @@
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "True"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## 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 = "mVaCzSyd78uoWkCBzZ_Y"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "True"
# Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "False"
SYNC_FROM_PLEX_TO_PLEX = "False"
SYNC_FROM_PLEX_TO_EMBY = "False"
SYNC_FROM_JELLYFIN_TO_PLEX = "False"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False"
SYNC_FROM_JELLYFIN_TO_EMBY = "False"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

116
test/ci_guids.env Normal file
View File

@@ -0,0 +1,116 @@
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "True"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## 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 = "mVaCzSyd78uoWkCBzZ_Y"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "True"
# Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

116
test/ci_jellyfin.env Normal file
View File

@@ -0,0 +1,116 @@
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "True"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## 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 = "mVaCzSyd78uoWkCBzZ_Y"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "True"
# Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "False"
SYNC_FROM_PLEX_TO_PLEX = "False"
SYNC_FROM_PLEX_TO_EMBY = "False"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "False"
SYNC_FROM_EMBY_TO_JELLYFIN = "False"
SYNC_FROM_EMBY_TO_EMBY = "False"

116
test/ci_locations.env Normal file
View File

@@ -0,0 +1,116 @@
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "True"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## 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 = "mVaCzSyd78uoWkCBzZ_Y"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "True"
# Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "True"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True"
SYNC_FROM_JELLYFIN_TO_EMBY = "True"
SYNC_FROM_EMBY_TO_PLEX = "True"
SYNC_FROM_EMBY_TO_JELLYFIN = "True"
SYNC_FROM_EMBY_TO_EMBY = "True"

116
test/ci_plex.env Normal file
View File

@@ -0,0 +1,116 @@
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "True"
## Additional logging information
DEBUG = "True"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## 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 = "mVaCzSyd78uoWkCBzZ_Y"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options
#PLEX_USERNAME = "PlexUser, PlexUser2"
#PLEX_PASSWORD = "SuperSecret, SuperSecret2"
#PLEX_SERVERNAME = "Plex Server1, Plex Server2"
## Skip hostname validation for ssl certificates.
## Set to True if running into ssl certificate errors
SSL_BYPASS = "True"
# Jellyfin
## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
JELLYFIN_BASEURL = "http://localhost:8096"
## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key
## Comma seperated list for multiple servers
JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c"
# Emby
## Emby server URL, use hostname or IP address if the hostname is not resolving correctly
## Comma seperated list for multiple servers
EMBY_BASEURL = "http://localhost:8097"
## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key
## Comma seperated list for multiple servers
EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515"
# Syncing Options
## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex
## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers
SYNC_FROM_PLEX_TO_JELLYFIN = "True"
SYNC_FROM_PLEX_TO_PLEX = "True"
SYNC_FROM_PLEX_TO_EMBY = "True"
SYNC_FROM_JELLYFIN_TO_PLEX = "False"
SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False"
SYNC_FROM_JELLYFIN_TO_EMBY = "False"
SYNC_FROM_EMBY_TO_PLEX = "False"
SYNC_FROM_EMBY_TO_JELLYFIN = "False"
SYNC_FROM_EMBY_TO_EMBY = "False"

116
test/ci_write.env Normal file
View File

@@ -0,0 +1,116 @@
# Global Settings
## Do not mark any shows/movies as played and instead just output to log if they would of been marked.
DRYRUN = "False"
## Additional logging information
DEBUG = "True"
## Debugging level, "info" is default, "debug" is more verbose
DEBUG_LEVEL = "info"
## 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 = "mVaCzSyd78uoWkCBzZ_Y"
## 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

View File

@@ -42,21 +42,19 @@ show_list = {
("tvdb", "392256"), ("tvdb", "392256"),
("title", "The Last of Us"), ("title", "The Last of Us"),
} }
): { ): [
"Season 1": [ {
{ "imdb": "tt11957006",
"imdb": "tt11957006", "tmdb": "2181581",
"tmdb": "2181581", "tvdb": "8444132",
"tvdb": "8444132", "locations": (
"locations": ( (
( "The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",
"The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv", )
) ),
), "status": {"completed": True, "time": 0},
"status": {"completed": True, "time": 0}, }
} ]
]
}
} }
movie_list = [ movie_list = [
{ {
@@ -83,6 +81,15 @@ episode_titles = {
"tvdb": ["8444132"], "tvdb": ["8444132"],
"completed": [True], "completed": [True],
"time": [0], "time": [0],
"show": [
{
"imdb": "tt3581920",
"locations": ("The Last of Us",),
"title": "The Last of Us",
"tmdb": "100088",
"tvdb": "392256",
}
],
} }
movie_titles = { movie_titles = {
"imdb": ["tt2380307"], "imdb": ["tt2380307"],

View File

@@ -18,242 +18,419 @@ from src.watched import cleanup_watched, combine_watched_dicts
tv_shows_watched_list_1 = { tv_shows_watched_list_1 = {
frozenset( frozenset(
{ {
("tvdb", "75710"), ("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
("title", "Criminal Minds"), ("imdb", "tt0436992"),
("imdb", "tt0452046"), ("tmdb", "57243"),
("locations", ("Criminal Minds",)), ("tvdb", "78804"),
("tmdb", "4057"), ("title", "Doctor Who (2005)"),
} }
): { ): [
"Season 1": [ {
{ "imdb": "tt0563001",
"imdb": "tt0550489", "tmdb": "968589",
"tmdb": "282843", "tvdb": "295296",
"tvdb": "176357", "title": "The Unquiet Dead",
"title": "Extreme Aggressor", "locations": ("S01E03.mkv",),
"locations": ( "status": {"completed": True, "time": 0},
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", },
), {
"status": {"completed": True, "time": 0}, "imdb": "tt0562985",
}, "tmdb": "968590",
{ "tvdb": "295297",
"imdb": "tt0550487", "title": "Aliens of London (1)",
"tmdb": "282861", "locations": ("S01E04.mkv",),
"tvdb": "300385", "status": {"completed": False, "time": 240000},
"title": "Compulsion", },
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",), {
"status": {"completed": True, "time": 0}, "imdb": "tt0563003",
}, "tmdb": "968592",
] "tvdb": "295298",
}, "title": "World War Three (2)",
frozenset({("title", "Test"), ("locations", ("Test",))}): { "locations": ("S01E05.mkv",),
"Season 1": [ "status": {"completed": True, "time": 0},
{ },
"title": "S01E01", ],
"locations": ("Test S01E01.mkv",), frozenset(
"status": {"completed": True, "time": 0}, {
}, ("title", "Monarch: Legacy of Monsters"),
{ ("imdb", "tt17220216"),
"title": "S01E02", ("tvdb", "422598"),
"locations": ("Test S01E02.mkv",), ("tmdb", "202411"),
"status": {"completed": True, "time": 0}, (
}, "locations",
{ ("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
"title": "S01E04", ),
"locations": ("Test S01E04.mkv",), }
"status": {"completed": False, "time": 5}, ): [
}, {
] "imdb": "tt21255044",
}, "tmdb": "4661246",
"tvdb": "10009418",
"title": "Secrets and Lies",
"locations": ("S01E03.mkv",),
"status": {"completed": True, "time": 0},
},
{
"imdb": "tt21255050",
"tmdb": "4712059",
"tvdb": "10009419",
"title": "Parallels and Interiors",
"locations": ("S01E04.mkv",),
"status": {"completed": False, "time": 240000},
},
{
"imdb": "tt23787572",
"tmdb": "4712061",
"tvdb": "10009420",
"title": "The Way Out",
"locations": ("S01E05.mkv",),
"status": {"completed": True, "time": 0},
},
],
frozenset(
{
("tmdb", "125928"),
("imdb", "tt14681924"),
("tvdb", "403172"),
(
"locations",
("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",),
),
("title", "My Adventures with Superman"),
}
): [
{
"imdb": "tt15699926",
"tmdb": "3070048",
"tvdb": "8438181",
"title": "Adventures of a Normal Man (1)",
"locations": ("S01E01.mkv",),
"status": {"completed": True, "time": 0},
},
{
"imdb": "tt20413322",
"tmdb": "4568681",
"tvdb": "9829910",
"title": "Adventures of a Normal Man (2)",
"locations": ("S01E02.mkv",),
"status": {"completed": True, "time": 0},
},
{
"imdb": "tt20413328",
"tmdb": "4497012",
"tvdb": "9870382",
"title": "My Interview with Superman",
"locations": ("S01E03.mkv",),
"status": {"completed": True, "time": 0},
},
],
} }
tv_shows_watched_list_2 = { tv_shows_watched_list_2 = {
frozenset( frozenset(
{ {
("tvdb", "75710"), ("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
("title", "Criminal Minds"), ("imdb", "tt0436992"),
("imdb", "tt0452046"), ("tmdb", "57243"),
("locations", ("Criminal Minds",)), ("title", "Doctor Who"),
("tmdb", "4057"), ("tvdb", "78804"),
("tvrage", "3332"),
} }
): { ): [
"Season 1": [ {
{ "tvdb": "295294",
"imdb": "tt0550487", "imdb": "tt0562992",
"tmdb": "282861", "title": "Rose",
"tvdb": "300385", "locations": ("S01E01.mkv",),
"title": "Compulsion", "status": {"completed": True, "time": 0},
"locations": ("Criminal Minds S01E02 Compulsion WEBDL-720p.mkv",), },
"status": {"completed": True, "time": 0}, {
}, "tvdb": "295295",
{ "imdb": "tt0562997",
"imdb": "tt0550498", "title": "The End of the World",
"tmdb": "282865", "locations": ("S01E02.mkv",),
"tvdb": "300474", "status": {"completed": False, "time": 300670},
"title": "Won't Get Fooled Again", },
"locations": ( {
"Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv", "tvdb": "295298",
), "imdb": "tt0563003",
"status": {"completed": True, "time": 0}, "title": "World War Three (2)",
}, "locations": ("S01E05.mkv",),
] "status": {"completed": True, "time": 0},
}, },
frozenset({("title", "Test"), ("locations", ("Test",))}): { ],
"Season 1": [ frozenset(
{ {
"title": "S01E02", ("title", "Monarch: Legacy of Monsters"),
"locations": ("Test S01E02.mkv",), ("imdb", "tt17220216"),
"status": {"completed": False, "time": 10}, ("tvdb", "422598"),
}, ("tmdb", "202411"),
{ (
"title": "S01E03", "locations",
"locations": ("Test S01E03.mkv",), ("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
"status": {"completed": True, "time": 0}, ),
}, }
{ ): [
"title": "S01E04", {
"locations": ("Test S01E04.mkv",), "tvdb": "9959300",
"status": {"completed": False, "time": 10}, "imdb": "tt20412166",
}, "title": "Aftermath",
] "locations": ("S01E01.mkv",),
}, "status": {"completed": True, "time": 0},
},
{
"tvdb": "10009417",
"imdb": "tt22866594",
"title": "Departure",
"locations": ("S01E02.mkv",),
"status": {"completed": False, "time": 300741},
},
{
"tvdb": "10009420",
"imdb": "tt23787572",
"title": "The Way Out",
"locations": ("S01E05.mkv",),
"status": {"completed": True, "time": 0},
},
],
frozenset(
{
("tmdb", "125928"),
("imdb", "tt14681924"),
("tvdb", "403172"),
(
"locations",
("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",),
),
("title", "My Adventures with Superman"),
}
): [
{
"tvdb": "8438181",
"imdb": "tt15699926",
"title": "Adventures of a Normal Man (1)",
"locations": ("S01E01.mkv",),
"status": {"completed": True, "time": 0},
},
{
"tvdb": "9829910",
"imdb": "tt20413322",
"title": "Adventures of a Normal Man (2)",
"locations": ("S01E02.mkv",),
"status": {"completed": True, "time": 0},
},
{
"tvdb": "9870382",
"imdb": "tt20413328",
"title": "My Interview with Superman",
"locations": ("S01E03.mkv",),
"status": {"completed": True, "time": 0},
},
],
} }
expected_tv_show_watched_list_1 = { expected_tv_show_watched_list_1 = {
frozenset( frozenset(
{ {
("tvdb", "75710"), ("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
("title", "Criminal Minds"), ("imdb", "tt0436992"),
("imdb", "tt0452046"), ("tmdb", "57243"),
("locations", ("Criminal Minds",)), ("tvdb", "78804"),
("tmdb", "4057"), ("title", "Doctor Who (2005)"),
} }
): { ): [
"Season 1": [ {
{ "imdb": "tt0563001",
"imdb": "tt0550489", "tmdb": "968589",
"tmdb": "282843", "tvdb": "295296",
"tvdb": "176357", "title": "The Unquiet Dead",
"title": "Extreme Aggressor", "locations": ("S01E03.mkv",),
"locations": ( "status": {"completed": True, "time": 0},
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", },
), {
"status": {"completed": True, "time": 0}, "imdb": "tt0562985",
} "tmdb": "968590",
] "tvdb": "295297",
}, "title": "Aliens of London (1)",
frozenset({("title", "Test"), ("locations", ("Test",))}): { "locations": ("S01E04.mkv",),
"Season 1": [ "status": {"completed": False, "time": 240000},
{ },
"title": "S01E01", ],
"locations": ("Test S01E01.mkv",), frozenset(
"status": {"completed": True, "time": 0}, {
}, ("title", "Monarch: Legacy of Monsters"),
{ ("imdb", "tt17220216"),
"title": "S01E02", ("tvdb", "422598"),
"locations": ("Test S01E02.mkv",), ("tmdb", "202411"),
"status": {"completed": True, "time": 0}, (
}, "locations",
] ("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
}, ),
}
): [
{
"imdb": "tt21255044",
"tmdb": "4661246",
"tvdb": "10009418",
"title": "Secrets and Lies",
"locations": ("S01E03.mkv",),
"status": {"completed": True, "time": 0},
},
{
"imdb": "tt21255050",
"tmdb": "4712059",
"tvdb": "10009419",
"title": "Parallels and Interiors",
"locations": ("S01E04.mkv",),
"status": {"completed": False, "time": 240000},
},
],
} }
expected_tv_show_watched_list_2 = { expected_tv_show_watched_list_2 = {
frozenset( frozenset(
{ {
("tvdb", "75710"), ("locations", ("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",)),
("title", "Criminal Minds"), ("imdb", "tt0436992"),
("imdb", "tt0452046"), ("tmdb", "57243"),
("locations", ("Criminal Minds",)), ("title", "Doctor Who"),
("tmdb", "4057"), ("tvdb", "78804"),
("tvrage", "3332"),
} }
): { ): [
"Season 1": [ {
{ "tvdb": "295294",
"imdb": "tt0550498", "imdb": "tt0562992",
"tmdb": "282865", "title": "Rose",
"tvdb": "300474", "locations": ("S01E01.mkv",),
"title": "Won't Get Fooled Again", "status": {"completed": True, "time": 0},
"locations": ( },
"Criminal Minds S01E03 Won't Get Fooled Again WEBDL-720p.mkv", {
), "tvdb": "295295",
"status": {"completed": True, "time": 0}, "imdb": "tt0562997",
} "title": "The End of the World",
] "locations": ("S01E02.mkv",),
}, "status": {"completed": False, "time": 300670},
frozenset({("title", "Test"), ("locations", ("Test",))}): { },
"Season 1": [ ],
{ frozenset(
"title": "S01E03", {
"locations": ("Test S01E03.mkv",), ("title", "Monarch: Legacy of Monsters"),
"status": {"completed": True, "time": 0}, ("imdb", "tt17220216"),
}, ("tvdb", "422598"),
{ ("tmdb", "202411"),
"title": "S01E04", (
"locations": ("Test S01E04.mkv",), "locations",
"status": {"completed": False, "time": 10}, ("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",),
}, ),
] }
}, ): [
{
"tvdb": "9959300",
"imdb": "tt20412166",
"title": "Aftermath",
"locations": ("S01E01.mkv",),
"status": {"completed": True, "time": 0},
},
{
"tvdb": "10009417",
"imdb": "tt22866594",
"title": "Departure",
"locations": ("S01E02.mkv",),
"status": {"completed": False, "time": 300741},
},
],
} }
movies_watched_list_1 = [ movies_watched_list_1 = [
{ {
"imdb": "tt2380307", "imdb": "tt1254207",
"tmdb": "354912", "tmdb": "10378",
"title": "Coco", "tvdb": "12352",
"locations": ("Coco (2017) Remux-1080p.mkv",), "title": "Big Buck Bunny",
"locations": ("Big Buck Bunny.mkv",),
"status": {"completed": True, "time": 0}, "status": {"completed": True, "time": 0},
}, },
{ {
"tmdbcollection": "448150", "imdb": "tt16431870",
"imdb": "tt1431045", "tmdb": "1029575",
"tmdb": "293660", "tvdb": "351194",
"title": "Deadpool", "title": "The Family Plan",
"locations": ("Deadpool (2016) Remux-1080p.mkv",), "locations": ("The Family Plan (2023).mkv",),
"status": {"completed": True, "time": 0}, "status": {"completed": True, "time": 0},
}, },
{
"imdb": "tt5537002",
"tmdb": "466420",
"tvdb": "135852",
"title": "Killers of the Flower Moon",
"locations": ("Killers of the Flower Moon (2023).mkv",),
"status": {"completed": False, "time": 240000},
},
] ]
movies_watched_list_2 = [ movies_watched_list_2 = [
{ {
"imdb": "tt2380307", "imdb": "tt16431870",
"tmdb": "354912", "tmdb": "1029575",
"title": "Coco", "title": "The Family Plan",
"locations": ("Coco (2017) Remux-1080p.mkv",), "locations": ("The Family Plan (2023).mkv",),
"status": {"completed": True, "time": 0}, "status": {"completed": True, "time": 0},
}, },
{ {
"imdb": "tt0384793", "imdb": "tt4589218",
"tmdb": "9788", "tmdb": "507089",
"tvdb": "9103", "title": "Five Nights at Freddy's",
"title": "Accepted", "locations": ("Five Nights at Freddy's (2023).mkv",),
"locations": ("Accepted (2006) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0}, "status": {"completed": True, "time": 0},
}, },
{
"imdb": "tt10545296",
"tmdb": "695721",
"tmdbcollection": "131635",
"title": "The Hunger Games: The Ballad of Songbirds & Snakes",
"locations": ("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",),
"status": {"completed": False, "time": 301215},
},
] ]
expected_movie_watched_list_1 = [ expected_movie_watched_list_1 = [
{ {
"tmdbcollection": "448150", "imdb": "tt1254207",
"imdb": "tt1431045", "tmdb": "10378",
"tmdb": "293660", "tvdb": "12352",
"title": "Deadpool", "title": "Big Buck Bunny",
"locations": ("Deadpool (2016) Remux-1080p.mkv",), "locations": ("Big Buck Bunny.mkv",),
"status": {"completed": True, "time": 0}, "status": {"completed": True, "time": 0},
} },
{
"imdb": "tt5537002",
"tmdb": "466420",
"tvdb": "135852",
"title": "Killers of the Flower Moon",
"locations": ("Killers of the Flower Moon (2023).mkv",),
"status": {"completed": False, "time": 240000},
},
] ]
expected_movie_watched_list_2 = [ expected_movie_watched_list_2 = [
{ {
"imdb": "tt0384793", "imdb": "tt4589218",
"tmdb": "9788", "tmdb": "507089",
"tvdb": "9103", "title": "Five Nights at Freddy's",
"title": "Accepted", "locations": ("Five Nights at Freddy's (2023).mkv",),
"locations": ("Accepted (2006) Remux-1080p.mkv",),
"status": {"completed": True, "time": 0}, "status": {"completed": True, "time": 0},
} },
{
"imdb": "tt10545296",
"tmdb": "695721",
"tmdbcollection": "131635",
"title": "The Hunger Games: The Ballad of Songbirds & Snakes",
"locations": ("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",),
"status": {"completed": False, "time": 301215},
},
] ]
# Test to see if objects get deleted all the way up to the root. # Test to see if objects get deleted all the way up to the root.
@@ -266,20 +443,16 @@ tv_shows_2_watched_list_1 = {
("locations", ("Criminal Minds",)), ("locations", ("Criminal Minds",)),
("tmdb", "4057"), ("tmdb", "4057"),
} }
): { ): [
"Season 1": [ {
{ "imdb": "tt0550489",
"imdb": "tt0550489", "tmdb": "282843",
"tmdb": "282843", "tvdb": "176357",
"tvdb": "176357", "title": "Extreme Aggressor",
"title": "Extreme Aggressor", "locations": ("Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv",),
"locations": ( "status": {"completed": True, "time": 0},
"Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", },
), ]
"status": {"completed": True, "time": 0},
},
]
}
} }
@@ -413,18 +586,16 @@ def test_combine_watched_dicts():
("locations", ("11.22.63",)), ("locations", ("11.22.63",)),
("imdb", "tt2879552"), ("imdb", "tt2879552"),
} }
): { ): [
"Season 1": [ {
{ "imdb": "tt4460418",
"imdb": "tt4460418", "title": "The Rabbit Hole",
"title": "The Rabbit Hole", "locations": (
"locations": ( "11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv",
"11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv", ),
), "status": {"completed": True, "time": 0},
"status": {"completed": True, "time": 0}, }
} ]
]
}
} }
} }
}, },
@@ -467,18 +638,16 @@ def test_combine_watched_dicts():
("locations", ("11.22.63",)), ("locations", ("11.22.63",)),
("imdb", "tt2879552"), ("imdb", "tt2879552"),
} }
): { ): [
"Season 1": [ {
{ "imdb": "tt4460418",
"imdb": "tt4460418", "title": "The Rabbit Hole",
"title": "The Rabbit Hole", "locations": (
"locations": ( "11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv",
"11.22.63 S01E01 The Rabbit Hole Bluray-1080p.mkv", ),
), "status": {"completed": True, "time": 0},
"status": {"completed": True, "time": 0}, }
} ]
]
}
}, },
"Subbed Anime": {}, "Subbed Anime": {},
} }

174
test/validate_ci_marklog.py Normal file
View File

@@ -0,0 +1,174 @@
# Check the mark.log file that is generated by the CI to make sure it contains the expected values
import os, argparse
def parse_args():
parser = argparse.ArgumentParser(
description="Check the mark.log file that is generated by the CI to make sure it contains the expected values"
)
parser.add_argument(
"--dry", action="store_true", help="Check the mark.log file for dry-run"
)
parser.add_argument(
"--write", action="store_true", help="Check the mark.log file for write-run"
)
parser.add_argument(
"--plex", action="store_true", help="Check the mark.log file for Plex"
)
parser.add_argument(
"--jellyfin", action="store_true", help="Check the mark.log file for Jellyfin"
)
parser.add_argument(
"--emby", action="store_true", help="Check the mark.log file for Emby"
)
return parser.parse_args()
def read_marklog():
marklog = os.path.join(os.getcwd(), "mark.log")
with open(marklog, "r") as f:
lines = f.readlines()
return lines
def check_marklog(lines, expected_values):
try:
# Check to make sure the marklog contains all the expected values and nothing else
found_values = []
for line in lines:
# Remove the newline character
line = line.strip()
if line not in expected_values:
raise Exception("Line not found in marklog: " + line)
found_values.append(line)
# Check to make sure the marklog contains the same number of values as the expected values
if len(found_values) != len(expected_values):
raise Exception(
"Marklog did not contain the same number of values as the expected values, found "
+ str(len(found_values))
+ " values, expected "
+ str(len(expected_values))
+ " values\n"
+ "\n".join(found_values)
)
# Check that the two lists contain the same values
if sorted(found_values) != sorted(expected_values):
raise Exception(
"Marklog did not contain the same values as the expected values, found:\n"
+ "\n".join(sorted(found_values))
+ "\n\nExpected:\n"
+ "\n".join(sorted(expected_values))
)
return True
except Exception as e:
print(e)
return False
def main():
args = parse_args()
expected_jellyfin = [
"jellyplex_watched/Movies/Five Nights at Freddy's",
"jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
"jellyplex_watched/Movies/The Family Plan",
"jellyplex_watched/Movies/Five Nights at Freddy's",
"jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
]
expected_emby = [
"jellyplex_watched/Movies/Tears of Steel",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
"JellyUser/Movies/Tears of Steel",
"JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
]
expected_plex = [
"JellyUser/Movies/Big Buck Bunny",
"JellyUser/Movies/Killers of the Flower Moon/4",
"JellyUser/Shows/Doctor Who/The Unquiet Dead",
"JellyUser/Shows/Doctor Who/Aliens of London (1)/4",
"JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
"jellyplex_watched/Movies/Big Buck Bunny",
"jellyplex_watched/Movies/The Family Plan",
"jellyplex_watched/Movies/Killers of the Flower Moon/4",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
]
expected_dry = expected_emby + expected_plex + expected_jellyfin
expected_write = [
"jellyplex_watched/Movies/Five Nights at Freddy's",
"jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
"JellyUser/Movies/Big Buck Bunny",
"JellyUser/Movies/Killers of the Flower Moon/4",
"JellyUser/Shows/Doctor Who/The Unquiet Dead",
"JellyUser/Shows/Doctor Who/Aliens of London (1)/4",
"JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
"jellyplex_watched/Movies/Tears of Steel",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
"jellyplex_watched/Movies/Big Buck Bunny",
"jellyplex_watched/Movies/The Family Plan",
"jellyplex_watched/Movies/Five Nights at Freddy's",
"jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5",
"jellyplex_watched/Movies/Killers of the Flower Moon/4",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5",
"jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead",
"jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
"JellyUser/Movies/Tears of Steel",
"JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
]
# Expected values for the mark.log file, dry-run is slightly different than write-run
# due to some of the items being copied over from one server to another and now being there
# for the next server run.
if args.dry:
expected_values = expected_dry
elif args.write:
expected_values = expected_write
elif args.plex:
expected_values = expected_plex
elif args.jellyfin:
expected_values = expected_jellyfin
elif args.emby:
expected_values = expected_emby
else:
print("No server specified")
exit(1)
lines = read_marklog()
if not check_marklog(lines, expected_values):
print("Failed to validate marklog")
exit(1)
print("Successfully validated marklog")
exit(0)
if __name__ == "__main__":
main()