73 Commits

Author SHA1 Message Date
Luis Garcia
64c1823e5b tag 7.0.3
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-04-03 19:25:20 +00:00
Luigi311
446f6df470 Merge pull request #259 from luigi311/fallback_library_types
Jellyfin/Emby: Add fallback to media files for library types
2025-04-03 13:23:41 -06:00
Luis Garcia
91ea5d76f6 Jellyfin/Emby: Add fallback to media files for library types
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-04-02 04:27:39 +00:00
Luigi311
dc26b9a7b1 Merge pull request #249 from luigi311/simplify_get_watched
Jellyfin/Emby: Simplify get watched
2025-03-07 16:35:11 -07:00
Luis Garcia
d98b7c3e09 Jellyfin/Emby: Simplify get watched
Shouldn't need to do library type checks as that is handed in the
get libraries function and then used with the sync libraries name
check

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

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

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

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

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

Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-19 10:51:10 -07:00
Luigi311
a1e1ccde42 Merge pull request #210 from luigi311/more_cleanup
More cleanup
2025-02-18 18:09:57 -07:00
Luis Garcia
bf633c75d1 More typing
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
46fa5e7c9a Use list instead of tuple for servers
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
170757aca1 Add lots of static typing
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
9786e9e27d Test: Split out black and white mapping tests
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:43 -07:00
Luis Garcia
8b691b7bfa Test: Remove duplicate black and white list test
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:59:38 -07:00
Luis Garcia
e1c65fc082 Jellyfin/emby: Combine info/version, add typing for versions
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:45:21 -07:00
Luis Garcia
58749a4fb8 Plex: Improve variable names to avoid confusion
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:45:21 -07:00
Luis Garcia
51ec69f651 Simplify search_mapping
Signed-off-by: Luis Garcia <git@luigi311.com>
2025-02-18 17:45:21 -07:00
Luigi311
4771f736b0 Merge pull request #223 from luigi311/update
Update python and PlexAPI
2025-02-18 17:23:47 -07:00
Luis Garcia
8d7436579e CI: Update plex token 2025-02-18 17:10:54 -07:00
Luis Garcia
43e1df98b1 Update to Python 3.13 2025-02-18 16:43:22 -07:00
Luis Garcia
3017030f52 Requirements: Update PlexAPI 4.16.1 2025-02-18 16:42:35 -07:00
Luis Garcia
348a0b8226 Dont show error on extract show/episode/movies output dict
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 13:38:59 -07:00
Luis Garcia
4e60c08120 Reduce sample max_threads to 2
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 12:53:13 -07:00
Luis Garcia
10b58379cd Test: Validate for GUIDS and Locations
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 12:17:33 -07:00
Luis Garcia
fa9201b20f Update requirements
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-11-09 10:44:31 -07:00
Luigi311
86f72997b4 Merge pull request #205 from luigi311/simplify_watched
Simplify get watched process
2024-10-27 18:13:31 -06:00
Luis Garcia
62d0319aad Remove unused
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
a096a09eb7 CI: Fix Validation. Print marklog on failed validation
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
7294241fed Add server type and name to marklog
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
a5995d3999 CI: More verbose
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
30f31b2f3f Remove unused combine_watched_dicts
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luis Garcia
bc09c873e9 Simplify get watched process. Only get watched for syncing libraries
Signed-off-by: Luis Garcia <git@luigi311.com>
2024-10-27 18:05:27 -06:00
Luigi311
8428be9dda Create FUNDING.yml 2024-10-27 09:05:06 -06:00
Luigi311
6a45ad18f9 Merge pull request #202 from luigi311/python-12
Update to python 12
2024-10-07 23:10:00 -06:00
Luigi311
023b638729 CI: Pin to python 3.12 2024-10-08 04:59:44 +00:00
Luigi311
4a4c9f9ccf Update to python 12 2023-12-06 14:16:47 -07:00
37 changed files with 2878 additions and 2671 deletions

View File

@@ -1,3 +1,4 @@
.venv
.dockerignore
.env
.env.sample
@@ -9,7 +10,4 @@
Dockerfile*
README.md
test
venv

View File

@@ -35,7 +35,7 @@ GENERATE_GUIDS = "True"
GENERATE_LOCATIONS = "True"
## Max threads for processing
MAX_THREADS = 32
MAX_THREADS = 2
## Map usernames between servers in the event that they are different, order does not matter
## Comma separated for multiple options

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [Luigi311]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -10,26 +10,45 @@ on:
- .gitignore
- "*.md"
env:
PYTHON_VERSION: '3.13'
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: "Install dependencies"
run: pip install -r requirements.txt && pip install -r test/requirements.txt
run: uv sync --all-extras --dev
- name: "Run tests"
run: pytest -vvv
run: uv run pytest -vvv
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: "Install dependencies"
run: |
pip install -r requirements.txt
uv sync --all-extras --dev
sudo apt update && sudo apt install -y docker-compose
- name: "Checkout JellyPlex-Watched-CI"
@@ -52,40 +71,40 @@ jobs:
- name: "Test Plex"
run: |
mv test/ci_plex.env .env
python main.py
python test/validate_ci_marklog.py --plex
uv run main.py
uv run test/validate_ci_marklog.py --plex
rm mark.log
- name: "Test Jellyfin"
run: |
mv test/ci_jellyfin.env .env
python main.py
python test/validate_ci_marklog.py --jellyfin
uv run main.py
uv run test/validate_ci_marklog.py --jellyfin
rm mark.log
- name: "Test Emby"
run: |
mv test/ci_emby.env .env
python main.py
python test/validate_ci_marklog.py --emby
uv run main.py
uv run test/validate_ci_marklog.py --emby
rm mark.log
- name: "Test Guids"
run: |
mv test/ci_guids.env .env
python main.py
python test/validate_ci_marklog.py --dry
uv run main.py
uv run test/validate_ci_marklog.py --guids
rm mark.log
- name: "Test Locations"
run: |
mv test/ci_locations.env .env
python main.py
python test/validate_ci_marklog.py --dry
uv run main.py
uv run test/validate_ci_marklog.py --locations
rm mark.log
@@ -93,12 +112,12 @@ jobs:
run: |
# Test writing to the servers
mv test/ci_write.env .env
python main.py
uv run main.py
# Test again to test if it can handle existing data
python main.py
uv run main.py
python test/validate_ci_marklog.py --write
uv run test/validate_ci_marklog.py --write
rm mark.log

3
.gitignore vendored
View File

@@ -84,9 +84,6 @@ target/
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

4
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"configurations": [
{
"name": "Python: Main",
"type": "python",
"type": "debugpy",
"request": "launch",
"program": "main.py",
"console": "integratedTerminal",
@@ -14,7 +14,7 @@
},
{
"name": "Pytest",
"type": "python",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": [

View File

@@ -1,4 +1,4 @@
FROM python:3.11-alpine
FROM ghcr.io/astral-sh/uv:python3.13-alpine
ENV PUID=1000
ENV PGID=1000
@@ -36,14 +36,72 @@ RUN set -eux; \
WORKDIR /app
COPY ./requirements.txt ./
# Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1
RUN pip install --no-cache-dir -r requirements.txt
ENV UV_LINK_MODE=copy
# Set the cache directory to /tmp instead of root
ENV UV_CACHE_DIR=/tmp/.cache/uv
# Install the project's dependencies using the lockfile and settings
RUN --mount=type=cache,target=/tmp/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
COPY . /app
RUN --mount=type=cache,target=/tmp/.cache/uv \
uv sync --frozen --no-dev
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
COPY . .
RUN chmod +x *.sh && \
dos2unix *.sh
# Set default values to prevent issues
ENV DRYRUN="True"
ENV DEBUG_LEVEL="INFO"
ENV RUN_ONLY_ONCE="False"
ENV SLEEP_DURATION=3600
ENV LOG_FILE="log.log"
ENV MARK_FILE="mark.log"
ENV REQUEST_TIME=300
ENV GENERATE_GUIDS="True"
ENV GENERATE_LOCATIONS="True"
ENV MAX_THREADS=1
ENV USER_MAPPING=""
ENV LIBRARY_MAPPING=""
ENV BLACKLIST_LIBRARY=""
ENV WHITELIST_LIBRARY=""
ENV BLACKLIST_LIBRARY_TYPE=""
ENV WHITELIST_LIBRARY_TYPE=""
ENV BLACKLIST_USERS=""
ENV WHITELIST_USERS=""
ENV PLEX_BASEURL=""
ENV PLEX_TOKEN=""
ENV PLEX_USERNAME=""
ENV PLEX_PASSWORD=""
ENV PLEX_SERVERNAME=""
ENV SSL_BYPASS="False"
ENV JELLYFIN_BASEURL=""
ENV JELLYFIN_TOKEN=""
ENV EMBY_BASEURL=""
ENV EMBY_TOKEN=""
ENV SYNC_FROM_PLEX_TO_JELLYFIN="True"
ENV SYNC_FROM_PLEX_TO_PLEX="True"
ENV SYNC_FROM_PLEX_TO_EMBY="True"
ENV SYNC_FROM_JELLYFIN_TO_PLEX="True"
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN="True"
ENV SYNC_FROM_JELLYFIN_TO_EMBY="True"
ENV SYNC_FROM_EMBY_TO_PLEX="True"
ENV SYNC_FROM_EMBY_TO_JELLYFIN="True"
ENV SYNC_FROM_EMBY_TO_EMBY="True"
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
CMD ["python", "-u", "main.py"]

View File

@@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM ghcr.io/astral-sh/uv:bookworm-slim
ENV PUID=1000
ENV PGID=1000
@@ -10,14 +10,72 @@ RUN apt-get update && \
WORKDIR /app
COPY ./requirements.txt ./
# Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1
RUN pip install --no-cache-dir -r requirements.txt
ENV UV_LINK_MODE=copy
COPY . .
# Set the cache directory to /tmp instead of root
ENV UV_CACHE_DIR=/tmp/.cache/uv
ENV UV_PYTHON_INSTALL_DIR=/app/.bin
# Install the project's dependencies using the lockfile and settings
RUN --mount=type=cache,target=/tmp/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
COPY . /app
RUN --mount=type=cache,target=/tmp/.cache/uv \
uv sync --frozen --no-dev
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
RUN chmod +x *.sh && \
dos2unix *.sh
# Set default values to prevent issues
ENV DRYRUN="True"
ENV DEBUG_LEVEL="INFO"
ENV RUN_ONLY_ONCE="False"
ENV SLEEP_DURATION=3600
ENV LOG_FILE="log.log"
ENV MARK_FILE="mark.log"
ENV REQUEST_TIME=300
ENV GENERATE_GUIDS="True"
ENV GENERATE_LOCATIONS="True"
ENV MAX_THREADS=1
ENV USER_MAPPING=""
ENV LIBRARY_MAPPING=""
ENV BLACKLIST_LIBRARY=""
ENV WHITELIST_LIBRARY=""
ENV BLACKLIST_LIBRARY_TYPE=""
ENV WHITELIST_LIBRARY_TYPE=""
ENV BLACKLIST_USERS=""
ENV WHITELIST_USERS=""
ENV PLEX_BASEURL=""
ENV PLEX_TOKEN=""
ENV PLEX_USERNAME=""
ENV PLEX_PASSWORD=""
ENV PLEX_SERVERNAME=""
ENV SSL_BYPASS="False"
ENV JELLYFIN_BASEURL=""
ENV JELLYFIN_TOKEN=""
ENV EMBY_BASEURL=""
ENV EMBY_TOKEN=""
ENV SYNC_FROM_PLEX_TO_JELLYFIN="True"
ENV SYNC_FROM_PLEX_TO_PLEX="True"
ENV SYNC_FROM_PLEX_TO_EMBY="True"
ENV SYNC_FROM_JELLYFIN_TO_PLEX="True"
ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN="True"
ENV SYNC_FROM_JELLYFIN_TO_EMBY="True"
ENV SYNC_FROM_EMBY_TO_PLEX="True"
ENV SYNC_FROM_EMBY_TO_JELLYFIN="True"
ENV SYNC_FROM_EMBY_TO_EMBY="True"
ENTRYPOINT ["/bin/tini", "--", "/app/entrypoint.sh"]
CMD ["python", "-u", "main.py"]

View File

@@ -48,20 +48,14 @@ Full list of configuration options can be found in the [.env.sample](.env.sample
### Baremetal
- Setup virtualenv of your choice
- [Install uv](https://docs.astral.sh/uv/getting-started/installation/)
- Install dependencies
```bash
pip install -r requirements.txt
```
- Create a .env file similar to .env.sample, uncomment whitelist and blacklist if needed, fill in baseurls and tokens
- Create a .env file similar to .env.sample; fill in baseurls and tokens, **remember to uncomment anything you wish to use** (e.g., user mapping, library mapping, black/whitelist, etc.)
- Run
```bash
python main.py
uv run main.py
```
### Docker
@@ -104,6 +98,7 @@ Full list of configuration options can be found in the [.env.sample](.env.sample
- Configuration
- Do not use quotes around variables in docker compose
- If you are not running all 3 supported servers, that is, Plex, Jellyfin, and Emby simultaneously, make sure to comment out the server url and token of the server you aren't using.
## Contributing

View File

@@ -50,12 +50,13 @@ echo "Starting JellyPlex-Watched with UID: $PUID and GID: $PGID"
# If root run as the created user
if [ "$(id -u)" = '0' ]; then
chown -R "$PUID:$PGID" /app/.venv
chown -R "$PUID:$PGID" "$LOG_DIR"
chown -R "$PUID:$PGID" "$MARK_DIR"
# Run the application as the created user
exec gosu "$PUID:$PGID" "$@"
else
# Run the application as the current user
exec "$@"
fi
# Run the application as the current user
exec "$@"

View File

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

24
pyproject.toml Normal file
View File

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

Binary file not shown.

View File

@@ -1,16 +1,18 @@
from src.functions import logger, search_mapping
from loguru import logger
from src.functions import search_mapping
def setup_black_white_lists(
blacklist_library: str,
whitelist_library: str,
blacklist_library_type: str,
whitelist_library_type: str,
blacklist_users: str,
whitelist_users: str,
library_mapping=None,
user_mapping=None,
):
blacklist_library: list[str] | None,
whitelist_library: list[str] | None,
blacklist_library_type: list[str] | None,
whitelist_library_type: list[str] | None,
blacklist_users: list[str] | None,
whitelist_users: list[str] | None,
library_mapping: dict[str, str] | None = None,
user_mapping: dict[str, str] | None = None,
) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str]]:
blacklist_library, blacklist_library_type, blacklist_users = setup_x_lists(
blacklist_library,
blacklist_library_type,
@@ -40,53 +42,44 @@ def setup_black_white_lists(
def setup_x_lists(
xlist_library,
xlist_library_type,
xlist_users,
xlist_type,
library_mapping=None,
user_mapping=None,
):
xlist_library: list[str] | None,
xlist_library_type: list[str] | None,
xlist_users: list[str] | None,
xlist_type: str | None,
library_mapping: dict[str, str] | None = None,
user_mapping: dict[str, str] | None = None,
) -> tuple[list[str], list[str], list[str]]:
out_library: list[str] = []
if xlist_library:
if len(xlist_library) > 0:
xlist_library = xlist_library.split(",")
xlist_library = [x.strip() for x in xlist_library]
if library_mapping:
temp_library = []
for library in xlist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
out_library = [x.strip() for x in xlist_library]
if library_mapping:
temp_library: list[str] = []
for library in xlist_library:
library_other = search_mapping(library_mapping, library)
if library_other:
temp_library.append(library_other)
xlist_library = xlist_library + temp_library
else:
xlist_library = []
logger(f"{xlist_type}list Library: {xlist_library}", 1)
out_library = out_library + temp_library
logger.info(f"{xlist_type}list Library: {xlist_library}")
out_library_type: list[str] = []
if xlist_library_type:
if len(xlist_library_type) > 0:
xlist_library_type = xlist_library_type.split(",")
xlist_library_type = [x.lower().strip() for x in xlist_library_type]
else:
xlist_library_type = []
logger(f"{xlist_type}list Library Type: {xlist_library_type}", 1)
out_library_type = [x.lower().strip() for x in xlist_library_type]
logger.info(f"{xlist_type}list Library Type: {out_library_type}")
out_users: list[str] = []
if xlist_users:
if len(xlist_users) > 0:
xlist_users = xlist_users.split(",")
xlist_users = [x.lower().strip() for x in xlist_users]
if user_mapping:
temp_users = []
for user in xlist_users:
user_other = search_mapping(user_mapping, user)
if user_other:
temp_users.append(user_other)
out_users = [x.lower().strip() for x in xlist_users]
if user_mapping:
temp_users: list[str] = []
for user in out_users:
user_other = search_mapping(user_mapping, user)
if user_other:
temp_users.append(user_other)
xlist_users = xlist_users + temp_users
else:
xlist_users = []
else:
xlist_users = []
logger(f"{xlist_type}list Users: {xlist_users}", 1)
out_users = out_users + temp_users
return xlist_library, xlist_library_type, xlist_users
logger.info(f"{xlist_type}list Users: {out_users}")
return out_library, out_library_type, out_users

View File

@@ -1,7 +1,9 @@
import os
from typing import Literal
from dotenv import load_dotenv
from loguru import logger
from src.functions import logger, str_to_bool
from src.functions import str_to_bool
from src.plex import Plex
from src.jellyfin import Jellyfin
from src.emby import Emby
@@ -9,60 +11,53 @@ from src.emby import Emby
load_dotenv(override=True)
def jellyfin_emby_server_connection(server_baseurl, server_token, server_type):
servers = []
def jellyfin_emby_server_connection(
server_baseurl: str, server_token: str, server_type: Literal["jellyfin", "emby"]
) -> list[Jellyfin | Emby]:
servers: list[Jellyfin | Emby] = []
server: Jellyfin | Emby
server_baseurl = server_baseurl.split(",")
server_token = server_token.split(",")
server_baseurls = server_baseurl.split(",")
server_tokens = server_token.split(",")
if len(server_baseurl) != len(server_token):
if len(server_baseurls) != len(server_tokens):
raise Exception(
f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries"
)
for i, baseurl in enumerate(server_baseurl):
baseurl = baseurl.strip()
if baseurl[-1] == "/":
baseurl = baseurl[:-1]
for i, base_url in enumerate(server_baseurls):
base_url = base_url.strip()
if base_url[-1] == "/":
base_url = base_url[:-1]
if server_type == "jellyfin":
server = Jellyfin(baseurl=baseurl, token=server_token[i].strip())
servers.append(
(
"jellyfin",
server,
)
)
server = Jellyfin(base_url=base_url, token=server_tokens[i].strip())
servers.append(server)
elif server_type == "emby":
server = Emby(baseurl=baseurl, token=server_token[i].strip())
servers.append(
(
"emby",
server,
)
)
server = Emby(base_url=base_url, token=server_tokens[i].strip())
servers.append(server)
else:
raise Exception("Unknown server type")
logger(f"{server_type} Server {i} info: {server.info()}", 3)
logger.debug(f"{server_type} Server {i} info: {server.info()}")
return servers
def generate_server_connections():
servers = []
def generate_server_connections() -> list[Plex | Jellyfin | Emby]:
servers: list[Plex | Jellyfin | Emby] = []
plex_baseurl = os.getenv("PLEX_BASEURL", None)
plex_token = os.getenv("PLEX_TOKEN", None)
plex_username = os.getenv("PLEX_USERNAME", None)
plex_password = os.getenv("PLEX_PASSWORD", None)
plex_servername = os.getenv("PLEX_SERVERNAME", None)
plex_baseurl_str: str | None = os.getenv("PLEX_BASEURL", None)
plex_token_str: str | None = os.getenv("PLEX_TOKEN", None)
plex_username_str: str | None = os.getenv("PLEX_USERNAME", None)
plex_password_str: str | None = os.getenv("PLEX_PASSWORD", None)
plex_servername_str: str | None = os.getenv("PLEX_SERVERNAME", None)
ssl_bypass = str_to_bool(os.getenv("SSL_BYPASS", "False"))
if plex_baseurl and plex_token:
plex_baseurl = plex_baseurl.split(",")
plex_token = plex_token.split(",")
if plex_baseurl_str and plex_token_str:
plex_baseurl = plex_baseurl_str.split(",")
plex_token = plex_token_str.split(",")
if len(plex_baseurl) != len(plex_token):
raise Exception(
@@ -71,27 +66,22 @@ def generate_server_connections():
for i, url in enumerate(plex_baseurl):
server = Plex(
baseurl=url.strip(),
base_url=url.strip(),
token=plex_token[i].strip(),
username=None,
user_name=None,
password=None,
servername=None,
server_name=None,
ssl_bypass=ssl_bypass,
)
logger(f"Plex Server {i} info: {server.info()}", 3)
logger.debug(f"Plex Server {i} info: {server.info()}")
servers.append(
(
"plex",
server,
)
)
servers.append(server)
if plex_username and plex_password and plex_servername:
plex_username = plex_username.split(",")
plex_password = plex_password.split(",")
plex_servername = plex_servername.split(",")
if plex_username_str and plex_password_str and plex_servername_str:
plex_username = plex_username_str.split(",")
plex_password = plex_password_str.split(",")
plex_servername = plex_servername_str.split(",")
if len(plex_username) != len(plex_password) or len(plex_username) != len(
plex_servername
@@ -102,25 +92,19 @@ def generate_server_connections():
for i, username in enumerate(plex_username):
server = Plex(
baseurl=None,
base_url=None,
token=None,
username=username.strip(),
user_name=username.strip(),
password=plex_password[i].strip(),
servername=plex_servername[i].strip(),
server_name=plex_servername[i].strip(),
ssl_bypass=ssl_bypass,
)
logger(f"Plex Server {i} info: {server.info()}", 3)
servers.append(
(
"plex",
server,
)
)
logger.debug(f"Plex Server {i} info: {server.info()}")
servers.append(server)
jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None)
jellyfin_token = os.getenv("JELLYFIN_TOKEN", None)
if jellyfin_baseurl and jellyfin_token:
servers.extend(
jellyfin_emby_server_connection(
@@ -130,7 +114,6 @@ def generate_server_connections():
emby_baseurl = os.getenv("EMBY_BASEURL", None)
emby_token = os.getenv("EMBY_TOKEN", None)
if emby_baseurl and emby_token:
servers.extend(
jellyfin_emby_server_connection(emby_baseurl, emby_token, "emby")

View File

@@ -1,9 +1,10 @@
from src.jellyfin_emby import JellyfinEmby
from packaging import version
from packaging.version import parse, Version
from loguru import logger
class Emby(JellyfinEmby):
def __init__(self, baseurl, token):
def __init__(self, base_url: str, token: str) -> None:
authorization = (
"Emby , "
'Client="JellyPlex-Watched", '
@@ -18,8 +19,14 @@ class Emby(JellyfinEmby):
}
super().__init__(
server_type="Emby", baseurl=baseurl, token=token, headers=headers
server_type="Emby", base_url=base_url, token=token, headers=headers
)
def is_partial_update_supported(self, server_version):
return server_version > version.parse("4.4")
def is_partial_update_supported(self, server_version: Version) -> bool:
if not server_version >= parse("4.4"):
logger.info(
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
)
return False
return True

View File

@@ -1,48 +1,23 @@
import os
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any, Callable
from dotenv import load_dotenv
load_dotenv(override=True)
log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
mark_file = os.getenv("MARK_FILE", os.getenv("MARKFILE", "mark.log"))
def logger(message: str, log_type=0):
debug = str_to_bool(os.getenv("DEBUG", "False"))
debug_level = os.getenv("DEBUG_LEVEL", "info").lower()
output = str(message)
if log_type == 0:
pass
elif log_type == 1 and (debug and debug_level in ("info", "debug")):
output = f"[INFO]: {output}"
elif log_type == 2:
output = f"[ERROR]: {output}"
elif log_type == 3 and (debug and debug_level == "debug"):
output = f"[DEBUG]: {output}"
elif log_type == 4:
output = f"[WARNING]: {output}"
elif log_type == 5:
output = f"[MARK]: {output}"
elif log_type == 6:
output = f"[DRYRUN]: {output}"
else:
output = None
if output is not None:
print(output)
with open(f"{log_file}", "a", encoding="utf-8") as file:
file.write(output + "\n")
def log_marked(
username: str, library: str, movie_show: str, episode: str = None, duration=None
):
if mark_file is None:
return
output = f"{username}/{library}/{movie_show}"
server_type: str,
server_name: str,
username: str,
library: str,
movie_show: str,
episode: str | None = None,
duration: float | None = None,
) -> None:
output = f"{server_type}/{server_name}/{username}/{library}/{movie_show}"
if episode:
output += f"/{episode}"
@@ -56,29 +31,14 @@ def log_marked(
# Reimplementation of distutils.util.strtobool due to it being deprecated
# Source: https://github.com/PostHog/posthog/blob/01e184c29d2c10c43166f1d40a334abbc3f99d8a/posthog/utils.py#L668
def str_to_bool(value: any) -> bool:
def str_to_bool(value: str) -> bool:
if not value:
return False
return str(value).lower() in ("y", "yes", "t", "true", "on", "1")
# Search for nested element in list
def contains_nested(element, lst):
if lst is None:
return None
for i, item in enumerate(lst):
if item is None:
continue
if element in item:
return i
elif element == item:
return i
return None
# Get mapped value
def search_mapping(dictionary: dict, key_value: str):
def search_mapping(dictionary: dict[str, str], key_value: str) -> str | None:
if key_value in dictionary.keys():
return dictionary[key_value]
elif key_value.lower() in dictionary.keys():
@@ -93,36 +53,66 @@ def search_mapping(dictionary: dict, key_value: str):
return None
# Return list of objects that exist in both lists including mappings
def match_list(
list1: list[str], list2: list[str], list_mapping: dict[str, str] | None = None
) -> list[str]:
output: list[str] = []
for element in list1:
if element in list2:
output.append(element)
elif list_mapping:
element_other = search_mapping(list_mapping, element)
if element_other in list2:
output.append(element)
return output
def future_thread_executor(
args: list, threads: int = None, override_threads: bool = False
):
futures_list = []
results = []
args: list[tuple[Callable[..., Any], ...]],
threads: int | None = None,
override_threads: bool = False,
) -> list[Any]:
results: list[Any] = []
workers = min(int(os.getenv("MAX_THREADS", 32)), os.cpu_count() * 2)
if threads:
# Determine the number of workers, defaulting to 1 if os.cpu_count() returns None
max_threads_env: int = int(os.getenv("MAX_THREADS", 32))
cpu_threads: int = os.cpu_count() or 1 # Default to 1 if os.cpu_count() is None
workers: int = min(max_threads_env, cpu_threads * 2)
# Adjust workers based on threads parameter and override_threads flag
if threads is not None:
workers = min(threads, workers)
if override_threads:
workers = threads
workers = threads if threads is not None else workers
# 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:
futures_list: list[Future[Any]] = []
for arg in args:
# * arg unpacks the list into actual arguments
futures_list.append(executor.submit(*arg))
for future in futures_list:
for out in futures_list:
try:
result = future.result()
result = out.result()
results.append(result)
except Exception as e:
raise Exception(e)
return results
def parse_string_to_list(string: str | None) -> list[str]:
output: list[str] = []
if string and len(string) > 0:
output = string.split(",")
return output

View File

@@ -1,9 +1,10 @@
from src.jellyfin_emby import JellyfinEmby
from packaging import version
from packaging.version import parse, Version
from loguru import logger
class Jellyfin(JellyfinEmby):
def __init__(self, baseurl, token):
def __init__(self, base_url: str, token: str) -> None:
authorization = (
"MediaBrowser , "
'Client="JellyPlex-Watched", '
@@ -18,8 +19,14 @@ class Jellyfin(JellyfinEmby):
}
super().__init__(
server_type="Jellyfin", baseurl=baseurl, token=token, headers=headers
server_type="Jellyfin", base_url=base_url, token=token, headers=headers
)
def is_partial_update_supported(self, server_version):
return server_version >= version.parse("10.9.0")
def is_partial_update_supported(self, server_version: Version) -> bool:
if not server_version >= parse("10.9.0"):
logger.info(
f"{self.server_type}: Server version {server_version} does not support updating playback position.",
)
return False
return True

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,24 @@
from loguru import logger
from src.functions import (
logger,
match_list,
search_mapping,
)
from src.emby import Emby
from src.jellyfin import Jellyfin
from src.plex import Plex
def check_skip_logic(
library_title,
library_type,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping=None,
):
library_title: str,
library_type: str,
blacklist_library: list[str],
whitelist_library: list[str],
blacklist_library_type: list[str],
whitelist_library_type: list[str],
library_mapping: dict[str, str] | None = None,
) -> str | None:
skip_reason = None
library_other = None
if library_mapping:
@@ -47,12 +53,12 @@ def check_skip_logic(
def check_blacklist_logic(
library_title,
library_type,
blacklist_library,
blacklist_library_type,
library_other=None,
):
library_title: str,
library_type: str,
blacklist_library: list[str],
blacklist_library_type: list[str],
library_other: str | None = None,
) -> str | None:
skip_reason = None
if isinstance(library_type, (list, tuple, set)):
for library_type_item in library_type:
@@ -83,12 +89,12 @@ def check_blacklist_logic(
def check_whitelist_logic(
library_title,
library_type,
whitelist_library,
whitelist_library_type,
library_other=None,
):
library_title: str,
library_type: str,
whitelist_library: list[str],
whitelist_library_type: list[str],
library_other: str | None = None,
) -> str | None:
skip_reason = None
if len(whitelist_library_type) > 0:
if isinstance(library_type, (list, tuple, set)):
@@ -129,140 +135,73 @@ def check_whitelist_logic(
return skip_reason
def show_title_dict(user_list: dict):
try:
show_output_dict = {}
show_output_dict["locations"] = []
show_counter = 0 # Initialize a counter for the current show position
def filter_libaries(
server_libraries: dict[str, str],
blacklist_library: list[str],
blacklist_library_type: list[str],
whitelist_library: list[str],
whitelist_library_type: list[str],
library_mapping: dict[str, str] | None = None,
) -> list[str]:
filtered_libaries: list[str] = []
for library in server_libraries:
skip_reason = check_skip_logic(
library,
server_libraries[library],
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
show_output_keys = user_list.keys()
show_output_keys = [dict(x) for x in list(show_output_keys)]
for show_key in show_output_keys:
for provider_key, provider_value in show_key.items():
# Skip title
if provider_key.lower() == "title":
continue
if provider_key.lower() not in show_output_dict:
show_output_dict[provider_key.lower()] = [None] * show_counter
if provider_key.lower() == "locations":
show_output_dict[provider_key.lower()].append(provider_value)
else:
show_output_dict[provider_key.lower()].append(
provider_value.lower()
)
if skip_reason:
logger.info(f"Skipping library {library}: {skip_reason}")
continue
show_counter += 1
for key in show_output_dict:
if len(show_output_dict[key]) < show_counter:
show_output_dict[key].append(None)
filtered_libaries.append(library)
return show_output_dict
except Exception:
logger("Skipping show_output_dict ", 1)
return {}
return filtered_libaries
def episode_title_dict(user_list: dict):
try:
episode_output_dict = {}
episode_output_dict["completed"] = []
episode_output_dict["time"] = []
episode_output_dict["locations"] = []
episode_output_dict["show"] = []
episode_counter = 0 # Initialize a counter for the current episode position
def setup_libraries(
server_1: Plex | Jellyfin | Emby,
server_2: Plex | Jellyfin | Emby,
blacklist_library: list[str],
blacklist_library_type: list[str],
whitelist_library: list[str],
whitelist_library_type: list[str],
library_mapping: dict[str, str] | None = None,
) -> tuple[list[str], list[str]]:
server_1_libraries = server_1.get_libraries()
server_2_libraries = server_2.get_libraries()
# Iterate through the shows and episodes in user_list
for show in user_list:
logger.debug(f"{server_1.server_type}: Libraries and types {server_1_libraries}")
logger.debug(f"{server_2.server_type}: Libraries and types {server_2_libraries}")
for episode in user_list[show]:
# Add the show title to the episode_output_dict if it doesn't exist
if "show" not in episode_output_dict:
episode_output_dict["show"] = [None] * episode_counter
# Filter out all blacklist, whitelist libaries
filtered_server_1_libraries = filter_libaries(
server_1_libraries,
blacklist_library,
blacklist_library_type,
whitelist_library,
whitelist_library_type,
library_mapping,
)
filtered_server_2_libraries = filter_libaries(
server_2_libraries,
blacklist_library,
blacklist_library_type,
whitelist_library,
whitelist_library_type,
library_mapping,
)
# Add the show title to the episode_output_dict
episode_output_dict["show"].append(dict(show))
output_server_1_libaries = match_list(
filtered_server_1_libraries, filtered_server_2_libraries, library_mapping
)
output_server_2_libaries = match_list(
filtered_server_2_libraries, filtered_server_1_libraries, library_mapping
)
# 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
if episode_key == "locations":
episode_output_dict[episode_key.lower()].append(episode_value)
# If the key is "status", append the "completed" and "time" values
elif episode_key == "status":
episode_output_dict["completed"].append(
episode_value["completed"]
)
episode_output_dict["time"].append(episode_value["time"])
# For other keys, append the value to the list
else:
episode_output_dict[episode_key.lower()].append(
episode_value.lower()
)
# Increment the episode_counter
episode_counter += 1
# Extend the lists in episode_output_dict with None values to match the current episode_counter
for key in episode_output_dict:
if len(episode_output_dict[key]) < episode_counter:
episode_output_dict[key].append(None)
return episode_output_dict
except Exception:
logger("Skipping episode_output_dict", 1)
return {}
def movies_title_dict(user_list: dict):
try:
movies_output_dict = {}
movies_output_dict["completed"] = []
movies_output_dict["time"] = []
movies_output_dict["locations"] = []
movie_counter = 0 # Initialize a counter for the current movie position
for movie in user_list:
for movie_key, movie_value in movie.items():
if movie_key != "status":
if movie_key.lower() not in movies_output_dict:
movies_output_dict[movie_key.lower()] = []
if movie_key == "locations":
movies_output_dict[movie_key.lower()].append(movie_value)
elif movie_key == "status":
movies_output_dict["completed"].append(movie_value["completed"])
movies_output_dict["time"].append(movie_value["time"])
else:
movies_output_dict[movie_key.lower()].append(movie_value.lower())
movie_counter += 1
for key in movies_output_dict:
if len(movies_output_dict[key]) < movie_counter:
movies_output_dict[key].append(None)
return movies_output_dict
except Exception:
logger("Skipping movies_output_dict failed", 1)
return {}
def generate_library_guids_dict(user_list: dict):
# Handle the case where user_list is empty or does not contain the expected keys and values
if not user_list:
return {}, {}, {}
show_output_dict = show_title_dict(user_list)
episode_output_dict = episode_title_dict(user_list)
movies_output_dict = movies_title_dict(user_list)
return show_output_dict, episode_output_dict, movies_output_dict
return output_server_1_libaries, output_server_2_libaries

View File

@@ -1,9 +1,17 @@
import os, traceback, json
import os
import traceback
import json
import sys
from dotenv import load_dotenv
from time import sleep, perf_counter
from loguru import logger
from src.emby import Emby
from src.jellyfin import Jellyfin
from src.plex import Plex
from src.library import setup_libraries
from src.functions import (
logger,
parse_string_to_list,
str_to_bool,
)
from src.users import setup_users
@@ -15,8 +23,30 @@ from src.connection import generate_server_connections
load_dotenv(override=True)
log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
level = os.getenv("DEBUG_LEVEL", "INFO").upper()
def should_sync_server(server_1_type, server_2_type):
def configure_logger() -> None:
# Remove default logger to configure our own
logger.remove()
# Choose log level based on environment
# If in debug mode with a "debug" level, use DEBUG; otherwise, default to INFO.
if level not in ["INFO", "DEBUG", "TRACE"]:
logger.add(sys.stdout)
raise Exception("Invalid DEBUG_LEVEL, please choose between INFO, DEBUG, TRACE")
# Add a sink for file logging and the console.
logger.add(log_file, level=level, mode="w")
logger.add(sys.stdout, level=level)
def should_sync_server(
server_1: Plex | Jellyfin | Emby,
server_2: Plex | Jellyfin | Emby,
) -> bool:
sync_from_plex_to_jellyfin = str_to_bool(
os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True")
)
@@ -39,75 +69,76 @@ def should_sync_server(server_1_type, server_2_type):
)
sync_from_emby_to_emby = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_EMBY", "True"))
if server_1_type == "plex":
if server_2_type == "jellyfin" and not sync_from_plex_to_jellyfin:
logger("Sync from plex -> jellyfin is disabled", 1)
if isinstance(server_1, Plex):
if isinstance(server_2, Jellyfin) and not sync_from_plex_to_jellyfin:
logger.info("Sync from plex -> jellyfin is disabled")
return False
if server_2_type == "emby" and not sync_from_plex_to_emby:
logger("Sync from plex -> emby is disabled", 1)
if isinstance(server_2, Emby) and not sync_from_plex_to_emby:
logger.info("Sync from plex -> emby is disabled")
return False
if server_2_type == "plex" and not sync_from_plex_to_plex:
logger("Sync from plex -> plex is disabled", 1)
if isinstance(server_2, Plex) and not sync_from_plex_to_plex:
logger.info("Sync from plex -> plex is disabled")
return False
if server_1_type == "jellyfin":
if server_2_type == "plex" and not sync_from_jelly_to_plex:
logger("Sync from jellyfin -> plex is disabled", 1)
if isinstance(server_1, Jellyfin):
if isinstance(server_2, Plex) and not sync_from_jelly_to_plex:
logger.info("Sync from jellyfin -> plex is disabled")
return False
if server_2_type == "jellyfin" and not sync_from_jelly_to_jellyfin:
logger("Sync from jellyfin -> jellyfin is disabled", 1)
if isinstance(server_2, Jellyfin) and not sync_from_jelly_to_jellyfin:
logger.info("Sync from jellyfin -> jellyfin is disabled")
return False
if server_2_type == "emby" and not sync_from_jelly_to_emby:
logger("Sync from jellyfin -> emby is disabled", 1)
if isinstance(server_2, Emby) and not sync_from_jelly_to_emby:
logger.info("Sync from jellyfin -> emby is disabled")
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)
if isinstance(server_1, Emby):
if isinstance(server_2, Plex) and not sync_from_emby_to_plex:
logger.info("Sync from emby -> plex is disabled")
return False
if server_2_type == "jellyfin" and not sync_from_emby_to_jellyfin:
logger("Sync from emby -> jellyfin is disabled", 1)
if isinstance(server_2, Jellyfin) and not sync_from_emby_to_jellyfin:
logger.info("Sync from emby -> jellyfin is disabled")
return False
if server_2_type == "emby" and not sync_from_emby_to_emby:
logger("Sync from emby -> emby is disabled", 1)
if isinstance(server_2, Emby) and not sync_from_emby_to_emby:
logger.info("Sync from emby -> emby is disabled")
return False
return True
def main_loop():
log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log"))
# Delete log_file if it exists
if os.path.exists(log_file):
os.remove(log_file)
def main_loop() -> None:
dryrun = str_to_bool(os.getenv("DRYRUN", "False"))
logger(f"Dryrun: {dryrun}", 1)
logger.info(f"Dryrun: {dryrun}")
user_mapping = os.getenv("USER_MAPPING")
if user_mapping:
user_mapping = json.loads(user_mapping.lower())
logger(f"User Mapping: {user_mapping}", 1)
user_mapping_env = os.getenv("USER_MAPPING", None)
user_mapping = None
if user_mapping_env:
user_mapping = json.loads(user_mapping_env.lower())
logger.info(f"User Mapping: {user_mapping}")
library_mapping = os.getenv("LIBRARY_MAPPING")
if library_mapping:
library_mapping = json.loads(library_mapping)
logger(f"Library Mapping: {library_mapping}", 1)
library_mapping_env = os.getenv("LIBRARY_MAPPING", None)
library_mapping = None
if library_mapping_env:
library_mapping = json.loads(library_mapping_env)
logger.info(f"Library Mapping: {library_mapping}")
# Create (black/white)lists
logger("Creating (black/white)lists", 1)
blacklist_library = os.getenv("BLACKLIST_LIBRARY", None)
whitelist_library = os.getenv("WHITELIST_LIBRARY", None)
blacklist_library_type = os.getenv("BLACKLIST_LIBRARY_TYPE", None)
whitelist_library_type = os.getenv("WHITELIST_LIBRARY_TYPE", None)
blacklist_users = os.getenv("BLACKLIST_USERS", None)
whitelist_users = os.getenv("WHITELIST_USERS", None)
logger.info("Creating (black/white)lists")
blacklist_library = parse_string_to_list(os.getenv("BLACKLIST_LIBRARY", None))
whitelist_library = parse_string_to_list(os.getenv("WHITELIST_LIBRARY", None))
blacklist_library_type = parse_string_to_list(
os.getenv("BLACKLIST_LIBRARY_TYPE", None)
)
whitelist_library_type = parse_string_to_list(
os.getenv("WHITELIST_LIBRARY_TYPE", None)
)
blacklist_users = parse_string_to_list(os.getenv("BLACKLIST_USERS", None))
whitelist_users = parse_string_to_list(os.getenv("WHITELIST_USERS", None))
(
blacklist_library,
@@ -128,7 +159,7 @@ def main_loop():
)
# Create server connections
logger("Creating server connections", 1)
logger.info("Creating server connections")
servers = generate_server_connections()
for server_1 in servers:
@@ -139,75 +170,71 @@ def main_loop():
# Start server_2 at the next server in the list
for server_2 in servers[servers.index(server_1) + 1 :]:
# Check if server 1 and server 2 are going to be synced in either direction, skip if not
if not should_sync_server(
server_1[0], server_2[0]
) and not should_sync_server(server_2[0], server_1[0]):
if not should_sync_server(server_1, server_2) and not should_sync_server(
server_2, server_1
):
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)
logger.info(f"Server 1: {type(server_1)}: {server_1.info()}")
logger.info(f"Server 2: {type(server_2)}: {server_2.info()}")
# Create users list
logger("Creating users list", 1)
logger.info("Creating users list")
server_1_users, server_2_users = setup_users(
server_1, server_2, blacklist_users, whitelist_users, user_mapping
)
logger("Creating watched lists", 1)
server_1_watched = server_1[1].get_watched(
server_1_users,
server_1_libraries, server_2_libraries = setup_libraries(
server_1,
server_2,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library,
whitelist_library_type,
library_mapping,
)
logger("Finished creating watched list server 1", 1)
logger.info(f"Server 1 syncing libraries: {server_1_libraries}")
logger.info(f"Server 2 syncing libraries: {server_2_libraries}")
server_2_watched = server_2[1].get_watched(
server_2_users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
logger("Finished creating watched list server 2", 1)
logger.info("Creating watched lists", 1)
server_1_watched = server_1.get_watched(server_1_users, server_1_libraries)
logger.info("Finished creating watched list server 1")
logger(f"Server 1 watched: {server_1_watched}", 3)
logger(f"Server 2 watched: {server_2_watched}", 3)
server_2_watched = server_2.get_watched(server_2_users, server_2_libraries)
logger.info("Finished creating watched list server 2")
logger("Cleaning Server 1 Watched", 1)
logger.trace(f"Server 1 watched: {server_1_watched}")
logger.trace(f"Server 2 watched: {server_2_watched}")
logger.info("Cleaning Server 1 Watched", 1)
server_1_watched_filtered = cleanup_watched(
server_1_watched, server_2_watched, user_mapping, library_mapping
)
logger("Cleaning Server 2 Watched", 1)
logger.info("Cleaning Server 2 Watched", 1)
server_2_watched_filtered = cleanup_watched(
server_2_watched, server_1_watched, user_mapping, library_mapping
)
logger(
logger.debug(
f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}",
1,
)
logger(
logger.debug(
f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}",
1,
)
if should_sync_server(server_2[0], server_1[0]):
logger(f"Syncing {server_2[1].info()} -> {server_1[1].info()}", 0)
server_1[1].update_watched(
if should_sync_server(server_2, server_1):
logger.info(f"Syncing {server_2.info()} -> {server_1.info()}")
server_1.update_watched(
server_2_watched_filtered,
user_mapping,
library_mapping,
dryrun,
)
if should_sync_server(server_1[0], server_2[0]):
logger(f"Syncing {server_1[1].info()} -> {server_2[1].info()}", 0)
server_2[1].update_watched(
if should_sync_server(server_1, server_2):
logger.info(f"Syncing {server_1.info()} -> {server_2.info()}")
server_2.update_watched(
server_1_watched_filtered,
user_mapping,
library_mapping,
@@ -215,43 +242,46 @@ def main_loop():
)
def main():
@logger.catch
def main() -> None:
run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False"))
sleep_duration = float(os.getenv("SLEEP_DURATION", "3600"))
times = []
times: list[float] = []
while True:
try:
start = perf_counter()
# Reconfigure the logger on each loop so the logs are rotated on each run
configure_logger()
main_loop()
end = perf_counter()
times.append(end - start)
if len(times) > 0:
logger(f"Average time: {sum(times) / len(times)}", 0)
logger.info(f"Average time: {sum(times) / len(times)}")
if run_only_once:
break
logger(f"Looping in {sleep_duration}")
logger.info(f"Looping in {sleep_duration}")
sleep(sleep_duration)
except Exception as error:
if isinstance(error, list):
for message in error:
logger(message, log_type=2)
logger.error(message)
else:
logger(error, log_type=2)
logger.error(error)
logger(traceback.format_exc(), 2)
logger.error(traceback.format_exc())
if run_only_once:
break
logger(f"Retrying in {sleep_duration}", log_type=0)
logger.info(f"Retrying in {sleep_duration}")
sleep(sleep_duration)
except KeyboardInterrupt:
if len(times) > 0:
logger(f"Average time: {sum(times) / len(times)}", 0)
logger("Exiting", log_type=0)
logger.info(f"Average time: {sum(times) / len(times)}")
logger.info("Exiting")
os._exit(0)

View File

@@ -1,6 +1,7 @@
import os, requests, traceback
import os
import requests
from dotenv import load_dotenv
from typing import Dict, Union, FrozenSet
from loguru import logger
from urllib3.poolmanager import PoolManager
from math import floor
@@ -9,22 +10,24 @@ from requests.adapters import HTTPAdapter as RequestsHTTPAdapter
from plexapi.video import Show, Episode, Movie
from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount
from plexapi.myplex import MyPlexAccount, MyPlexUser
from plexapi.library import MovieSection, ShowSection
from src.functions import (
logger,
search_mapping,
future_thread_executor,
contains_nested,
log_marked,
str_to_bool,
)
from src.library import (
check_skip_logic,
generate_library_guids_dict,
from src.watched import (
LibraryData,
MediaIdentifiers,
MediaItem,
WatchedStatus,
Series,
UserData,
check_same_identifiers,
)
load_dotenv(override=True)
generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True"))
@@ -33,7 +36,9 @@ generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True"))
# Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
class HostNameIgnoringAdapter(RequestsHTTPAdapter):
def init_poolmanager(self, connections, maxsize, block=..., **pool_kwargs):
def init_poolmanager(
self, connections: int, maxsize: int | None, block=..., **pool_kwargs
) -> None:
self.poolmanager = PoolManager(
num_pools=connections,
maxsize=maxsize,
@@ -43,441 +48,345 @@ class HostNameIgnoringAdapter(RequestsHTTPAdapter):
)
def extract_guids_from_item(item: Union[Movie, Show, Episode]) -> Dict[str, str]:
def extract_guids_from_item(item: Movie | Show | Episode) -> dict[str, str]:
# If GENERATE_GUIDS is set to False, then return an empty dict
if not generate_guids:
return {}
guids: Dict[str, str] = dict(
guids: dict[str, str] = dict(
guid.id.split("://")
for guid in item.guids
if guid.id is not None and len(guid.id.strip()) > 0
if guid.id and len(guid.id.strip()) > 0
)
if len(guids) == 0:
logger(
f"Plex: Failed to get any guids for {item.title}",
1,
)
return guids
def get_guids(item: Union[Movie, Episode], completed=True):
if not item.locations:
logger(
f"Plex: {item.title} has no locations",
1,
)
def extract_identifiers_from_item(item: Movie | Show | Episode) -> MediaIdentifiers:
guids = extract_guids_from_item(item)
if not item.guids:
logger(
f"Plex: {item.title} has no guids",
1,
)
return {
"title": item.title,
"locations": (
return MediaIdentifiers(
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
imdb_id=guids.get("imdb"),
tvdb_id=guids.get("tvdb"),
tmdb_id=guids.get("tmdb"),
)
def get_user_library_watched_show(show, process_episodes, threads=None):
def get_mediaitem(item: Movie | Episode, completed: bool) -> MediaItem:
return MediaItem(
identifiers=extract_identifiers_from_item(item),
status=WatchedStatus(completed=completed, time=item.viewOffset),
)
def update_user_watched(
user: MyPlexAccount,
user_plex: PlexServer,
library_data: LibraryData,
library_name: str,
dryrun: bool,
) -> None:
try:
show_guids: FrozenSet = frozenset(
(
{
"title": show.title,
"locations": (
tuple([location.split("/")[-1] for location in show.locations])
if generate_locations
else tuple()
),
}
| extract_guids_from_item(show)
).items() # Merge the metadata and guid dictionaries
# If there are no movies or shows to update, exit early.
if not library_data.series and not library_data.movies:
return
logger.info(
f"Plex: Updating watched for {user.title} in library {library_name}"
)
library_section = user_plex.library.section(library_name)
episode_guids_args = []
# Update movies.
if library_data.movies:
# Search for Plex movies that are currently marked as unwatched.
for plex_movie in library_section.search(unwatched=True):
plex_identifiers = extract_identifiers_from_item(plex_movie)
# Check each stored movie for a match.
for stored_movie in library_data.movies:
if check_same_identifiers(
plex_identifiers, stored_movie.identifiers
):
# If the stored movie is marked as watched (or has enough progress),
# update the Plex movie accordingly.
if stored_movie.status.completed:
msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}"
if not dryrun:
plex_movie.markWatched()
for episode in process_episodes:
episode_guids_args.append([get_guids, episode, episode.isWatched])
episode_guids_results = future_thread_executor(
episode_guids_args, threads=threads
)
episode_guids = []
for index, episode in enumerate(process_episodes):
episode_guids.append(episode_guids_results[index])
return show_guids, episode_guids
except Exception:
return {}, {}
def get_user_library_watched(user, user_plex, library):
user_name: str = user.username.lower() if user.username else user.title.lower()
try:
logger(
f"Plex: Generating watched for {user_name} in library {library.title}",
0,
)
library_videos = user_plex.library.section(library.title)
if library.type == "movie":
watched = []
args = [
[get_guids, video, video.isWatched]
for video in library_videos.search(unwatched=False)
+ library_videos.search(inProgress=True)
if video.isWatched or video.viewOffset >= 60000
]
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":
watched = {}
# Get all watched shows and partially watched shows
parallel_show_task = []
parallel_episodes_task = []
for show in library_videos.search(unwatched=False) + library_videos.search(
inProgress=True
):
process_episodes = []
for episode in show.episodes():
if episode.isWatched or episode.viewOffset >= 60000:
process_episodes.append(episode)
# Shows with more than 24 episodes has its episodes processed in parallel
# Shows with less than 24 episodes has its episodes processed in serial but the shows are processed in parallel
if len(process_episodes) >= 24:
parallel_episodes_task.append(
[
get_user_library_watched_show,
show,
process_episodes,
len(process_episodes),
]
)
else:
parallel_show_task.append(
[get_user_library_watched_show, show, process_episodes, 1]
)
for show_guids, episode_guids in future_thread_executor(
parallel_show_task, threads=len(parallel_show_task)
) + future_thread_executor(parallel_episodes_task, threads=1):
if show_guids and episode_guids:
watched[show_guids] = episode_guids
logger(
f"Plex: Added {episode_guids} to {user_name} {show_guids} watched list",
3,
)
else:
watched = None
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:
logger(
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
2,
)
return {}
def find_video(plex_search, video_ids, videos=None):
try:
if not generate_guids and not generate_locations:
return False, []
if generate_locations:
for location in plex_search.locations:
if (
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
episode_videos = []
if videos:
for show, episodes in videos.items():
show = {k: v for k, v in show}
if (
contains_nested(
location.split("/")[-1], show["locations"]
)
is not None
):
for episode in episodes:
episode_videos.append(episode)
return True, episode_videos
if generate_guids:
for guid in plex_search.guids:
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, []
except Exception:
return False, []
def get_video_status(plex_search, video_ids, videos):
try:
if not generate_guids and not generate_locations:
return None
if generate_locations:
for location in plex_search.locations:
if (
contains_nested(location.split("/")[-1], video_ids["locations"])
is not None
):
for video in videos:
if (
contains_nested(location.split("/")[-1], video["locations"])
is not None
):
return video["status"]
if generate_guids:
for guid in plex_search.guids:
guid_source, guid_id = guid.id.split("://")
# If show provider source and show provider id are in videos_shows_ids exactly, then the show is in the list
if guid_source in video_ids.keys():
if guid_id in video_ids[guid_source]:
for video in videos:
if guid_source in video.keys():
if guid_id == video[guid_source]:
return video["status"]
return None
except Exception:
return None
def update_user_watched(user, user_plex, library, videos, dryrun):
try:
logger(f"Plex: Updating watched for {user.title} in library {library}", 1)
(
videos_shows_ids,
videos_episodes_ids,
videos_movies_ids,
) = generate_library_guids_dict(videos)
logger(
f"Plex: mark list\nShows: {videos_shows_ids}\nEpisodes: {videos_episodes_ids}\nMovies: {videos_movies_ids}",
1,
)
library_videos = user_plex.library.section(library)
if videos_movies_ids:
for movies_search in library_videos.search(unwatched=True):
video_status = get_video_status(
movies_search, videos_movies_ids, videos
)
if video_status:
if video_status["completed"]:
msg = f"Plex: {movies_search.title} as watched for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
movies_search.markWatched()
else:
logger(msg, 6)
log_marked(user.title, library, movies_search.title, None, None)
elif video_status["time"] > 60_000:
msg = f"Plex: {movies_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
movies_search.updateTimeline(video_status["time"])
else:
logger(msg, 6)
log_marked(
user.title,
library,
movies_search.title,
duration=video_status["time"],
)
else:
logger(
f"Plex: Skipping movie {movies_search.title} as it is not in mark list for {user.title}",
1,
)
if videos_shows_ids and videos_episodes_ids:
for show_search in library_videos.search(unwatched=True):
show_found, episode_videos = find_video(
show_search, videos_shows_ids, videos
)
if show_found:
for episode_search in show_search.episodes():
video_status = get_video_status(
episode_search, videos_episodes_ids, episode_videos
)
if video_status:
if video_status["completed"]:
msg = f"Plex: {show_search.title} {episode_search.title} as watched for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
episode_search.markWatched()
else:
logger(msg, 6)
log_marked(
user.title,
library,
show_search.title,
episode_search.title,
)
else:
msg = f"Plex: {show_search.title} {episode_search.title} as partially watched for {floor(video_status['time'] / 60_000)} minutes for {user.title} in {library}"
if not dryrun:
logger(msg, 5)
episode_search.updateTimeline(video_status["time"])
else:
logger(msg, 6)
log_marked(
user.title,
library,
show_search.title,
episode_search.title,
video_status["time"],
)
else:
logger(
f"Plex: Skipping episode {episode_search.title} as it is not in mark list for {user.title}",
3,
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_movie.title,
None,
None,
)
else:
logger(
f"Plex: Skipping show {show_search.title} as it is not in mark list for {user.title}",
3,
)
else:
msg = f"Plex: {plex_movie.title} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user.title} in {library_name}"
if not dryrun:
plex_movie.updateTimeline(stored_movie.status.time)
if not videos_movies_ids and not videos_shows_ids and not videos_episodes_ids:
logger(
f"Jellyfin: No videos to mark as watched for {user.title} in library {library}",
1,
)
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_movie.title,
duration=stored_movie.status.time,
)
# Once matched, no need to check further.
break
# Update TV Shows (series/episodes).
if library_data.series:
# For each Plex show in the library section:
plex_shows = library_section.search(unwatched=True)
for plex_show in plex_shows:
# Extract identifiers from the Plex show.
plex_show_identifiers = extract_identifiers_from_item(plex_show)
# Try to find a matching series in your stored library.
for stored_series in library_data.series:
if check_same_identifiers(
plex_show_identifiers, stored_series.identifiers
):
logger.trace(f"Found matching show for '{plex_show.title}'")
# Now update episodes.
# Get the list of Plex episodes for this show.
plex_episodes = plex_show.episodes()
for plex_episode in plex_episodes:
plex_episode_identifiers = extract_identifiers_from_item(
plex_episode
)
for stored_ep in stored_series.episodes:
if check_same_identifiers(
plex_episode_identifiers, stored_ep.identifiers
):
if stored_ep.status.completed:
msg = f"Plex: {plex_show.title} {plex_episode.title} as watched for {user.title} in {library_name}"
if not dryrun:
plex_episode.markWatched()
logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_show.title,
plex_episode.title,
)
else:
msg = f"Plex: {plex_show.title} {plex_episode.title} as partially watched for {floor(stored_ep.status.time / 60_000)} minutes for {user.title} in {library_name}"
if not dryrun:
plex_episode.updateTimeline(
stored_ep.status.time
)
logger.success(
f"{'[DRYRUN] ' if dryrun else ''}{msg}"
)
log_marked(
"Plex",
user_plex.friendlyName,
user.title,
library_name,
plex_show.title,
plex_episode.title,
stored_ep.status.time,
)
break # Found a matching episode.
break # Found a matching show.
except Exception as e:
logger(
f"Plex: Failed to update watched for {user.title} in library {library}, Error: {e}",
logger.error(
f"Plex: Failed to update watched for {user.title} in library {library_name}, Error: {e}",
2,
)
logger(traceback.format_exc(), 2)
raise e
# class plex accept base url and token and username and password but default with none
class Plex:
def __init__(
self,
baseurl=None,
token=None,
username=None,
password=None,
servername=None,
ssl_bypass=False,
session=None,
):
self.baseurl = baseurl
self.token = token
self.username = username
self.password = password
self.servername = servername
self.ssl_bypass = ssl_bypass
base_url: str | None = None,
token: str | None = None,
user_name: str | None = None,
password: str | None = None,
server_name: str | None = None,
ssl_bypass: bool = False,
session: requests.Session | None = None,
) -> None:
self.server_type: str = "Plex"
self.ssl_bypass: bool = ssl_bypass
if ssl_bypass:
# Session for ssl bypass
session = requests.Session()
# By pass ssl hostname check https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186
session.mount("https://", HostNameIgnoringAdapter())
self.session = session
self.plex = self.login(self.baseurl, self.token)
self.admin_user = self.plex.myPlexAccount()
self.users = self.get_users()
self.plex: PlexServer = self.login(
base_url, token, user_name, password, server_name
)
def login(self, baseurl, token):
self.base_url: str = self.plex._baseurl
self.admin_user: MyPlexAccount = self.plex.myPlexAccount()
self.users: list[MyPlexUser | MyPlexAccount] = self.get_users()
def login(
self,
base_url: str | None,
token: str | None,
user_name: str | None,
password: str | None,
server_name: str | None,
) -> PlexServer:
try:
if baseurl and token:
plex = PlexServer(baseurl, token, session=self.session)
elif self.username and self.password and self.servername:
if base_url and token:
plex: PlexServer = PlexServer(base_url, token, session=self.session)
elif user_name and password and server_name:
# Login via plex account
account = MyPlexAccount(self.username, self.password)
plex = account.resource(self.servername).connect()
account = MyPlexAccount(user_name, password)
plex = account.resource(server_name).connect()
else:
raise Exception("No complete plex credentials provided")
return plex
except Exception as e:
if self.username or self.password:
msg = f"Failed to login via plex account {self.username}"
logger(f"Plex: Failed to login, {msg}, Error: {e}", 2)
if user_name:
msg = f"Failed to login via plex account {user_name}"
logger.error(f"Plex: Failed to login, {msg}, Error: {e}")
else:
logger(f"Plex: Failed to login, Error: {e}", 2)
logger.error(f"Plex: Failed to login, Error: {e}")
raise Exception(e)
def info(self) -> str:
return f"Plex {self.plex.friendlyName}: {self.plex.version}"
def get_users(self):
def get_users(self) -> list[MyPlexUser | MyPlexAccount]:
try:
users = self.plex.myPlexAccount().users()
users: list[MyPlexUser | MyPlexAccount] = self.plex.myPlexAccount().users()
# append self to users
users.append(self.plex.myPlexAccount())
return users
except Exception as e:
logger(f"Plex: Failed to get users, Error: {e}", 2)
logger.error(f"Plex: Failed to get users, Error: {e}")
raise Exception(e)
def get_watched(
self,
users,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
):
def get_libraries(self) -> dict[str, str]:
try:
# Get all libraries
users_watched = {}
output = {}
libraries = self.plex.library.sections()
logger.debug(
f"Plex: All Libraries {[library.title for library in libraries]}"
)
for library in libraries:
library_title = library.title
library_type = library.type
if library_type not in ["movie", "show"]:
logger.debug(
f"Plex: Skipping Library {library_title} found type {library_type}",
)
continue
output[library_title] = library_type
return output
except Exception as e:
logger.error(f"Plex: Failed to get libraries, Error: {e}")
raise Exception(e)
def get_user_library_watched(
self, user_name: str, user_plex: PlexServer, library: MovieSection | ShowSection
) -> LibraryData:
try:
logger.info(
f"Plex: Generating watched for {user_name} in library {library.title}",
)
watched = LibraryData(title=library.title)
library_videos = user_plex.library.section(library.title)
if library.type == "movie":
for video in library_videos.search(
unwatched=False
) + library_videos.search(inProgress=True):
if video.isWatched or video.viewOffset >= 60000:
watched.movies.append(get_mediaitem(video, video.isWatched))
elif library.type == "show":
# Keep track of processed shows to reduce duplicate shows
processed_shows = []
for show in library_videos.search(
unwatched=False
) + library_videos.search(inProgress=True):
if show.key in processed_shows:
continue
processed_shows.append(show.key)
show_guids = extract_guids_from_item(show)
episode_mediaitem = []
# Fetch watched or partially watched episodes
for episode in show.watched() + show.episodes(
viewOffset__gte=60_000
):
episode_mediaitem.append(
get_mediaitem(episode, episode.isWatched)
)
if episode_mediaitem:
watched.series.append(
Series(
identifiers=MediaIdentifiers(
title=show.title,
locations=(
tuple(
[
location.split("/")[-1]
for location in show.locations
]
)
if generate_locations
else tuple()
),
imdb_id=show_guids.get("imdb"),
tvdb_id=show_guids.get("tvdb"),
tmdb_id=show_guids.get("tmdb"),
),
episodes=episode_mediaitem,
)
)
return watched
except Exception as e:
logger.error(
f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}",
)
return LibraryData(title=library.title)
def get_watched(
self, users: list[MyPlexUser | MyPlexAccount], sync_libraries: list[str]
) -> dict[str, UserData]:
try:
users_watched: dict[str, UserData] = {}
for user in users:
if self.admin_user == user:
@@ -485,66 +394,50 @@ class Plex:
else:
token = user.get_token(self.plex.machineIdentifier)
if token:
user_plex = self.login(
self.plex._baseurl,
token,
)
user_plex = self.login(self.base_url, token, None, None, None)
else:
logger(
logger.error(
f"Plex: Failed to get token for {user.title}, skipping",
2,
)
users_watched[user.title] = {}
continue
user_name: str = (
user.username.lower() if user.username else user.title.lower()
)
libraries = user_plex.library.sections()
for library in libraries:
library_title = library.title
library_type = library.type
skip_reason = check_skip_logic(
library_title,
library_type,
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
library_mapping,
)
if skip_reason:
logger(
f"Plex: Skipping library {library_title}: {skip_reason}", 1
)
if library.title not in sync_libraries:
continue
user_watched = get_user_library_watched(user, user_plex, library)
library_data = self.get_user_library_watched(
user_name, user_plex, library
)
for user_watched, user_watched_temp in user_watched.items():
if user_watched not in users_watched:
users_watched[user_watched] = {}
users_watched[user_watched].update(user_watched_temp)
if user_name not in users_watched:
users_watched[user_name] = UserData()
users_watched[user_name].libraries[library.title] = library_data
return users_watched
except Exception as e:
logger(f"Plex: Failed to get watched, Error: {e}", 2)
logger.error(f"Plex: Failed to get watched, Error: {e}")
raise Exception(e)
def update_watched(
self, watched_list, user_mapping=None, library_mapping=None, dryrun=False
):
self,
watched_list: dict[str, UserData],
user_mapping: dict[str, str] | None = None,
library_mapping: dict[str, str] | None = None,
dryrun: bool = False,
) -> None:
try:
args = []
for user, libraries in watched_list.items():
for user, user_data in watched_list.items():
user_other = None
# If type of user is dict
if user_mapping:
if user in user_mapping.keys():
user_other = user_mapping[user]
elif user in user_mapping.values():
user_other = search_mapping(user_mapping, user)
user_other = search_mapping(user_mapping, user)
for index, value in enumerate(self.users):
username_title = (
@@ -564,71 +457,69 @@ class Plex:
user_plex = self.plex
else:
if isinstance(user, str):
logger(
logger.debug(
f"Plex: {user} is not a plex object, attempting to get object for user",
4,
)
user = self.plex.myPlexAccount().user(user)
if not isinstance(user, MyPlexUser):
logger.error(f"Plex: {user} failed to get PlexUser")
continue
token = user.get_token(self.plex.machineIdentifier)
if token:
user_plex = PlexServer(
self.plex._baseurl,
self.base_url,
token,
session=self.session,
)
else:
logger(
logger.error(
f"Plex: Failed to get token for {user.title}, skipping",
2,
)
continue
for library, videos in libraries.items():
if not user_plex:
logger.error(f"Plex: {user} Failed to get PlexServer")
continue
for library_name in user_data.libraries:
library_data = user_data.libraries[library_name]
library_other = None
if library_mapping:
if library in library_mapping.keys():
library_other = library_mapping[library]
elif library in library_mapping.values():
library_other = search_mapping(library_mapping, library)
library_other = search_mapping(library_mapping, library_name)
# if library in plex library list
library_list = user_plex.library.sections()
if library.lower() not in [x.title.lower() for x in library_list]:
if library_name.lower() not in [
x.title.lower() for x in library_list
]:
if library_other:
if library_other.lower() in [
x.title.lower() for x in library_list
]:
logger(
f"Plex: Library {library} not found, but {library_other} found, using {library_other}",
1,
logger.info(
f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}",
)
library = library_other
library_name = library_other
else:
logger(
f"Plex: Library {library} or {library_other} not found in library list",
1,
logger.info(
f"Plex: Library {library_name} or {library_other} not found in library list",
)
continue
else:
logger(
f"Plex: Library {library} not found in library list",
1,
logger.info(
f"Plex: Library {library_name} not found in library list",
)
continue
args.append(
[
update_user_watched,
user,
user_plex,
library,
videos,
dryrun,
]
update_user_watched(
user,
user_plex,
library_data,
library_name,
dryrun,
)
future_thread_executor(args)
except Exception as e:
logger(f"Plex: Failed to update watched, Error: {e}", 2)
logger.error(f"Plex: Failed to update watched, Error: {e}")
raise Exception(e)

View File

@@ -1,30 +1,35 @@
from src.functions import (
logger,
search_mapping,
)
from plexapi.myplex import MyPlexAccount, MyPlexUser
from loguru import logger
from src.emby import Emby
from src.jellyfin import Jellyfin
from src.plex import Plex
from src.functions import search_mapping
def generate_user_list(server):
def generate_user_list(server: Plex | Jellyfin | Emby) -> list[str]:
# generate list of users from server 1 and server 2
server_type = server[0]
server_connection = server[1]
server_users = []
if server_type == "plex":
for user in server_connection.users:
server_users: list[str] = []
if isinstance(server, Plex):
for user in server.users:
server_users.append(
user.username.lower() if user.username else user.title.lower()
)
elif server_type in ["jellyfin", "emby"]:
server_users = [key.lower() for key in server_connection.users.keys()]
elif isinstance(server, (Jellyfin, Emby)):
server_users = [key.lower() for key in server.users.keys()]
return server_users
def combine_user_lists(server_1_users, server_2_users, user_mapping):
def combine_user_lists(
server_1_users: list[str],
server_2_users: list[str],
user_mapping: dict[str, str] | None,
) -> dict[str, str]:
# combined list of overlapping users from plex and jellyfin
users = {}
users: dict[str, str] = {}
for server_1_user in server_1_users:
if user_mapping:
@@ -49,13 +54,15 @@ def combine_user_lists(server_1_users, server_2_users, user_mapping):
return users
def filter_user_lists(users, blacklist_users, whitelist_users):
users_filtered = {}
def filter_user_lists(
users: dict[str, str], blacklist_users: list[str], whitelist_users: list[str]
) -> dict[str, str]:
users_filtered: dict[str, str] = {}
for user in users:
# whitelist_user is not empty and user lowercase is not in whitelist lowercase
if len(whitelist_users) > 0:
if user not in whitelist_users and users[user] not in whitelist_users:
logger(f"{user} or {users[user]} is not in whitelist", 1)
logger.info(f"{user} or {users[user]} is not in whitelist")
continue
if user not in blacklist_users and users[user] not in blacklist_users:
@@ -64,12 +71,13 @@ def filter_user_lists(users, blacklist_users, whitelist_users):
return users_filtered
def generate_server_users(server, users):
server_users = None
if server[0] == "plex":
server_users = []
for plex_user in server[1].users:
def generate_server_users(
server: Plex | Jellyfin | Emby,
users: dict[str, str],
) -> list[MyPlexAccount] | dict[str, str] | None:
if isinstance(server, Plex):
plex_server_users: list[MyPlexAccount] = []
for plex_user in server.users:
username_title = (
plex_user.username if plex_user.username else plex_user.title
)
@@ -78,45 +86,56 @@ def generate_server_users(server, users):
username_title.lower() in users.keys()
or username_title.lower() in users.values()
):
server_users.append(plex_user)
elif server[0] in ["jellyfin", "emby"]:
server_users = {}
for jellyfin_user, jellyfin_id in server[1].users.items():
plex_server_users.append(plex_user)
return plex_server_users
elif isinstance(server, (Jellyfin, Emby)):
jelly_emby_server_users: dict[str, str] = {}
for jellyfin_user, jellyfin_id in server.users.items():
if (
jellyfin_user.lower() in users.keys()
or jellyfin_user.lower() in users.values()
):
server_users[jellyfin_user] = jellyfin_id
jelly_emby_server_users[jellyfin_user] = jellyfin_id
return server_users
return jelly_emby_server_users
return None
def setup_users(
server_1, server_2, blacklist_users, whitelist_users, user_mapping=None
):
server_1: Plex | Jellyfin | Emby,
server_2: Plex | Jellyfin | Emby,
blacklist_users: list[str],
whitelist_users: list[str],
user_mapping: dict[str, str] | None = None,
) -> tuple[
list[MyPlexAccount | MyPlexUser] | dict[str, str],
list[MyPlexAccount | MyPlexUser] | dict[str, str],
]:
server_1_users = generate_user_list(server_1)
server_2_users = generate_user_list(server_2)
logger(f"Server 1 users: {server_1_users}", 1)
logger(f"Server 2 users: {server_2_users}", 1)
logger.debug(f"Server 1 users: {server_1_users}")
logger.debug(f"Server 2 users: {server_2_users}")
users = combine_user_lists(server_1_users, server_2_users, user_mapping)
logger(f"User list that exist on both servers {users}", 1)
logger.debug(f"User list that exist on both servers {users}")
users_filtered = filter_user_lists(users, blacklist_users, whitelist_users)
logger(f"Filtered user list {users_filtered}", 1)
logger.debug(f"Filtered user list {users_filtered}")
output_server_1_users = generate_server_users(server_1, users_filtered)
output_server_2_users = generate_server_users(server_2, users_filtered)
# Check if users is none or empty
if output_server_1_users is None or len(output_server_1_users) == 0:
logger(
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}"
logger.warning(
f"No users found for server 1 {type(server_1)}, users: {server_1_users}, overlapping users {users}, filtered users {users_filtered}, server 1 users {server_1.users}"
)
if output_server_2_users is None or len(output_server_2_users) == 0:
logger(
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}"
logger.warning(
f"No users found for server 2 {type(server_2)}, users: {server_2_users}, overlapping users {users} filtered users {users_filtered}, server 2 users {server_2.users}"
)
if (
@@ -127,7 +146,7 @@ def setup_users(
):
raise Exception("No users found for one or both servers")
logger(f"Server 1 users: {output_server_1_users}", 1)
logger(f"Server 2 users: {output_server_2_users}", 1)
logger.info(f"Server 1 users: {output_server_1_users}")
logger.info(f"Server 2 users: {output_server_2_users}")
return output_server_1_users, output_server_2_users

View File

@@ -1,82 +1,112 @@
import copy
from pydantic import BaseModel, Field
from loguru import logger
from typing import Any
from src.functions import logger, search_mapping, contains_nested
from src.library import generate_library_guids_dict
from src.functions import search_mapping
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")
class MediaIdentifiers(BaseModel):
title: str | None = None
combined_dict = {}
# File information, will be folder for series and media file for episode/movie
locations: tuple[str, ...] = tuple()
for single_dict in dicts:
for key, value in single_dict.items():
if key not in combined_dict:
combined_dict[key] = {}
for subkey, subvalue in value.items():
if subkey in combined_dict[key]:
# If the subkey already exists in the combined dictionary,
# check if the values are different and raise an exception if they are
if combined_dict[key][subkey] != subvalue:
raise ValueError(
f"Conflicting values for subkey '{subkey}' under key '{key}'"
)
else:
# If the subkey does not exist in the combined dictionary, add it
combined_dict[key][subkey] = subvalue
return combined_dict
# Guids
imdb_id: str | None = None
tvdb_id: str | None = None
tmdb_id: str | None = None
def check_remove_entry(video, library, video_index, library_watched_list_2):
if video_index is not None:
if (
library_watched_list_2["completed"][video_index]
== video["status"]["completed"]
) and (library_watched_list_2["time"][video_index] == video["status"]["time"]):
logger(
f"Removing {video['title']} from {library} due to exact match",
3,
)
return True
elif (
library_watched_list_2["completed"][video_index] == True
and video["status"]["completed"] == False
):
logger(
f"Removing {video['title']} from {library} due to being complete in one library and not the other",
3,
)
return True
elif (
library_watched_list_2["completed"][video_index] == False
and video["status"]["completed"] == False
) and (video["status"]["time"] < library_watched_list_2["time"][video_index]):
logger(
f"Removing {video['title']} from {library} due to more time watched in one library than the other",
3,
)
return True
elif (
library_watched_list_2["completed"][video_index] == True
and video["status"]["completed"] == True
):
logger(
f"Removing {video['title']} from {library} due to being complete in both libraries",
3,
)
class WatchedStatus(BaseModel):
completed: bool
time: int
class MediaItem(BaseModel):
identifiers: MediaIdentifiers
status: WatchedStatus
class Series(BaseModel):
identifiers: MediaIdentifiers
episodes: list[MediaItem] = Field(default_factory=list)
class LibraryData(BaseModel):
title: str
movies: list[MediaItem] = Field(default_factory=list)
series: list[Series] = Field(default_factory=list)
class UserData(BaseModel):
libraries: dict[str, LibraryData] = Field(default_factory=dict)
def check_same_identifiers(item1: MediaIdentifiers, item2: MediaIdentifiers) -> bool:
# Check for duplicate based on file locations:
if item1.locations and item2.locations:
if set(item1.locations) & set(item2.locations):
return True
# Check for duplicate based on GUIDs:
if (
(item1.imdb_id and item2.imdb_id and item1.imdb_id == item2.imdb_id)
or (item1.tvdb_id and item2.tvdb_id and item1.tvdb_id == item2.tvdb_id)
or (item1.tmdb_id and item2.tmdb_id and item1.tmdb_id == item2.tmdb_id)
):
return True
return False
def check_remove_entry(item1: MediaItem, item2: MediaItem) -> bool:
"""
Returns True if item1 (from watched_list_1) should be removed
in favor of item2 (from watched_list_2), based on:
- Duplicate criteria:
* They match if any file location is shared OR
at least one of imdb_id, tvdb_id, or tmdb_id matches.
- Watched status:
* If one is complete and the other is not, remove the incomplete one.
* If both are incomplete, remove the one with lower progress (time).
* If both are complete, remove item1 as duplicate.
"""
if not check_same_identifiers(item1.identifiers, item2.identifiers):
return False
# Compare watched statuses.
status1 = item1.status
status2 = item2.status
# If one is complete and the other isn't, remove the one that's not complete.
if status1.completed != status2.completed:
if not status1.completed and status2.completed:
return True # Remove item1 since it's not complete.
else:
return False # Do not remove item1; it's complete.
# Both have the same completed status.
if not status1.completed and not status2.completed:
# Both incomplete: remove the one with lower progress (time)
if status1.time < status2.time:
return True # Remove item1 because it has watched less.
elif status1.time > status2.time:
return False # Keep item1 because it has more progress.
else:
# Same progress; Remove duplicate
return True
# If both are complete, consider item1 the duplicate and remove it.
return True
def cleanup_watched(
watched_list_1, watched_list_2, user_mapping=None, library_mapping=None
):
watched_list_1: dict[str, UserData],
watched_list_2: dict[str, UserData],
user_mapping: dict[str, str] | None = None,
library_mapping: dict[str, str] | None = None,
) -> dict[str, UserData]:
modified_watched_list_1 = copy.deepcopy(watched_list_1)
# remove entries from watched_list_1 that are in watched_list_2
@@ -88,195 +118,99 @@ def cleanup_watched(
if user_2 is None:
continue
for library_1 in watched_list_1[user_1]:
for library_1_key in watched_list_1[user_1].libraries:
library_other = None
if library_mapping:
library_other = search_mapping(library_mapping, library_1)
library_2 = get_other(watched_list_2[user_2], library_1, library_other)
if library_2 is None:
library_other = search_mapping(library_mapping, library_1_key)
library_2_key = get_other(
watched_list_2[user_2].libraries, library_1_key, library_other
)
if library_2_key is None:
continue
(
_,
episode_watched_list_2_keys_dict,
movies_watched_list_2_keys_dict,
) = generate_library_guids_dict(watched_list_2[user_2][library_2])
library_1 = watched_list_1[user_1].libraries[library_1_key]
library_2 = watched_list_2[user_2].libraries[library_2_key]
# Movies
if isinstance(watched_list_1[user_1][library_1], list):
for movie in watched_list_1[user_1][library_1]:
movie_index = get_movie_index_in_dict(
movie, movies_watched_list_2_keys_dict
)
if movie_index is not None:
if check_remove_entry(
movie,
library_1,
movie_index,
movies_watched_list_2_keys_dict,
):
modified_watched_list_1[user_1][library_1].remove(movie)
filtered_movies = []
for movie in library_1.movies:
remove_flag = False
for movie2 in library_2.movies:
if check_remove_entry(movie, movie2):
logger.trace(f"Removing movie: {movie.identifiers.title}")
remove_flag = True
break
if not remove_flag:
filtered_movies.append(movie)
modified_watched_list_1[user_1].libraries[
library_1_key
].movies = filtered_movies
# TV Shows
elif isinstance(watched_list_1[user_1][library_1], dict):
for show_key_1 in watched_list_1[user_1][library_1].keys():
show_key_dict = dict(show_key_1)
filtered_series_list = []
for series1 in library_1.series:
matching_series = None
for series2 in library_2.series:
if check_same_identifiers(series1.identifiers, series2.identifiers):
matching_series = series2
break
# Filter the episode_watched_list_2_keys_dict dictionary to handle cases
# where episode location names are not unique such as S01E01.mkv
filtered_episode_watched_list_2_keys_dict = (
filter_episode_watched_list_2_keys_dict(
episode_watched_list_2_keys_dict, show_key_dict
if matching_series is None:
# No matching show in watched_list_2; keep the series as is.
filtered_series_list.append(series1)
else:
# We have a matching show; now clean up the episodes.
filtered_episodes = []
for ep1 in series1.episodes:
remove_flag = False
for ep2 in matching_series.episodes:
if check_remove_entry(ep1, ep2):
logger.trace(
f"Removing episode '{ep1.identifiers.title}' from show '{series1.identifiers.title}'",
)
remove_flag = True
break
if not remove_flag:
filtered_episodes.append(ep1)
# Only keep the series if there are remaining episodes.
if filtered_episodes:
modified_series1 = copy.deepcopy(series1)
modified_series1.episodes = filtered_episodes
filtered_series_list.append(modified_series1)
else:
logger.trace(
f"Removing entire show '{series1.identifiers.title}' as no episodes remain after cleanup.",
)
)
for episode in watched_list_1[user_1][library_1][show_key_1]:
episode_index = get_episode_index_in_dict(
episode, filtered_episode_watched_list_2_keys_dict
)
if episode_index is not None:
if check_remove_entry(
episode,
library_1,
episode_index,
episode_watched_list_2_keys_dict,
):
modified_watched_list_1[user_1][library_1][
show_key_1
].remove(episode)
modified_watched_list_1[user_1].libraries[
library_1_key
].series = filtered_series_list
# Remove empty shows
if len(modified_watched_list_1[user_1][library_1][show_key_1]) == 0:
if show_key_1 in modified_watched_list_1[user_1][library_1]:
logger(
f"Removing {show_key_dict['title']} because it is empty",
3,
)
del modified_watched_list_1[user_1][library_1][show_key_1]
for user_1 in watched_list_1:
for library_1 in watched_list_1[user_1]:
if library_1 in modified_watched_list_1[user_1]:
# If library is empty then remove it
if len(modified_watched_list_1[user_1][library_1]) == 0:
logger(f"Removing {library_1} from {user_1} because it is empty", 1)
del modified_watched_list_1[user_1][library_1]
if user_1 in modified_watched_list_1:
# If user is empty delete user
if len(modified_watched_list_1[user_1]) == 0:
logger(f"Removing {user_1} from watched list 1 because it is empty", 1)
del modified_watched_list_1[user_1]
# After processing, remove any library that is completely empty.
for user, user_data in modified_watched_list_1.items():
new_libraries = {}
for lib_key, library in user_data.libraries.items():
if library.movies or library.series:
new_libraries[lib_key] = library
else:
logger.trace(f"Removing empty library '{lib_key}' for user '{user}'")
user_data.libraries = new_libraries
return modified_watched_list_1
def get_other(watched_list, object_1, object_2):
def get_other(
watched_list: dict[str, Any], object_1: str, object_2: str | None
) -> str | None:
if object_1 in watched_list:
return object_1
elif object_2 in watched_list:
if object_2 and object_2 in watched_list:
return object_2
else:
logger(f"{object_1} and {object_2} not found in watched list 2", 1)
return None
logger.info(
f"{object_1}{' and ' + object_2 if object_2 else ''} not found in watched list 2"
)
def get_movie_index_in_dict(movie, movies_watched_list_2_keys_dict):
# Iterate through the keys and values of the movie dictionary
for movie_key, movie_value in movie.items():
# If the key is "locations", check if the "locations" key is present in the movies_watched_list_2_keys_dict dictionary
if movie_key == "locations":
if "locations" in movies_watched_list_2_keys_dict.keys():
# Iterate through the locations in the movie dictionary
for location in movie_value:
# If the location is in the movies_watched_list_2_keys_dict dictionary, return index of the key
return contains_nested(
location, movies_watched_list_2_keys_dict["locations"]
)
# If the key is not "locations", check if the movie_key is present in the movies_watched_list_2_keys_dict dictionary
else:
if movie_key in movies_watched_list_2_keys_dict.keys():
# If the movie_value is in the movies_watched_list_2_keys_dict dictionary, return True
if movie_value in movies_watched_list_2_keys_dict[movie_key]:
return movies_watched_list_2_keys_dict[movie_key].index(movie_value)
# If the loop completes without finding a match, return False
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):
# Iterate through the keys and values of the episode dictionary
for episode_key, episode_value in episode.items():
if episode_key in episode_watched_list_2_keys_dict.keys():
if episode_key == "locations":
# Iterate through the locations in the episode dictionary
for location in episode_value:
# If the location is in the episode_watched_list_2_keys_dict dictionary, return index of the key
return contains_nested(
location, episode_watched_list_2_keys_dict["locations"]
)
else:
# If the episode_value is in the episode_watched_list_2_keys_dict dictionary, return True
if episode_value in episode_watched_list_2_keys_dict[episode_key]:
return episode_watched_list_2_keys_dict[episode_key].index(
episode_value
)
# If the loop completes without finding a match, return False
return None

View File

@@ -3,11 +3,8 @@
## 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"
DEBUG_LEVEL = "trace"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "True"
@@ -66,7 +63,7 @@ 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"
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options

View File

@@ -3,11 +3,8 @@
## 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"
DEBUG_LEVEL = "trace"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "True"
@@ -66,7 +63,7 @@ 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"
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options

View File

@@ -3,11 +3,8 @@
## 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"
DEBUG_LEVEL = "trace"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "True"
@@ -66,7 +63,7 @@ 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"
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options

View File

@@ -3,11 +3,8 @@
## 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"
DEBUG_LEVEL = "trace"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "True"
@@ -66,7 +63,7 @@ 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"
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options

View File

@@ -3,11 +3,8 @@
## 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"
DEBUG_LEVEL = "trace"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "True"
@@ -66,7 +63,7 @@ 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"
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options

View File

@@ -3,11 +3,8 @@
## 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"
DEBUG_LEVEL = "trace"
## If set to true then the script will only run once and then exit
RUN_ONLY_ONCE = "True"
@@ -66,7 +63,7 @@ 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"
PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c"
## If not using plex token then use username and password of the server admin along with the servername
## Comma seperated for multiple options

View File

@@ -18,12 +18,12 @@ from src.black_white import setup_black_white_lists
def test_setup_black_white_lists():
# Simple
blacklist_library = "library1, library2"
whitelist_library = "library1, library2"
blacklist_library_type = "library_type1, library_type2"
whitelist_library_type = "library_type1, library_type2"
blacklist_users = "user1, user2"
whitelist_users = "user1, user2"
blacklist_library = ["library1", "library2"]
whitelist_library = ["library1", "library2"]
blacklist_library_type = ["library_type1", "library_type2"]
whitelist_library_type = ["library_type1", "library_type2"]
blacklist_users = ["user1", "user2"]
whitelist_users = ["user1", "user2"]
(
results_blacklist_library,
@@ -48,6 +48,15 @@ def test_setup_black_white_lists():
assert return_blacklist_users == ["user1", "user2"]
assert return_whitelist_users == ["user1", "user2"]
def test_library_mapping_black_white_list():
blacklist_library = ["library1", "library2"]
whitelist_library = ["library1", "library2"]
blacklist_library_type = ["library_type1", "library_type2"]
whitelist_library_type = ["library_type1", "library_type2"]
blacklist_users = ["user1", "user2"]
whitelist_users = ["user1", "user2"]
# Library Mapping and user mapping
library_mapping = {"library1": "library3"}
user_mapping = {"user1": "user3"}

View File

@@ -21,10 +21,6 @@ from src.library import (
check_skip_logic,
check_blacklist_logic,
check_whitelist_logic,
show_title_dict,
episode_title_dict,
movies_title_dict,
generate_library_guids_dict,
)
blacklist_library = ["TV Shows"]
@@ -280,45 +276,3 @@ def test_check_whitelist_logic():
)
assert skip_reason is None
def test_show_title_dict():
show_titles_dict = show_title_dict(show_list)
assert show_titles_dict == show_titles
def test_episode_title_dict():
episode_titles_dict = episode_title_dict(show_list)
assert episode_titles_dict == episode_titles
def test_movies_title_dict():
movies_titles_dict = movies_title_dict(movie_list)
assert movies_titles_dict == movie_titles
def test_generate_library_guids_dict():
# Test with shows
(
show_titles_dict,
episode_titles_dict,
movies_titles_dict,
) = generate_library_guids_dict(show_list)
assert show_titles_dict == show_titles
assert episode_titles_dict == episode_titles
assert movies_titles_dict == {}
# Test with movies
(
show_titles_dict,
episode_titles_dict,
movies_titles_dict,
) = generate_library_guids_dict(movie_list)
assert show_titles_dict == {}
assert episode_titles_dict == {}
assert movies_titles_dict == movie_titles

View File

@@ -1,78 +0,0 @@
import sys
import os
# getting the name of the directory
# where the this file is present.
current = os.path.dirname(os.path.realpath(__file__))
# Getting the parent directory name
# where the current directory is present.
parent = os.path.dirname(current)
# adding the parent directory to
# the sys.path.
sys.path.append(parent)
from src.black_white import setup_black_white_lists
def test_setup_black_white_lists():
# Simple
blacklist_library = "library1, library2"
whitelist_library = "library1, library2"
blacklist_library_type = "library_type1, library_type2"
whitelist_library_type = "library_type1, library_type2"
blacklist_users = "user1, user2"
whitelist_users = "user1, user2"
(
results_blacklist_library,
return_whitelist_library,
return_blacklist_library_type,
return_whitelist_library_type,
return_blacklist_users,
return_whitelist_users,
) = setup_black_white_lists(
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
blacklist_users,
whitelist_users,
)
assert results_blacklist_library == ["library1", "library2"]
assert return_whitelist_library == ["library1", "library2"]
assert return_blacklist_library_type == ["library_type1", "library_type2"]
assert return_whitelist_library_type == ["library_type1", "library_type2"]
assert return_blacklist_users == ["user1", "user2"]
assert return_whitelist_users == ["user1", "user2"]
# Library Mapping and user mapping
library_mapping = {"library1": "library3"}
user_mapping = {"user1": "user3"}
(
results_blacklist_library,
return_whitelist_library,
return_blacklist_library_type,
return_whitelist_library_type,
return_blacklist_users,
return_whitelist_users,
) = setup_black_white_lists(
blacklist_library,
whitelist_library,
blacklist_library_type,
whitelist_library_type,
blacklist_users,
whitelist_users,
library_mapping,
user_mapping,
)
assert results_blacklist_library == ["library1", "library2", "library3"]
assert return_whitelist_library == ["library1", "library2", "library3"]
assert return_blacklist_library_type == ["library_type1", "library_type2"]
assert return_whitelist_library_type == ["library_type1", "library_type2"]
assert return_blacklist_users == ["user1", "user2", "user3"]
assert return_whitelist_users == ["user1", "user2", "user3"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,37 @@
# Check the mark.log file that is generated by the CI to make sure it contains the expected values
import argparse
import os
import sys
from loguru import logger
from collections import Counter
import os, argparse
class MarkLogError(Exception):
"""Custom exception for mark.log validation failures."""
pass
def parse_args():
parser = argparse.ArgumentParser(
description="Check the mark.log file that is generated by the CI to make sure it contains the expected values"
)
parser.add_argument(
"--dry", action="store_true", help="Check the mark.log file for dry-run"
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--guids", action="store_true", help="Check the mark.log file for guids"
)
parser.add_argument(
group.add_argument(
"--locations", action="store_true", help="Check the mark.log file for locations"
)
group.add_argument(
"--write", action="store_true", help="Check the mark.log file for write-run"
)
parser.add_argument(
group.add_argument(
"--plex", action="store_true", help="Check the mark.log file for Plex"
)
parser.add_argument(
group.add_argument(
"--jellyfin", action="store_true", help="Check the mark.log file for Jellyfin"
)
parser.add_argument(
group.add_argument(
"--emby", action="store_true", help="Check the mark.log file for Emby"
)
@@ -28,146 +40,177 @@ def parse_args():
def read_marklog():
marklog = os.path.join(os.getcwd(), "mark.log")
with open(marklog, "r") as f:
lines = f.readlines()
return lines
try:
with open(marklog, "r") as f:
lines = [line.strip() for line in f if line.strip()]
return lines
except Exception as e:
raise MarkLogError(f"Error reading {marklog}: {e}")
def check_marklog(lines, expected_values):
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_counter = Counter(lines)
expected_counter = Counter(expected_values)
found_values.append(line)
# Determine missing and extra items by comparing counts
missing = expected_counter - found_counter
extra = found_counter - expected_counter
# 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)
)
if missing or extra:
if missing:
logger.error("Missing expected entries (with counts):")
for entry, count in missing.items():
logger.error(f" {entry}: missing {count} time(s)")
if extra:
logger.error("Unexpected extra entries found (with counts):")
for entry, count in extra.items():
logger.error(f" {entry}: found {count} extra time(s)")
# 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))
)
logger.error(
f"Entry count mismatch: found {len(lines)} entries, expected {len(expected_values)} entries."
)
logger.error("Full mark.log content:")
for line in sorted(lines):
logger.error(f" {line}")
raise MarkLogError("mark.log validation failed.")
return True
except Exception as e:
print(e)
return False
return True
def main():
args = parse_args()
# Expected values defined for each check
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",
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Two (2021)",
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 2",
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Five Nights at Freddy's",
"Plex/JellyPlex-CI/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie Two",
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E02",
"Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan",
"Emby/Emby-Server/jellyplex_watched/Movies/Five Nights at Freddy's",
"Emby/Emby-Server/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
]
expected_emby = [
"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",
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Three (2022)",
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 3",
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Tears of Steel",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie Three (2022)",
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E03",
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Tears of Steel",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
]
expected_plex = [
"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",
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Big Buck Bunny",
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Killers of the Flower Moon/4",
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/The Unquiet Dead",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/Aliens of London (1)/4",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie One (2020)",
"Emby/Emby-Server/jellyplex_watched/Movies/Big Buck Bunny",
"Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan",
"Emby/Emby-Server/jellyplex_watched/Movies/Killers of the Flower Moon/4",
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie One",
]
expected_dry = expected_emby + expected_plex + expected_jellyfin
expected_locations = expected_emby + expected_plex + expected_jellyfin
# Remove Custom Movies/TV Shows as they should not have guids
expected_guids = [item for item in expected_locations if "Custom" not in item]
expected_write = [
"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",
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Two (2021)",
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 2",
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Five Nights at Freddy's",
"Plex/JellyPlex-CI/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741",
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Big Buck Bunny",
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Killers of the Flower Moon/4",
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/The Unquiet Dead",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/Aliens of London (1)/4",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie One (2020)",
"Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Three (2022)",
"Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 3",
"Plex/JellyPlex-CI/jellyplex_watched/Movies/Tears of Steel",
"Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429",
"Emby/Emby-Server/jellyplex_watched/Movies/Big Buck Bunny",
"Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan",
"Emby/Emby-Server/jellyplex_watched/Movies/Five Nights at Freddy's",
"Emby/Emby-Server/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5",
"Emby/Emby-Server/jellyplex_watched/Movies/Killers of the Flower Moon/4",
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E01",
"Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E02",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies",
"Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out",
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie One",
"Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie Two",
"Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie Three (2022)",
"Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E03",
"Jellyfin/Jellyfin-Server/JellyUser/Movies/Tears of Steel",
"Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4",
]
# Expected values for the mark.log file, dry-run is slightly different than write-run
# due to some of the items being copied over from one server to another and now being there
# for the next server run.
if args.dry:
expected_values = expected_dry
# Determine which expected values to use based on the command-line flag
if args.guids:
expected_values = expected_guids
check_type = "GUIDs"
elif args.locations:
expected_values = expected_locations
check_type = "locations"
elif args.write:
expected_values = expected_write
check_type = "write-run"
elif args.plex:
expected_values = expected_plex
check_type = "Plex"
elif args.jellyfin:
expected_values = expected_jellyfin
check_type = "Jellyfin"
elif args.emby:
expected_values = expected_emby
check_type = "Emby"
else:
print("No server specified")
exit(1)
raise MarkLogError("No server specified")
lines = read_marklog()
if not check_marklog(lines, expected_values):
print("Failed to validate marklog")
exit(1)
logger.info(f"Validating mark.log for {check_type}...")
print("Successfully validated marklog")
exit(0)
try:
lines = read_marklog()
check_marklog(lines, expected_values)
except MarkLogError as e:
logger.error(e)
sys.exit(1)
logger.success("Successfully validated mark.log")
sys.exit(0)
if __name__ == "__main__":

356
uv.lock generated Normal file
View File

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