├── .dockerignore ├── .env.sample ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── .python-version ├── .vscode ├── launch.json └── settings.json ├── Dockerfile.alpine ├── Dockerfile.slim ├── LICENSE ├── README.md ├── docker-compose.yml ├── entrypoint.sh ├── main.py ├── pyproject.toml ├── src ├── black_white.py ├── connection.py ├── emby.py ├── functions.py ├── jellyfin.py ├── jellyfin_emby.py ├── library.py ├── main.py ├── plex.py ├── users.py └── watched.py ├── test ├── ci_emby.env ├── ci_guids.env ├── ci_jellyfin.env ├── ci_locations.env ├── ci_plex.env ├── ci_write.env ├── requirements.txt ├── test_black_white.py ├── test_library.py ├── test_users.py ├── test_watched.py └── validate_ci_marklog.py └── uv.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .dockerignore 3 | .env 4 | .env.sample 5 | .git 6 | .github 7 | .gitignore 8 | .idea 9 | .vscode 10 | 11 | Dockerfile* 12 | README.md 13 | test 14 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Global Settings 2 | 3 | ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. 4 | DRYRUN = "True" 5 | 6 | ## Additional logging information 7 | DEBUG = "False" 8 | 9 | ## Debugging level, "info" is default, "debug" is more verbose 10 | DEBUG_LEVEL = "info" 11 | 12 | ## If set to true then the script will only run once and then exit 13 | RUN_ONLY_ONCE = "False" 14 | 15 | ## How often to run the script in seconds 16 | SLEEP_DURATION = "3600" 17 | 18 | ## Log file where all output will be written to 19 | LOGFILE = "log.log" 20 | 21 | ## Mark file where all shows/movies that have been marked as played will be written to 22 | MARK_FILE = "mark.log" 23 | 24 | ## Timeout for requests for jellyfin 25 | REQUEST_TIMEOUT = 300 26 | 27 | ## Generate guids 28 | ## Generating guids is a slow process, so this is a way to speed up the process 29 | ## by using the location only, useful when using same files on multiple servers 30 | GENERATE_GUIDS = "True" 31 | 32 | ## Generate locations 33 | ## Generating locations is a slow process, so this is a way to speed up the process 34 | ## by using the guid only, useful when using different files on multiple servers 35 | GENERATE_LOCATIONS = "True" 36 | 37 | ## Max threads for processing 38 | MAX_THREADS = 2 39 | 40 | ## Map usernames between servers in the event that they are different, order does not matter 41 | ## Comma separated for multiple options 42 | #USER_MAPPING = { "testuser2": "testuser3", "testuser1":"testuser4" } 43 | 44 | ## Map libraries between servers in the event that they are different, order does not matter 45 | ## Comma separated for multiple options 46 | #LIBRARY_MAPPING = { "Shows": "TV Shows", "Movie": "Movies" } 47 | 48 | ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. 49 | ## Comma separated for multiple options 50 | #BLACKLIST_LIBRARY = "" 51 | #WHITELIST_LIBRARY = "" 52 | #BLACKLIST_LIBRARY_TYPE = "" 53 | #WHITELIST_LIBRARY_TYPE = "" 54 | #BLACKLIST_USERS = "" 55 | WHITELIST_USERS = "testuser1,testuser2" 56 | 57 | 58 | # Plex 59 | 60 | ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers 61 | ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly 62 | ## Comma separated list for multiple servers 63 | PLEX_BASEURL = "http://localhost:32400, https://nas:32400" 64 | 65 | ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 66 | ## Comma separated list for multiple servers 67 | PLEX_TOKEN = "SuperSecretToken, SuperSecretToken2" 68 | 69 | ## If not using plex token then use username and password of the server admin along with the servername 70 | ## Comma separated for multiple options 71 | #PLEX_USERNAME = "PlexUser, PlexUser2" 72 | #PLEX_PASSWORD = "SuperSecret, SuperSecret2" 73 | #PLEX_SERVERNAME = "Plex Server1, Plex Server2" 74 | 75 | ## Skip hostname validation for ssl certificates. 76 | ## Set to True if running into ssl certificate errors 77 | SSL_BYPASS = "False" 78 | 79 | 80 | # Jellyfin 81 | 82 | ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly 83 | ## Comma separated list for multiple servers 84 | JELLYFIN_BASEURL = "http://localhost:8096, http://nas:8096" 85 | 86 | ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key 87 | ## Comma separated list for multiple servers 88 | JELLYFIN_TOKEN = "SuperSecretToken, SuperSecretToken2" 89 | 90 | 91 | # Emby 92 | 93 | ## Emby server URL, use hostname or IP address if the hostname is not resolving correctly 94 | ## Comma seperated list for multiple servers 95 | EMBY_BASEURL = "http://localhost:8097" 96 | 97 | ## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key 98 | ## Comma seperated list for multiple servers 99 | EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" 100 | 101 | 102 | # Syncing Options 103 | 104 | ## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex 105 | ## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers 106 | SYNC_FROM_PLEX_TO_JELLYFIN = "True" 107 | SYNC_FROM_PLEX_TO_PLEX = "True" 108 | SYNC_FROM_PLEX_TO_EMBY = "True" 109 | 110 | SYNC_FROM_JELLYFIN_TO_PLEX = "True" 111 | SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" 112 | SYNC_FROM_JELLYFIN_TO_EMBY = "True" 113 | 114 | SYNC_FROM_EMBY_TO_PLEX = "True" 115 | SYNC_FROM_EMBY_TO_JELLYFIN = "True" 116 | SYNC_FROM_EMBY_TO_EMBY = "True" -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Luigi311] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Logs** 24 | If applicable, add logs to help explain your problem ideally with DEBUG set to true, be sure to remove sensitive information 25 | 26 | **Type:** 27 | - [ ] Docker Compose 28 | - [ ] Docker 29 | - [ ] Unraid 30 | - [ ] Native 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | workflow_dispatch: 4 | push: 5 | paths-ignore: 6 | - .gitignore 7 | - "*.md" 8 | pull_request: 9 | paths-ignore: 10 | - .gitignore 11 | - "*.md" 12 | 13 | env: 14 | PYTHON_VERSION: '3.13' 15 | 16 | jobs: 17 | pytest: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v5 24 | 25 | - name: "Set up Python" 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version-file: ".python-version" 29 | 30 | - name: "Install dependencies" 31 | run: uv sync --all-extras --dev 32 | 33 | - name: "Run tests" 34 | run: uv run pytest -vvv 35 | 36 | test: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: Install uv 42 | uses: astral-sh/setup-uv@v5 43 | 44 | - name: "Set up Python" 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version-file: ".python-version" 48 | 49 | - name: "Install dependencies" 50 | run: | 51 | uv sync --all-extras --dev 52 | sudo apt update && sudo apt install -y docker-compose 53 | 54 | - name: "Checkout JellyPlex-Watched-CI" 55 | uses: actions/checkout@v4 56 | with: 57 | repository: luigi311/JellyPlex-Watched-CI 58 | path: JellyPlex-Watched-CI 59 | 60 | - name: "Start containers" 61 | run: | 62 | JellyPlex-Watched-CI/start_containers.sh 63 | 64 | # Wait for containers to start 65 | sleep 10 66 | 67 | for FOLDER in $(find "JellyPlex-Watched-CI" -type f -name "docker-compose.yml" -exec dirname {} \;); do 68 | docker compose -f "${FOLDER}/docker-compose.yml" logs 69 | done 70 | 71 | - name: "Test Plex" 72 | run: | 73 | mv test/ci_plex.env .env 74 | uv run main.py 75 | uv run test/validate_ci_marklog.py --plex 76 | 77 | rm mark.log 78 | 79 | - name: "Test Jellyfin" 80 | run: | 81 | mv test/ci_jellyfin.env .env 82 | uv run main.py 83 | uv run test/validate_ci_marklog.py --jellyfin 84 | 85 | rm mark.log 86 | 87 | - name: "Test Emby" 88 | run: | 89 | mv test/ci_emby.env .env 90 | uv run main.py 91 | uv run test/validate_ci_marklog.py --emby 92 | 93 | rm mark.log 94 | 95 | - name: "Test Guids" 96 | run: | 97 | mv test/ci_guids.env .env 98 | uv run main.py 99 | uv run test/validate_ci_marklog.py --guids 100 | 101 | rm mark.log 102 | 103 | - name: "Test Locations" 104 | run: | 105 | mv test/ci_locations.env .env 106 | uv run main.py 107 | uv run test/validate_ci_marklog.py --locations 108 | 109 | rm mark.log 110 | 111 | - name: "Test writing to the servers" 112 | run: | 113 | # Test writing to the servers 114 | mv test/ci_write.env .env 115 | uv run main.py 116 | 117 | # Test again to test if it can handle existing data 118 | uv run main.py 119 | 120 | uv run test/validate_ci_marklog.py --write 121 | 122 | rm mark.log 123 | 124 | docker: 125 | runs-on: ubuntu-latest 126 | needs: 127 | - pytest 128 | - test 129 | env: 130 | DEFAULT_VARIANT: alpine 131 | strategy: 132 | fail-fast: false 133 | matrix: 134 | include: 135 | - dockerfile: Dockerfile.alpine 136 | variant: alpine 137 | - dockerfile: Dockerfile.slim 138 | variant: slim 139 | steps: 140 | - name: Checkout 141 | uses: actions/checkout@v4 142 | 143 | - name: Docker meta 144 | id: docker_meta 145 | uses: docker/metadata-action@v5 146 | with: 147 | images: | 148 | ${{ secrets.DOCKER_USERNAME }}/jellyplex-watched,enable=${{ secrets.DOCKER_USERNAME != '' }} 149 | # Do not push to ghcr.io on PRs due to permission issues, only push if the owner is luigi311 so it doesnt fail on forks 150 | ghcr.io/${{ github.repository }},enable=${{ github.event_name != 'pull_request' && github.repository_owner == 'luigi311'}} 151 | flavor: latest=false 152 | tags: | 153 | type=raw,value=latest,enable=${{ matrix.variant == env.DEFAULT_VARIANT && startsWith(github.ref, 'refs/tags/') }} 154 | type=raw,value=latest,suffix=-${{ matrix.variant }},enable=${{ startsWith(github.ref, 'refs/tags/') }} 155 | 156 | type=ref,event=branch,suffix=-${{ matrix.variant }} 157 | type=ref,event=branch,enable=${{ matrix.variant == env.DEFAULT_VARIANT }} 158 | 159 | type=ref,event=pr,suffix=-${{ matrix.variant }} 160 | type=ref,event=pr,enable=${{ matrix.variant == env.DEFAULT_VARIANT }} 161 | 162 | type=semver,pattern={{ version }},suffix=-${{ matrix.variant }} 163 | type=semver,pattern={{ version }},enable=${{ matrix.variant == env.DEFAULT_VARIANT }} 164 | 165 | type=semver,pattern={{ major }}.{{ minor }},suffix=-${{ matrix.variant }} 166 | type=semver,pattern={{ major }}.{{ minor }},enable=${{ matrix.variant == env.DEFAULT_VARIANT }} 167 | 168 | type=sha,suffix=-${{ matrix.variant }} 169 | type=sha,enable=${{ matrix.variant == env.DEFAULT_VARIANT }} 170 | 171 | - name: Set up QEMU 172 | uses: docker/setup-qemu-action@v3 173 | 174 | - name: Set up Docker Buildx 175 | uses: docker/setup-buildx-action@v3 176 | 177 | - name: Login to DockerHub 178 | env: 179 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 180 | if: "${{ env.DOCKER_USERNAME != '' }}" 181 | uses: docker/login-action@v3 182 | with: 183 | username: ${{ secrets.DOCKER_USERNAME }} 184 | password: ${{ secrets.DOCKER_TOKEN }} 185 | 186 | - name: Login to GitHub Container Registry 187 | if: "${{ steps.docker_meta.outcome == 'success' }}" 188 | uses: docker/login-action@v3 189 | with: 190 | registry: ghcr.io 191 | username: ${{ github.actor }} 192 | password: ${{ secrets.GITHUB_TOKEN }} 193 | 194 | - name: Build 195 | id: build 196 | if: "${{ steps.docker_meta.outputs.tags == '' }}" 197 | uses: docker/build-push-action@v5 198 | with: 199 | context: . 200 | file: ${{ matrix.dockerfile }} 201 | platforms: linux/amd64,linux/arm64 202 | push: false 203 | tags: jellyplex-watched:action 204 | 205 | - name: Build Push 206 | id: build_push 207 | if: "${{ steps.docker_meta.outputs.tags != '' }}" 208 | uses: docker/build-push-action@v5 209 | with: 210 | context: . 211 | file: ${{ matrix.dockerfile }} 212 | platforms: linux/amd64,linux/arm64 213 | push: true 214 | tags: ${{ steps.docker_meta.outputs.tags }} 215 | labels: ${{ steps.docker_meta.outputs.labels }} 216 | 217 | # Echo digest so users can validate their image 218 | - name: Image digest 219 | if: "${{ steps.docker_meta.outcome == 'success' }}" 220 | run: echo "${{ steps.build_push.outputs.digest }}" 221 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: "23 20 * * 6" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.env* 2 | *.prof 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Main", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "main.py", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | }, 15 | { 16 | "name": "Pytest", 17 | "type": "debugpy", 18 | "request": "launch", 19 | "module": "pytest", 20 | "args": [ 21 | "-vv" 22 | ], 23 | "console": "integratedTerminal", 24 | "justMyCode": true 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]" : { 3 | "editor.formatOnSave": true, 4 | }, 5 | "python.formatting.provider": "black", 6 | 7 | } -------------------------------------------------------------------------------- /Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.13-alpine 2 | 3 | ENV PUID=1000 4 | ENV PGID=1000 5 | ENV GOSU_VERSION=1.17 6 | 7 | RUN apk add --no-cache tini dos2unix 8 | 9 | # Install gosu 10 | RUN set -eux; \ 11 | \ 12 | apk add --no-cache --virtual .gosu-deps \ 13 | ca-certificates \ 14 | dpkg \ 15 | gnupg \ 16 | ; \ 17 | \ 18 | dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ 19 | wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ 20 | wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ 21 | \ 22 | # verify the signature 23 | export GNUPGHOME="$(mktemp -d)"; \ 24 | gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ 25 | gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ 26 | gpgconf --kill all; \ 27 | rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \ 28 | \ 29 | # clean up fetch dependencies 30 | apk del --no-network .gosu-deps; \ 31 | \ 32 | chmod +x /usr/local/bin/gosu; \ 33 | # verify that the binary works 34 | gosu --version; \ 35 | gosu nobody true 36 | 37 | WORKDIR /app 38 | 39 | # Enable bytecode compilation 40 | ENV UV_COMPILE_BYTECODE=1 41 | 42 | ENV UV_LINK_MODE=copy 43 | 44 | # Set the cache directory to /tmp instead of root 45 | ENV UV_CACHE_DIR=/tmp/.cache/uv 46 | 47 | # Install the project's dependencies using the lockfile and settings 48 | RUN --mount=type=cache,target=/tmp/.cache/uv \ 49 | --mount=type=bind,source=uv.lock,target=uv.lock \ 50 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 51 | uv sync --frozen --no-install-project --no-dev 52 | 53 | # Then, add the rest of the project source code and install it 54 | # Installing separately from its dependencies allows optimal layer caching 55 | COPY . /app 56 | RUN --mount=type=cache,target=/tmp/.cache/uv \ 57 | uv sync --frozen --no-dev 58 | 59 | # Place executables in the environment at the front of the path 60 | ENV PATH="/app/.venv/bin:$PATH" 61 | 62 | COPY . . 63 | 64 | RUN chmod +x *.sh && \ 65 | dos2unix *.sh 66 | 67 | # Set default values to prevent issues 68 | ENV DRYRUN="True" 69 | ENV DEBUG_LEVEL="INFO" 70 | ENV RUN_ONLY_ONCE="False" 71 | ENV SLEEP_DURATION=3600 72 | ENV LOG_FILE="log.log" 73 | ENV MARK_FILE="mark.log" 74 | ENV REQUEST_TIME=300 75 | ENV GENERATE_GUIDS="True" 76 | ENV GENERATE_LOCATIONS="True" 77 | ENV MAX_THREADS=1 78 | ENV USER_MAPPING="" 79 | ENV LIBRARY_MAPPING="" 80 | ENV BLACKLIST_LIBRARY="" 81 | ENV WHITELIST_LIBRARY="" 82 | ENV BLACKLIST_LIBRARY_TYPE="" 83 | ENV WHITELIST_LIBRARY_TYPE="" 84 | ENV BLACKLIST_USERS="" 85 | ENV WHITELIST_USERS="" 86 | ENV PLEX_BASEURL="" 87 | ENV PLEX_TOKEN="" 88 | ENV PLEX_USERNAME="" 89 | ENV PLEX_PASSWORD="" 90 | ENV PLEX_SERVERNAME="" 91 | ENV SSL_BYPASS="False" 92 | ENV JELLYFIN_BASEURL="" 93 | ENV JELLYFIN_TOKEN="" 94 | ENV EMBY_BASEURL="" 95 | ENV EMBY_TOKEN="" 96 | ENV SYNC_FROM_PLEX_TO_JELLYFIN="True" 97 | ENV SYNC_FROM_PLEX_TO_PLEX="True" 98 | ENV SYNC_FROM_PLEX_TO_EMBY="True" 99 | ENV SYNC_FROM_JELLYFIN_TO_PLEX="True" 100 | ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN="True" 101 | ENV SYNC_FROM_JELLYFIN_TO_EMBY="True" 102 | ENV SYNC_FROM_EMBY_TO_PLEX="True" 103 | ENV SYNC_FROM_EMBY_TO_JELLYFIN="True" 104 | ENV SYNC_FROM_EMBY_TO_EMBY="True" 105 | 106 | ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"] 107 | CMD ["python", "-u", "main.py"] 108 | -------------------------------------------------------------------------------- /Dockerfile.slim: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:bookworm-slim 2 | 3 | ENV PUID=1000 4 | ENV PGID=1000 5 | 6 | RUN apt-get update && \ 7 | apt-get install tini gosu dos2unix --yes --no-install-recommends && \ 8 | apt-get clean && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | WORKDIR /app 12 | 13 | # Enable bytecode compilation 14 | ENV UV_COMPILE_BYTECODE=1 15 | 16 | ENV UV_LINK_MODE=copy 17 | 18 | # Set the cache directory to /tmp instead of root 19 | ENV UV_CACHE_DIR=/tmp/.cache/uv 20 | 21 | ENV UV_PYTHON_INSTALL_DIR=/app/.bin 22 | 23 | # Install the project's dependencies using the lockfile and settings 24 | RUN --mount=type=cache,target=/tmp/.cache/uv \ 25 | --mount=type=bind,source=uv.lock,target=uv.lock \ 26 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 27 | uv sync --frozen --no-install-project --no-dev 28 | 29 | # Then, add the rest of the project source code and install it 30 | # Installing separately from its dependencies allows optimal layer caching 31 | COPY . /app 32 | RUN --mount=type=cache,target=/tmp/.cache/uv \ 33 | uv sync --frozen --no-dev 34 | 35 | # Place executables in the environment at the front of the path 36 | ENV PATH="/app/.venv/bin:$PATH" 37 | 38 | RUN chmod +x *.sh && \ 39 | dos2unix *.sh 40 | 41 | # Set default values to prevent issues 42 | ENV DRYRUN="True" 43 | ENV DEBUG_LEVEL="INFO" 44 | ENV RUN_ONLY_ONCE="False" 45 | ENV SLEEP_DURATION=3600 46 | ENV LOG_FILE="log.log" 47 | ENV MARK_FILE="mark.log" 48 | ENV REQUEST_TIME=300 49 | ENV GENERATE_GUIDS="True" 50 | ENV GENERATE_LOCATIONS="True" 51 | ENV MAX_THREADS=1 52 | ENV USER_MAPPING="" 53 | ENV LIBRARY_MAPPING="" 54 | ENV BLACKLIST_LIBRARY="" 55 | ENV WHITELIST_LIBRARY="" 56 | ENV BLACKLIST_LIBRARY_TYPE="" 57 | ENV WHITELIST_LIBRARY_TYPE="" 58 | ENV BLACKLIST_USERS="" 59 | ENV WHITELIST_USERS="" 60 | ENV PLEX_BASEURL="" 61 | ENV PLEX_TOKEN="" 62 | ENV PLEX_USERNAME="" 63 | ENV PLEX_PASSWORD="" 64 | ENV PLEX_SERVERNAME="" 65 | ENV SSL_BYPASS="False" 66 | ENV JELLYFIN_BASEURL="" 67 | ENV JELLYFIN_TOKEN="" 68 | ENV EMBY_BASEURL="" 69 | ENV EMBY_TOKEN="" 70 | ENV SYNC_FROM_PLEX_TO_JELLYFIN="True" 71 | ENV SYNC_FROM_PLEX_TO_PLEX="True" 72 | ENV SYNC_FROM_PLEX_TO_EMBY="True" 73 | ENV SYNC_FROM_JELLYFIN_TO_PLEX="True" 74 | ENV SYNC_FROM_JELLYFIN_TO_JELLYFIN="True" 75 | ENV SYNC_FROM_JELLYFIN_TO_EMBY="True" 76 | ENV SYNC_FROM_EMBY_TO_PLEX="True" 77 | ENV SYNC_FROM_EMBY_TO_JELLYFIN="True" 78 | ENV SYNC_FROM_EMBY_TO_EMBY="True" 79 | 80 | ENTRYPOINT ["/bin/tini", "--", "/app/entrypoint.sh"] 81 | CMD ["python", "-u", "main.py"] 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JellyPlex-Watched 2 | 3 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/26b47c5db63942f28f02f207f692dc85)](https://www.codacy.com/gh/luigi311/JellyPlex-Watched/dashboard?utm_source=github.com&utm_medium=referral&utm_content=luigi311/JellyPlex-Watched&utm_campaign=Badge_Grade) 4 | 5 | Sync watched between jellyfin, plex and emby locally 6 | 7 | ## Description 8 | 9 | Keep in sync all your users watched history between jellyfin, plex and emby servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the .env plex/jellyfin section separated by commas. 10 | 11 | ## Features 12 | 13 | ### Plex 14 | 15 | - \[x] Match via filenames 16 | - \[x] Match via provider ids 17 | - \[x] Map usernames 18 | - \[x] Use single login 19 | - \[x] One way/multi way sync 20 | - \[x] Sync watched 21 | - \[x] Sync in progress 22 | 23 | ### Jellyfin 24 | 25 | - \[x] Match via filenames 26 | - \[x] Match via provider ids 27 | - \[x] Map usernames 28 | - \[x] Use single login 29 | - \[x] One way/multi way sync 30 | - \[x] Sync watched 31 | - \[x] Sync in progress 32 | 33 | ### Emby 34 | 35 | - \[x] Match via filenames 36 | - \[x] Match via provider ids 37 | - \[x] Map usernames 38 | - \[x] Use single login 39 | - \[x] One way/multi way sync 40 | - \[x] Sync watched 41 | - \[x] Sync in progress 42 | 43 | ## Configuration 44 | 45 | Full list of configuration options can be found in the [.env.sample](.env.sample) 46 | 47 | ## Installation 48 | 49 | ### Baremetal 50 | 51 | - [Install uv](https://docs.astral.sh/uv/getting-started/installation/) 52 | 53 | - 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.) 54 | 55 | - Run 56 | 57 | ```bash 58 | uv run main.py 59 | ``` 60 | 61 | ### Docker 62 | 63 | - Build docker image 64 | 65 | ```bash 66 | docker build -t jellyplex-watched . 67 | ``` 68 | 69 | - or use pre-built image 70 | 71 | ```bash 72 | docker pull luigi311/jellyplex-watched:latest 73 | ``` 74 | 75 | #### With variables 76 | 77 | - Run 78 | 79 | ```bash 80 | docker run --rm -it -e PLEX_TOKEN='SuperSecretToken' luigi311/jellyplex-watched:latest 81 | ``` 82 | 83 | #### With .env 84 | 85 | - Create a .env file similar to .env.sample and set the variables to match your setup 86 | 87 | - Run 88 | 89 | ```bash 90 | docker run --rm -it -v "$(pwd)/.env:/app/.env" luigi311/jellyplex-watched:latest 91 | ``` 92 | 93 | ## Troubleshooting/Issues 94 | 95 | - Jellyfin 96 | 97 | - Attempt to decode JSON with unexpected mimetype, make sure you enable remote access or add your docker subnet to lan networks in jellyfin settings 98 | 99 | - Configuration 100 | - Do not use quotes around variables in docker compose 101 | - 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. 102 | 103 | ## Contributing 104 | 105 | I am open to receiving pull requests. If you are submitting a pull request, please make sure run it locally for a day or two to make sure it is working as expected and stable. 106 | 107 | ## License 108 | 109 | This is currently under the GNU General Public License v3.0. 110 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Sync watched status between media servers locally 2 | 3 | services: 4 | jellyplex-watched: 5 | image: luigi311/jellyplex-watched:latest 6 | container_name: jellyplex-watched 7 | restart: unless-stopped 8 | environment: 9 | - PUID=1000 10 | - PGID=1000 11 | env_file: "./.env" 12 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | # Check if user is root 6 | if [ "$(id -u)" = '0' ]; then 7 | echo "User is root, checking if we need to create a user and group based on environment variables" 8 | # Create group and user based on environment variables 9 | if [ ! "$(getent group "$PGID")" ]; then 10 | # If groupadd exists, use it 11 | if command -v groupadd > /dev/null; then 12 | groupadd -g "$PGID" jellyplex_watched 13 | elif command -v addgroup > /dev/null; then 14 | addgroup -g "$PGID" jellyplex_watched 15 | fi 16 | fi 17 | 18 | # If user id does not exist, create the user 19 | if [ ! "$(getent passwd "$PUID")" ]; then 20 | if command -v useradd > /dev/null; then 21 | useradd --no-create-home -u "$PUID" -g "$PGID" jellyplex_watched 22 | elif command -v adduser > /dev/null; then 23 | # Get the group name based on the PGID since adduser does not have a flag to specify the group id 24 | # and if the group id already exists the group name will be sommething unexpected 25 | GROUPNAME=$(getent group "$PGID" | cut -d: -f1) 26 | 27 | # Use alpine busybox adduser syntax 28 | adduser -D -H -u "$PUID" -G "$GROUPNAME" jellyplex_watched 29 | fi 30 | fi 31 | else 32 | # If user is not root, set the PUID and PGID to the current user 33 | PUID=$(id -u) 34 | PGID=$(id -g) 35 | fi 36 | 37 | # Get directory of log and mark file to create base folder if it doesnt exist 38 | LOG_DIR=$(dirname "$LOG_FILE") 39 | # If LOG_DIR is set, create the directory 40 | if [ -n "$LOG_DIR" ]; then 41 | mkdir -p "$LOG_DIR" 42 | fi 43 | 44 | MARK_DIR=$(dirname "$MARK_FILE") 45 | if [ -n "$MARK_DIR" ]; then 46 | mkdir -p "$MARK_DIR" 47 | fi 48 | 49 | echo "Starting JellyPlex-Watched with UID: $PUID and GID: $PGID" 50 | 51 | # If root run as the created user 52 | if [ "$(id -u)" = '0' ]; then 53 | chown -R "$PUID:$PGID" /app/.venv 54 | chown -R "$PUID:$PGID" "$LOG_DIR" 55 | chown -R "$PUID:$PGID" "$MARK_DIR" 56 | 57 | # Run the application as the created user 58 | exec gosu "$PUID:$PGID" "$@" 59 | else 60 | # Run the application as the current user 61 | exec "$@" 62 | fi 63 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if __name__ == "__main__": 4 | # Check python version 3.12 or higher 5 | if not (3, 12) <= tuple(map(int, sys.version_info[:2])): 6 | print("This script requires Python 3.12 or higher") 7 | sys.exit(1) 8 | 9 | from src.main import main 10 | 11 | main() 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "jellyplex-watched" 3 | version = "7.0.4" 4 | description = "Sync watched between media servers locally" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "loguru>=0.7.3", 9 | "packaging==25.0", 10 | "plexapi==4.17.0", 11 | "pydantic==2.11.4", 12 | "python-dotenv==1.1.0", 13 | "requests==2.32.3", 14 | ] 15 | 16 | [dependency-groups] 17 | lint = [ 18 | "ruff>=0.11.10", 19 | ] 20 | dev = [ 21 | "mypy>=1.15.0", 22 | "pytest>=8.3.5", 23 | "types-requests>=2.32.0.20250515", 24 | ] 25 | -------------------------------------------------------------------------------- /src/black_white.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from src.functions import search_mapping 4 | 5 | 6 | def setup_black_white_lists( 7 | blacklist_library: list[str] | None, 8 | whitelist_library: list[str] | None, 9 | blacklist_library_type: list[str] | None, 10 | whitelist_library_type: list[str] | None, 11 | blacklist_users: list[str] | None, 12 | whitelist_users: list[str] | None, 13 | library_mapping: dict[str, str] | None = None, 14 | user_mapping: dict[str, str] | None = None, 15 | ) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str]]: 16 | blacklist_library, blacklist_library_type, blacklist_users = setup_x_lists( 17 | blacklist_library, 18 | blacklist_library_type, 19 | blacklist_users, 20 | "Black", 21 | library_mapping, 22 | user_mapping, 23 | ) 24 | 25 | whitelist_library, whitelist_library_type, whitelist_users = setup_x_lists( 26 | whitelist_library, 27 | whitelist_library_type, 28 | whitelist_users, 29 | "White", 30 | library_mapping, 31 | user_mapping, 32 | ) 33 | 34 | return ( 35 | blacklist_library, 36 | whitelist_library, 37 | blacklist_library_type, 38 | whitelist_library_type, 39 | blacklist_users, 40 | whitelist_users, 41 | ) 42 | 43 | 44 | def setup_x_lists( 45 | xlist_library: list[str] | None, 46 | xlist_library_type: list[str] | None, 47 | xlist_users: list[str] | None, 48 | xlist_type: str | None, 49 | library_mapping: dict[str, str] | None = None, 50 | user_mapping: dict[str, str] | None = None, 51 | ) -> tuple[list[str], list[str], list[str]]: 52 | out_library: list[str] = [] 53 | if xlist_library: 54 | out_library = [x.strip() for x in xlist_library] 55 | if library_mapping: 56 | temp_library: list[str] = [] 57 | for library in xlist_library: 58 | library_other = search_mapping(library_mapping, library) 59 | if library_other: 60 | temp_library.append(library_other) 61 | 62 | out_library = out_library + temp_library 63 | logger.info(f"{xlist_type}list Library: {xlist_library}") 64 | 65 | out_library_type: list[str] = [] 66 | if xlist_library_type: 67 | out_library_type = [x.lower().strip() for x in xlist_library_type] 68 | 69 | logger.info(f"{xlist_type}list Library Type: {out_library_type}") 70 | 71 | out_users: list[str] = [] 72 | if xlist_users: 73 | out_users = [x.lower().strip() for x in xlist_users] 74 | if user_mapping: 75 | temp_users: list[str] = [] 76 | for user in out_users: 77 | user_other = search_mapping(user_mapping, user) 78 | if user_other: 79 | temp_users.append(user_other) 80 | 81 | out_users = out_users + temp_users 82 | 83 | logger.info(f"{xlist_type}list Users: {out_users}") 84 | 85 | return out_library, out_library_type, out_users 86 | -------------------------------------------------------------------------------- /src/connection.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Literal 3 | from dotenv import load_dotenv 4 | from loguru import logger 5 | 6 | from src.functions import str_to_bool 7 | from src.plex import Plex 8 | from src.jellyfin import Jellyfin 9 | from src.emby import Emby 10 | 11 | load_dotenv(override=True) 12 | 13 | 14 | def jellyfin_emby_server_connection( 15 | server_baseurl: str, server_token: str, server_type: Literal["jellyfin", "emby"] 16 | ) -> list[Jellyfin | Emby]: 17 | servers: list[Jellyfin | Emby] = [] 18 | server: Jellyfin | Emby 19 | 20 | server_baseurls = server_baseurl.split(",") 21 | server_tokens = server_token.split(",") 22 | 23 | if len(server_baseurls) != len(server_tokens): 24 | raise Exception( 25 | f"{server_type.upper()}_BASEURL and {server_type.upper()}_TOKEN must have the same number of entries" 26 | ) 27 | 28 | for i, base_url in enumerate(server_baseurls): 29 | base_url = base_url.strip() 30 | if base_url[-1] == "/": 31 | base_url = base_url[:-1] 32 | 33 | if server_type == "jellyfin": 34 | server = Jellyfin(base_url=base_url, token=server_tokens[i].strip()) 35 | servers.append(server) 36 | 37 | elif server_type == "emby": 38 | server = Emby(base_url=base_url, token=server_tokens[i].strip()) 39 | servers.append(server) 40 | else: 41 | raise Exception("Unknown server type") 42 | 43 | logger.debug(f"{server_type} Server {i} info: {server.info()}") 44 | 45 | return servers 46 | 47 | 48 | def generate_server_connections() -> list[Plex | Jellyfin | Emby]: 49 | servers: list[Plex | Jellyfin | Emby] = [] 50 | 51 | plex_baseurl_str: str | None = os.getenv("PLEX_BASEURL", None) 52 | plex_token_str: str | None = os.getenv("PLEX_TOKEN", None) 53 | plex_username_str: str | None = os.getenv("PLEX_USERNAME", None) 54 | plex_password_str: str | None = os.getenv("PLEX_PASSWORD", None) 55 | plex_servername_str: str | None = os.getenv("PLEX_SERVERNAME", None) 56 | ssl_bypass = str_to_bool(os.getenv("SSL_BYPASS", "False")) 57 | 58 | if plex_baseurl_str and plex_token_str: 59 | plex_baseurl = plex_baseurl_str.split(",") 60 | plex_token = plex_token_str.split(",") 61 | 62 | if len(plex_baseurl) != len(plex_token): 63 | raise Exception( 64 | "PLEX_BASEURL and PLEX_TOKEN must have the same number of entries" 65 | ) 66 | 67 | for i, url in enumerate(plex_baseurl): 68 | server = Plex( 69 | base_url=url.strip(), 70 | token=plex_token[i].strip(), 71 | user_name=None, 72 | password=None, 73 | server_name=None, 74 | ssl_bypass=ssl_bypass, 75 | ) 76 | 77 | logger.debug(f"Plex Server {i} info: {server.info()}") 78 | 79 | servers.append(server) 80 | 81 | if plex_username_str and plex_password_str and plex_servername_str: 82 | plex_username = plex_username_str.split(",") 83 | plex_password = plex_password_str.split(",") 84 | plex_servername = plex_servername_str.split(",") 85 | 86 | if len(plex_username) != len(plex_password) or len(plex_username) != len( 87 | plex_servername 88 | ): 89 | raise Exception( 90 | "PLEX_USERNAME, PLEX_PASSWORD and PLEX_SERVERNAME must have the same number of entries" 91 | ) 92 | 93 | for i, username in enumerate(plex_username): 94 | server = Plex( 95 | base_url=None, 96 | token=None, 97 | user_name=username.strip(), 98 | password=plex_password[i].strip(), 99 | server_name=plex_servername[i].strip(), 100 | ssl_bypass=ssl_bypass, 101 | ) 102 | 103 | logger.debug(f"Plex Server {i} info: {server.info()}") 104 | servers.append(server) 105 | 106 | jellyfin_baseurl = os.getenv("JELLYFIN_BASEURL", None) 107 | jellyfin_token = os.getenv("JELLYFIN_TOKEN", None) 108 | if jellyfin_baseurl and jellyfin_token: 109 | servers.extend( 110 | jellyfin_emby_server_connection( 111 | jellyfin_baseurl, jellyfin_token, "jellyfin" 112 | ) 113 | ) 114 | 115 | emby_baseurl = os.getenv("EMBY_BASEURL", None) 116 | emby_token = os.getenv("EMBY_TOKEN", None) 117 | if emby_baseurl and emby_token: 118 | servers.extend( 119 | jellyfin_emby_server_connection(emby_baseurl, emby_token, "emby") 120 | ) 121 | 122 | return servers 123 | -------------------------------------------------------------------------------- /src/emby.py: -------------------------------------------------------------------------------- 1 | from src.jellyfin_emby import JellyfinEmby 2 | from packaging.version import parse, Version 3 | from loguru import logger 4 | 5 | 6 | class Emby(JellyfinEmby): 7 | def __init__(self, base_url: str, token: str) -> None: 8 | authorization = ( 9 | "Emby , " 10 | 'Client="JellyPlex-Watched", ' 11 | 'Device="script", ' 12 | 'DeviceId="script", ' 13 | 'Version="6.0.2"' 14 | ) 15 | headers = { 16 | "Accept": "application/json", 17 | "X-Emby-Token": token, 18 | "X-Emby-Authorization": authorization, 19 | } 20 | 21 | super().__init__( 22 | server_type="Emby", base_url=base_url, token=token, headers=headers 23 | ) 24 | 25 | def is_partial_update_supported(self, server_version: Version) -> bool: 26 | if not server_version >= parse("4.4"): 27 | logger.info( 28 | f"{self.server_type}: Server version {server_version} does not support updating playback position.", 29 | ) 30 | return False 31 | 32 | return True 33 | -------------------------------------------------------------------------------- /src/functions.py: -------------------------------------------------------------------------------- 1 | import os 2 | from concurrent.futures import Future, ThreadPoolExecutor 3 | from typing import Any, Callable 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv(override=True) 7 | 8 | mark_file = os.getenv("MARK_FILE", os.getenv("MARKFILE", "mark.log")) 9 | 10 | 11 | def log_marked( 12 | server_type: str, 13 | server_name: str, 14 | username: str, 15 | library: str, 16 | movie_show: str, 17 | episode: str | None = None, 18 | duration: float | None = None, 19 | ) -> None: 20 | output = f"{server_type}/{server_name}/{username}/{library}/{movie_show}" 21 | 22 | if episode: 23 | output += f"/{episode}" 24 | 25 | if duration: 26 | output += f"/{duration}" 27 | 28 | with open(mark_file, "a", encoding="utf-8") as file: 29 | file.write(output + "\n") 30 | 31 | 32 | # Reimplementation of distutils.util.strtobool due to it being deprecated 33 | # Source: https://github.com/PostHog/posthog/blob/01e184c29d2c10c43166f1d40a334abbc3f99d8a/posthog/utils.py#L668 34 | def str_to_bool(value: str) -> bool: 35 | if not value: 36 | return False 37 | return str(value).lower() in ("y", "yes", "t", "true", "on", "1") 38 | 39 | 40 | # Get mapped value 41 | def search_mapping(dictionary: dict[str, str], key_value: str) -> str | None: 42 | if key_value in dictionary.keys(): 43 | return dictionary[key_value] 44 | elif key_value.lower() in dictionary.keys(): 45 | return dictionary[key_value.lower()] 46 | elif key_value in dictionary.values(): 47 | return list(dictionary.keys())[list(dictionary.values()).index(key_value)] 48 | elif key_value.lower() in dictionary.values(): 49 | return list(dictionary.keys())[ 50 | list(dictionary.values()).index(key_value.lower()) 51 | ] 52 | else: 53 | return None 54 | 55 | 56 | # Return list of objects that exist in both lists including mappings 57 | def match_list( 58 | list1: list[str], list2: list[str], list_mapping: dict[str, str] | None = None 59 | ) -> list[str]: 60 | output: list[str] = [] 61 | for element in list1: 62 | if element in list2: 63 | output.append(element) 64 | elif list_mapping: 65 | element_other = search_mapping(list_mapping, element) 66 | if element_other in list2: 67 | output.append(element) 68 | 69 | return output 70 | 71 | 72 | def future_thread_executor( 73 | args: list[tuple[Callable[..., Any], ...]], 74 | threads: int | None = None, 75 | override_threads: bool = False, 76 | ) -> list[Any]: 77 | results: list[Any] = [] 78 | 79 | # Determine the number of workers, defaulting to 1 if os.cpu_count() returns None 80 | max_threads_env: int = int(os.getenv("MAX_THREADS", 32)) 81 | cpu_threads: int = os.cpu_count() or 1 # Default to 1 if os.cpu_count() is None 82 | workers: int = min(max_threads_env, cpu_threads * 2) 83 | 84 | # Adjust workers based on threads parameter and override_threads flag 85 | if threads is not None: 86 | workers = min(threads, workers) 87 | if override_threads: 88 | workers = threads if threads is not None else workers 89 | 90 | # If only one worker, run in main thread to avoid overhead 91 | if workers == 1: 92 | for arg in args: 93 | results.append(arg[0](*arg[1:])) 94 | return results 95 | 96 | with ThreadPoolExecutor(max_workers=workers) as executor: 97 | futures_list: list[Future[Any]] = [] 98 | 99 | for arg in args: 100 | # * arg unpacks the list into actual arguments 101 | futures_list.append(executor.submit(*arg)) 102 | 103 | for out in futures_list: 104 | try: 105 | result = out.result() 106 | results.append(result) 107 | except Exception as e: 108 | raise Exception(e) 109 | 110 | return results 111 | 112 | 113 | def parse_string_to_list(string: str | None) -> list[str]: 114 | output: list[str] = [] 115 | if string and len(string) > 0: 116 | output = string.split(",") 117 | 118 | return output 119 | -------------------------------------------------------------------------------- /src/jellyfin.py: -------------------------------------------------------------------------------- 1 | from src.jellyfin_emby import JellyfinEmby 2 | from packaging.version import parse, Version 3 | from loguru import logger 4 | 5 | 6 | class Jellyfin(JellyfinEmby): 7 | def __init__(self, base_url: str, token: str) -> None: 8 | authorization = ( 9 | "MediaBrowser , " 10 | 'Client="JellyPlex-Watched", ' 11 | 'Device="script", ' 12 | 'DeviceId="script", ' 13 | 'Version="6.0.2", ' 14 | f'Token="{token}"' 15 | ) 16 | headers = { 17 | "Accept": "application/json", 18 | "Authorization": authorization, 19 | } 20 | 21 | super().__init__( 22 | server_type="Jellyfin", base_url=base_url, token=token, headers=headers 23 | ) 24 | 25 | def is_partial_update_supported(self, server_version: Version) -> bool: 26 | if not server_version >= parse("10.9.0"): 27 | logger.info( 28 | f"{self.server_type}: Server version {server_version} does not support updating playback position.", 29 | ) 30 | return False 31 | 32 | return True 33 | -------------------------------------------------------------------------------- /src/jellyfin_emby.py: -------------------------------------------------------------------------------- 1 | # Functions for Jellyfin and Emby 2 | 3 | import requests 4 | import traceback 5 | import os 6 | from math import floor 7 | from typing import Any, Literal 8 | from dotenv import load_dotenv 9 | from packaging.version import parse, Version 10 | from loguru import logger 11 | 12 | from src.functions import ( 13 | search_mapping, 14 | log_marked, 15 | str_to_bool, 16 | ) 17 | from src.watched import ( 18 | LibraryData, 19 | MediaIdentifiers, 20 | MediaItem, 21 | WatchedStatus, 22 | Series, 23 | UserData, 24 | check_same_identifiers, 25 | ) 26 | 27 | load_dotenv(override=True) 28 | 29 | generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True")) 30 | generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True")) 31 | 32 | 33 | def extract_identifiers_from_item( 34 | server_type: str, item: dict[str, Any] 35 | ) -> MediaIdentifiers: 36 | title = item.get("Name") 37 | id = None 38 | if not title: 39 | id = item.get("Id") 40 | logger.info(f"{server_type}: Name not found for {id}") 41 | 42 | guids = {} 43 | if generate_guids: 44 | guids = {k.lower(): v for k, v in item.get("ProviderIds", {}).items()} 45 | if not guids: 46 | logger.info( 47 | f"{server_type}: {title if title else id} has no guids", 48 | ) 49 | 50 | locations: tuple[str, ...] = tuple() 51 | if generate_locations: 52 | if item.get("Path"): 53 | locations = tuple([item["Path"].split("/")[-1]]) 54 | elif item.get("MediaSources"): 55 | locations = tuple( 56 | [ 57 | x["Path"].split("/")[-1] 58 | for x in item["MediaSources"] 59 | if x.get("Path") 60 | ] 61 | ) 62 | 63 | if not locations: 64 | logger.info(f"{server_type}: {title if title else id} has no locations") 65 | 66 | return MediaIdentifiers( 67 | title=title, 68 | locations=locations, 69 | imdb_id=guids.get("imdb"), 70 | tvdb_id=guids.get("tvdb"), 71 | tmdb_id=guids.get("tmdb"), 72 | ) 73 | 74 | 75 | def get_mediaitem(server_type: str, item: dict[str, Any]) -> MediaItem: 76 | return MediaItem( 77 | identifiers=extract_identifiers_from_item(server_type, item), 78 | status=WatchedStatus( 79 | completed=item.get("UserData", {}).get("Played"), 80 | time=floor( 81 | item.get("UserData", {}).get("PlaybackPositionTicks", 0) / 10000 82 | ), 83 | ), 84 | ) 85 | 86 | 87 | class JellyfinEmby: 88 | def __init__( 89 | self, 90 | server_type: Literal["Jellyfin", "Emby"], 91 | base_url: str, 92 | token: str, 93 | headers: dict[str, str], 94 | ) -> None: 95 | if server_type not in ["Jellyfin", "Emby"]: 96 | raise Exception(f"Server type {server_type} not supported") 97 | self.server_type: str = server_type 98 | self.base_url: str = base_url 99 | self.token: str = token 100 | self.headers: dict[str, str] = headers 101 | self.timeout: int = int(os.getenv("REQUEST_TIMEOUT", 300)) 102 | 103 | if not self.base_url: 104 | raise Exception(f"{self.server_type} base_url not set") 105 | 106 | if not self.token: 107 | raise Exception(f"{self.server_type} token not set") 108 | 109 | self.session = requests.Session() 110 | self.users: dict[str, str] = self.get_users() 111 | self.server_name: str = self.info(name_only=True) 112 | self.server_version: Version = self.info(version_only=True) 113 | self.update_partial: bool = self.is_partial_update_supported( 114 | self.server_version 115 | ) 116 | 117 | def query( 118 | self, 119 | query: str, 120 | query_type: Literal["get", "post"], 121 | identifiers: dict[str, str] | None = None, 122 | json: dict[str, float] | None = None, 123 | ) -> list[dict[str, Any]] | dict[str, Any] | None: 124 | try: 125 | results = None 126 | 127 | if query_type == "get": 128 | response = self.session.get( 129 | self.base_url + query, headers=self.headers, timeout=self.timeout 130 | ) 131 | if response.status_code not in [200, 204]: 132 | raise Exception( 133 | f"Query failed with status {response.status_code} {response.reason}" 134 | ) 135 | if response.status_code == 204: 136 | results = None 137 | else: 138 | results = response.json() 139 | 140 | elif query_type == "post": 141 | response = self.session.post( 142 | self.base_url + query, 143 | headers=self.headers, 144 | json=json, 145 | timeout=self.timeout, 146 | ) 147 | if response.status_code not in [200, 204]: 148 | raise Exception( 149 | f"Query failed with status {response.status_code} {response.reason}" 150 | ) 151 | if response.status_code == 204: 152 | results = None 153 | else: 154 | results = response.json() 155 | 156 | if results: 157 | if not isinstance(results, list) and not isinstance(results, dict): 158 | raise Exception("Query result is not of type list or dict") 159 | 160 | # append identifiers to results 161 | if identifiers and isinstance(results, dict): 162 | results["Identifiers"] = identifiers 163 | 164 | return results 165 | 166 | except Exception as e: 167 | logger.error( 168 | f"{self.server_type}: Query {query_type} {query}\nResults {results}\n{e}", 169 | ) 170 | raise Exception(e) 171 | 172 | def info( 173 | self, name_only: bool = False, version_only: bool = False 174 | ) -> str | Version | None: 175 | try: 176 | query_string = "/System/Info/Public" 177 | 178 | response = self.query(query_string, "get") 179 | 180 | if response and isinstance(response, dict): 181 | if name_only: 182 | return response.get("ServerName") 183 | elif version_only: 184 | return parse(response.get("Version", "")) 185 | 186 | return f"{self.server_type} {response.get('ServerName')}: {response.get('Version')}" 187 | else: 188 | return None 189 | 190 | except Exception as e: 191 | logger.error(f"{self.server_type}: Get server name failed {e}") 192 | raise Exception(e) 193 | 194 | def get_users(self) -> dict[str, str]: 195 | try: 196 | users: dict[str, str] = {} 197 | 198 | query_string = "/Users" 199 | response = self.query(query_string, "get") 200 | 201 | if response and isinstance(response, list): 202 | for user in response: 203 | users[user["Name"]] = user["Id"] 204 | 205 | return users 206 | except Exception as e: 207 | logger.error(f"{self.server_type}: Get users failed {e}") 208 | raise Exception(e) 209 | 210 | def get_libraries(self) -> dict[str, str]: 211 | try: 212 | libraries: dict[str, str] = {} 213 | 214 | # Theres no way to get all libraries so individually get list of libraries from all users 215 | users = self.get_users() 216 | 217 | for user_name, user_id in users.items(): 218 | user_libraries = self.query(f"/Users/{user_id}/Views", "get") 219 | 220 | if not user_libraries or not isinstance(user_libraries, dict): 221 | logger.error( 222 | f"{self.server_type}: Failed to get libraries for {user_name}" 223 | ) 224 | return libraries 225 | 226 | logger.debug( 227 | f"{self.server_type}: All Libraries for {user_name} {[library.get('Name') for library in user_libraries.get('Items', [])]}" 228 | ) 229 | 230 | for library in user_libraries.get("Items", []): 231 | library_title = library.get("Name") 232 | library_type = library.get("CollectionType") 233 | 234 | # If collection type is not set, fallback based on media files 235 | if not library_type: 236 | library_id = library.get("Id") 237 | # Get first 100 items in library 238 | library_items = self.query( 239 | f"/Users/{user_id}/Items" 240 | + f"?ParentId={library_id}&Recursive=True&excludeItemTypes=Folder&limit=100", 241 | "get", 242 | ) 243 | 244 | if not library_items or not isinstance(library_items, dict): 245 | logger.debug( 246 | f"{self.server_type}: Failed to get library items for {user_name} {library_title}" 247 | ) 248 | continue 249 | 250 | all_types = set( 251 | [x.get("Type") for x in library_items.get("Items", [])] 252 | ) 253 | types = set([x for x in all_types if x in ["Movie", "Episode"]]) 254 | 255 | if not len(types) == 1: 256 | logger.debug( 257 | f"{self.server_type}: Skipping Library {library_title} didn't find just a single type, found {all_types}", 258 | ) 259 | continue 260 | 261 | library_type = types.pop() 262 | 263 | library_type = ( 264 | "movies" if library_type == "Movie" else "tvshows" 265 | ) 266 | 267 | if library_type not in ["movies", "tvshows"]: 268 | logger.debug( 269 | f"{self.server_type}: Skipping Library {library_title} found type {library_type}", 270 | ) 271 | continue 272 | 273 | libraries[library_title] = library_type 274 | 275 | return libraries 276 | except Exception as e: 277 | logger.error(f"{self.server_type}: Get libraries failed {e}") 278 | raise Exception(e) 279 | 280 | def get_user_library_watched( 281 | self, 282 | user_name: str, 283 | user_id: str, 284 | library_type: Literal["movies", "tvshows"], 285 | library_id: str, 286 | library_title: str, 287 | ) -> LibraryData: 288 | user_name = user_name.lower() 289 | try: 290 | logger.info( 291 | f"{self.server_type}: Generating watched for {user_name} in library {library_title}", 292 | ) 293 | watched = LibraryData(title=library_title) 294 | 295 | # Movies 296 | if library_type == "movies": 297 | movie_items = [] 298 | watched_items = self.query( 299 | f"/Users/{user_id}/Items" 300 | + f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", 301 | "get", 302 | ) 303 | 304 | if watched_items and isinstance(watched_items, dict): 305 | movie_items += watched_items.get("Items", []) 306 | 307 | in_progress_items = self.query( 308 | f"/Users/{user_id}/Items" 309 | + f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources", 310 | "get", 311 | ) 312 | 313 | if in_progress_items and isinstance(in_progress_items, dict): 314 | movie_items += in_progress_items.get("Items", []) 315 | 316 | for movie in movie_items: 317 | # Skip if theres no user data which means the movie has not been watched 318 | if not movie.get("UserData"): 319 | continue 320 | 321 | # Skip if theres no media tied to the movie 322 | if not movie.get("MediaSources"): 323 | continue 324 | 325 | # Skip if not watched or watched less than a minute 326 | if ( 327 | movie["UserData"].get("Played") 328 | or movie["UserData"].get("PlaybackPositionTicks", 0) > 600000000 329 | ): 330 | watched.movies.append(get_mediaitem(self.server_type, movie)) 331 | 332 | # TV Shows 333 | if library_type == "tvshows": 334 | # Retrieve a list of watched TV shows 335 | all_shows = self.query( 336 | f"/Users/{user_id}/Items" 337 | + f"?ParentId={library_id}&isPlaceHolder=false&IncludeItemTypes=Series&Recursive=True&Fields=ProviderIds,Path,RecursiveItemCount", 338 | "get", 339 | ) 340 | 341 | if not all_shows or not isinstance(all_shows, dict): 342 | logger.debug( 343 | f"{self.server_type}: Failed to get shows for {user_name} in {library_title}" 344 | ) 345 | return watched 346 | 347 | # Filter the list of shows to only include those that have been partially or fully watched 348 | watched_shows_filtered = [] 349 | for show in all_shows.get("Items", []): 350 | if not show.get("UserData"): 351 | continue 352 | 353 | if show["UserData"].get("PlayedPercentage", 0) > 0: 354 | watched_shows_filtered.append(show) 355 | 356 | # Retrieve the watched/partially watched list of episodes of each watched show 357 | for show in watched_shows_filtered: 358 | show_name = show.get("Name") 359 | show_guids = { 360 | k.lower(): v for k, v in show.get("ProviderIds", {}).items() 361 | } 362 | show_locations = ( 363 | tuple([show["Path"].split("/")[-1]]) 364 | if show.get("Path") 365 | else tuple() 366 | ) 367 | 368 | show_episodes = self.query( 369 | f"/Shows/{show.get('Id')}/Episodes" 370 | + f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources", 371 | "get", 372 | ) 373 | 374 | if not show_episodes or not isinstance(show_episodes, dict): 375 | logger.debug( 376 | f"{self.server_type}: Failed to get episodes for {user_name} {library_title} {show_name}" 377 | ) 378 | continue 379 | 380 | # Iterate through the episodes 381 | # Create a list to store the episodes 382 | episode_mediaitem = [] 383 | for episode in show_episodes.get("Items", []): 384 | if not episode.get("UserData"): 385 | continue 386 | 387 | if not episode.get("MediaSources"): 388 | continue 389 | 390 | # If watched or watched more than a minute 391 | if ( 392 | episode["UserData"].get("Played") 393 | or episode["UserData"].get("PlaybackPositionTicks", 0) 394 | > 600000000 395 | ): 396 | episode_mediaitem.append( 397 | get_mediaitem(self.server_type, episode) 398 | ) 399 | 400 | if episode_mediaitem: 401 | watched.series.append( 402 | Series( 403 | identifiers=MediaIdentifiers( 404 | title=show.get("Name"), 405 | locations=show_locations, 406 | imdb_id=show_guids.get("imdb"), 407 | tvdb_id=show_guids.get("tvdb"), 408 | tmdb_id=show_guids.get("tmdb"), 409 | ), 410 | episodes=episode_mediaitem, 411 | ) 412 | ) 413 | 414 | logger.info( 415 | f"{self.server_type}: Finished getting watched for {user_name} in library {library_title}", 416 | ) 417 | 418 | return watched 419 | except Exception as e: 420 | logger.error( 421 | f"{self.server_type}: Failed to get watched for {user_name} in library {library_title}, Error: {e}", 422 | ) 423 | 424 | logger.error(traceback.format_exc()) 425 | return LibraryData(title=library_title) 426 | 427 | def get_watched( 428 | self, users: dict[str, str], sync_libraries: list[str] 429 | ) -> dict[str, UserData]: 430 | try: 431 | users_watched: dict[str, UserData] = {} 432 | 433 | for user_name, user_id in users.items(): 434 | all_libraries = self.query(f"/Users/{user_id}/Views", "get") 435 | if not all_libraries or not isinstance(all_libraries, dict): 436 | logger.debug( 437 | f"{self.server_type}: Failed to get all libraries for {user_name}" 438 | ) 439 | continue 440 | 441 | for library in all_libraries.get("Items", []): 442 | if library.get("Name") not in sync_libraries: 443 | continue 444 | 445 | library_id = library.get("Id") 446 | library_title = library.get("Name") 447 | library_type = library.get("CollectionType") 448 | if not library_id or not library_title or not library_type: 449 | logger.debug( 450 | f"{self.server_type}: Failed to get library data for {user_name} {library_title}" 451 | ) 452 | 453 | # Get watched for user 454 | library_data = self.get_user_library_watched( 455 | user_name, 456 | user_id, 457 | library_type, 458 | library_id, 459 | library_title, 460 | ) 461 | 462 | if user_name.lower() not in users_watched: 463 | users_watched[user_name.lower()] = UserData() 464 | 465 | users_watched[user_name.lower()].libraries[library_title] = ( 466 | library_data 467 | ) 468 | 469 | return users_watched 470 | except Exception as e: 471 | logger.error(f"{self.server_type}: Failed to get watched, Error: {e}") 472 | return {} 473 | 474 | def update_user_watched( 475 | self, 476 | user_name: str, 477 | user_id: str, 478 | library_data: LibraryData, 479 | library_name: str, 480 | library_id: str, 481 | dryrun: bool, 482 | ) -> None: 483 | try: 484 | # If there are no movies or shows to update, exit early. 485 | if not library_data.series and not library_data.movies: 486 | return 487 | 488 | logger.info( 489 | f"{self.server_type}: Updating watched for {user_name} in library {library_name}", 490 | ) 491 | 492 | # Update movies. 493 | if library_data.movies: 494 | jellyfin_search = self.query( 495 | f"/Users/{user_id}/Items" 496 | + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" 497 | + "&isPlayed=false&Fields=ItemCounts,ProviderIds,MediaSources&IncludeItemTypes=Movie", 498 | "get", 499 | ) 500 | 501 | if not jellyfin_search or not isinstance(jellyfin_search, dict): 502 | logger.debug( 503 | f"{self.server_type}: Failed to get movies for {user_name} {library_name}" 504 | ) 505 | return 506 | 507 | for jellyfin_video in jellyfin_search.get("Items", []): 508 | jelly_identifiers = extract_identifiers_from_item( 509 | self.server_type, jellyfin_video 510 | ) 511 | # Check each stored movie for a match. 512 | for stored_movie in library_data.movies: 513 | if check_same_identifiers( 514 | jelly_identifiers, stored_movie.identifiers 515 | ): 516 | jellyfin_video_id = jellyfin_video.get("Id") 517 | if stored_movie.status.completed: 518 | msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library_name}" 519 | if not dryrun: 520 | self.query( 521 | f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}", 522 | "post", 523 | ) 524 | 525 | logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}") 526 | log_marked( 527 | self.server_type, 528 | self.server_name, 529 | user_name, 530 | library_name, 531 | jellyfin_video.get("Name"), 532 | ) 533 | elif self.update_partial: 534 | msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user_name} in {library_name}" 535 | 536 | if not dryrun: 537 | playback_position_payload: dict[str, float] = { 538 | "PlaybackPositionTicks": stored_movie.status.time 539 | * 10_000, 540 | } 541 | self.query( 542 | f"/Users/{user_id}/Items/{jellyfin_video_id}/UserData", 543 | "post", 544 | json=playback_position_payload, 545 | ) 546 | 547 | logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}") 548 | log_marked( 549 | self.server_type, 550 | self.server_name, 551 | user_name, 552 | library_name, 553 | jellyfin_video.get("Name"), 554 | duration=floor(stored_movie.status.time / 60_000), 555 | ) 556 | else: 557 | logger.trace( 558 | f"{self.server_type}: Skipping movie {jellyfin_video.get('Name')} as it is not in mark list for {user_name}", 559 | ) 560 | 561 | # Update TV Shows (series/episodes). 562 | if library_data.series: 563 | jellyfin_search = self.query( 564 | f"/Users/{user_id}/Items" 565 | + f"?SortBy=SortName&SortOrder=Ascending&Recursive=True&ParentId={library_id}" 566 | + "&Fields=ItemCounts,ProviderIds,Path&IncludeItemTypes=Series", 567 | "get", 568 | ) 569 | if not jellyfin_search or not isinstance(jellyfin_search, dict): 570 | logger.debug( 571 | f"{self.server_type}: Failed to get shows for {user_name} {library_name}" 572 | ) 573 | return 574 | 575 | jellyfin_shows = [x for x in jellyfin_search.get("Items", [])] 576 | 577 | for jellyfin_show in jellyfin_shows: 578 | jellyfin_show_identifiers = extract_identifiers_from_item( 579 | self.server_type, jellyfin_show 580 | ) 581 | # Try to find a matching series in your stored library. 582 | for stored_series in library_data.series: 583 | if check_same_identifiers( 584 | jellyfin_show_identifiers, stored_series.identifiers 585 | ): 586 | logger.trace( 587 | f"Found matching show for '{jellyfin_show.get('Name')}'", 588 | ) 589 | # Now update episodes. 590 | # Get the list of Plex episodes for this show. 591 | jellyfin_show_id = jellyfin_show.get("Id") 592 | jellyfin_episodes = self.query( 593 | f"/Shows/{jellyfin_show_id}/Episodes" 594 | + f"?userId={user_id}&Fields=ItemCounts,ProviderIds,MediaSources", 595 | "get", 596 | ) 597 | 598 | if not jellyfin_episodes or not isinstance( 599 | jellyfin_episodes, dict 600 | ): 601 | logger.debug( 602 | f"{self.server_type}: Failed to get episodes for {user_name} {library_name} {jellyfin_show.get('Name')}" 603 | ) 604 | return 605 | 606 | for jellyfin_episode in jellyfin_episodes.get("Items", []): 607 | jellyfin_episode_identifiers = ( 608 | extract_identifiers_from_item( 609 | self.server_type, jellyfin_episode 610 | ) 611 | ) 612 | for stored_ep in stored_series.episodes: 613 | if check_same_identifiers( 614 | jellyfin_episode_identifiers, 615 | stored_ep.identifiers, 616 | ): 617 | jellyfin_episode_id = jellyfin_episode.get("Id") 618 | if stored_ep.status.completed: 619 | msg = ( 620 | f"{self.server_type}: {jellyfin_episode.get('SeriesName')} {jellyfin_episode.get('SeasonName')} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" 621 | + f" as watched for {user_name} in {library_name}" 622 | ) 623 | if not dryrun: 624 | self.query( 625 | f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}", 626 | "post", 627 | ) 628 | 629 | logger.success( 630 | f"{'[DRYRUN] ' if dryrun else ''}{msg}" 631 | ) 632 | log_marked( 633 | self.server_type, 634 | self.server_name, 635 | user_name, 636 | library_name, 637 | jellyfin_episode.get("SeriesName"), 638 | jellyfin_episode.get("Name"), 639 | ) 640 | elif self.update_partial: 641 | msg = ( 642 | f"{self.server_type}: {jellyfin_episode.get('SeriesName')} {jellyfin_episode.get('SeasonName')} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}" 643 | + f" as partially watched for {floor(stored_ep.status.time / 60_000)} minutes for {user_name} in {library_name}" 644 | ) 645 | 646 | if not dryrun: 647 | playback_position_payload = { 648 | "PlaybackPositionTicks": stored_ep.status.time 649 | * 10_000, 650 | } 651 | self.query( 652 | f"/Users/{user_id}/Items/{jellyfin_episode_id}/UserData", 653 | "post", 654 | json=playback_position_payload, 655 | ) 656 | 657 | logger.success( 658 | f"{'[DRYRUN] ' if dryrun else ''}{msg}" 659 | ) 660 | log_marked( 661 | self.server_type, 662 | self.server_name, 663 | user_name, 664 | library_name, 665 | jellyfin_episode.get("SeriesName"), 666 | jellyfin_episode.get("Name"), 667 | duration=floor( 668 | stored_ep.status.time / 60_000 669 | ), 670 | ) 671 | else: 672 | logger.trace( 673 | f"{self.server_type}: Skipping episode {jellyfin_episode.get('Name')} as it is not in mark list for {user_name}", 674 | ) 675 | else: 676 | logger.trace( 677 | f"{self.server_type}: Skipping show {jellyfin_show.get('Name')} as it is not in mark list for {user_name}", 678 | ) 679 | 680 | except Exception as e: 681 | logger.error( 682 | f"{self.server_type}: Error updating watched for {user_name} in library {library_name}, {e}", 683 | ) 684 | 685 | def update_watched( 686 | self, 687 | watched_list: dict[str, UserData], 688 | user_mapping: dict[str, str] | None = None, 689 | library_mapping: dict[str, str] | None = None, 690 | dryrun: bool = False, 691 | ) -> None: 692 | for user, user_data in watched_list.items(): 693 | user_other = None 694 | user_name = None 695 | if user_mapping: 696 | if user in user_mapping.keys(): 697 | user_other = user_mapping[user] 698 | elif user in user_mapping.values(): 699 | user_other = search_mapping(user_mapping, user) 700 | 701 | user_id = None 702 | for key in self.users: 703 | if user.lower() == key.lower(): 704 | user_id = self.users[key] 705 | user_name = key 706 | break 707 | elif user_other and user_other.lower() == key.lower(): 708 | user_id = self.users[key] 709 | user_name = key 710 | break 711 | 712 | if not user_id or not user_name: 713 | logger.info(f"{user} {user_other} not found in Jellyfin") 714 | continue 715 | 716 | jellyfin_libraries = self.query( 717 | f"/Users/{user_id}/Views", 718 | "get", 719 | ) 720 | 721 | if not jellyfin_libraries or not isinstance(jellyfin_libraries, dict): 722 | logger.debug( 723 | f"{self.server_type}: Failed to get libraries for {user_name}" 724 | ) 725 | continue 726 | 727 | jellyfin_libraries = [x for x in jellyfin_libraries.get("Items", [])] 728 | 729 | for library_name in user_data.libraries: 730 | library_data = user_data.libraries[library_name] 731 | library_other = None 732 | if library_mapping: 733 | if library_name in library_mapping.keys(): 734 | library_other = library_mapping[library_name] 735 | elif library_name in library_mapping.values(): 736 | library_other = search_mapping(library_mapping, library_name) 737 | 738 | if library_name.lower() not in [ 739 | x["Name"].lower() for x in jellyfin_libraries 740 | ]: 741 | if library_other: 742 | if library_other.lower() in [ 743 | x["Name"].lower() for x in jellyfin_libraries 744 | ]: 745 | logger.info( 746 | f"{self.server_type}: Library {library_name} not found, but {library_other} found, using {library_other}", 747 | ) 748 | library_name = library_other 749 | else: 750 | logger.info( 751 | f"{self.server_type}: Library {library_name} or {library_other} not found in library list", 752 | ) 753 | continue 754 | else: 755 | logger.info( 756 | f"{self.server_type}: Library {library_name} not found in library list", 757 | ) 758 | continue 759 | 760 | library_id = None 761 | for jellyfin_library in jellyfin_libraries: 762 | if jellyfin_library["Name"].lower() == library_name.lower(): 763 | library_id = jellyfin_library["Id"] 764 | continue 765 | 766 | if library_id: 767 | try: 768 | self.update_user_watched( 769 | user_name, 770 | user_id, 771 | library_data, 772 | library_name, 773 | library_id, 774 | dryrun, 775 | ) 776 | except Exception as e: 777 | logger.error( 778 | f"{self.server_type}: Error updating watched for {user_name} in library {library_name}, {e}", 779 | ) 780 | -------------------------------------------------------------------------------- /src/library.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from src.functions import ( 4 | match_list, 5 | search_mapping, 6 | ) 7 | 8 | from src.emby import Emby 9 | from src.jellyfin import Jellyfin 10 | from src.plex import Plex 11 | 12 | 13 | def check_skip_logic( 14 | library_title: str, 15 | library_type: str, 16 | blacklist_library: list[str], 17 | whitelist_library: list[str], 18 | blacklist_library_type: list[str], 19 | whitelist_library_type: list[str], 20 | library_mapping: dict[str, str] | None = None, 21 | ) -> str | None: 22 | skip_reason = None 23 | library_other = None 24 | if library_mapping: 25 | library_other = search_mapping(library_mapping, library_title) 26 | 27 | skip_reason_black = check_blacklist_logic( 28 | library_title, 29 | library_type, 30 | blacklist_library, 31 | blacklist_library_type, 32 | library_other, 33 | ) 34 | skip_reason_white = check_whitelist_logic( 35 | library_title, 36 | library_type, 37 | whitelist_library, 38 | whitelist_library_type, 39 | library_other, 40 | ) 41 | 42 | # Combine skip reasons 43 | if skip_reason_black: 44 | skip_reason = skip_reason_black 45 | 46 | if skip_reason_white: 47 | if skip_reason: 48 | skip_reason = skip_reason + " and " + skip_reason_white 49 | else: 50 | skip_reason = skip_reason_white 51 | 52 | return skip_reason 53 | 54 | 55 | def check_blacklist_logic( 56 | library_title: str, 57 | library_type: str, 58 | blacklist_library: list[str], 59 | blacklist_library_type: list[str], 60 | library_other: str | None = None, 61 | ) -> str | None: 62 | skip_reason = None 63 | if isinstance(library_type, (list, tuple, set)): 64 | for library_type_item in library_type: 65 | if library_type_item.lower() in blacklist_library_type: 66 | skip_reason = f"{library_type_item} is in blacklist_library_type" 67 | else: 68 | if library_type.lower() in blacklist_library_type: 69 | skip_reason = f"{library_type} is in blacklist_library_type" 70 | 71 | if library_title.lower() in [x.lower() for x in blacklist_library]: 72 | if skip_reason: 73 | skip_reason = ( 74 | skip_reason + " and " + f"{library_title} is in blacklist_library" 75 | ) 76 | else: 77 | skip_reason = f"{library_title} is in blacklist_library" 78 | 79 | if library_other: 80 | if library_other.lower() in [x.lower() for x in blacklist_library]: 81 | if skip_reason: 82 | skip_reason = ( 83 | skip_reason + " and " + f"{library_other} is in blacklist_library" 84 | ) 85 | else: 86 | skip_reason = f"{library_other} is in blacklist_library" 87 | 88 | return skip_reason 89 | 90 | 91 | def check_whitelist_logic( 92 | library_title: str, 93 | library_type: str, 94 | whitelist_library: list[str], 95 | whitelist_library_type: list[str], 96 | library_other: str | None = None, 97 | ) -> str | None: 98 | skip_reason = None 99 | if len(whitelist_library_type) > 0: 100 | if isinstance(library_type, (list, tuple, set)): 101 | for library_type_item in library_type: 102 | if library_type_item.lower() not in whitelist_library_type: 103 | skip_reason = ( 104 | f"{library_type_item} is not in whitelist_library_type" 105 | ) 106 | else: 107 | if library_type.lower() not in whitelist_library_type: 108 | skip_reason = f"{library_type} is not in whitelist_library_type" 109 | 110 | # if whitelist is not empty and library is not in whitelist 111 | if len(whitelist_library) > 0: 112 | if library_other: 113 | if library_title.lower() not in [ 114 | x.lower() for x in whitelist_library 115 | ] and library_other.lower() not in [x.lower() for x in whitelist_library]: 116 | if skip_reason: 117 | skip_reason = ( 118 | skip_reason 119 | + " and " 120 | + f"{library_title} is not in whitelist_library" 121 | ) 122 | else: 123 | skip_reason = f"{library_title} is not in whitelist_library" 124 | else: 125 | if library_title.lower() not in [x.lower() for x in whitelist_library]: 126 | if skip_reason: 127 | skip_reason = ( 128 | skip_reason 129 | + " and " 130 | + f"{library_title} is not in whitelist_library" 131 | ) 132 | else: 133 | skip_reason = f"{library_title} is not in whitelist_library" 134 | 135 | return skip_reason 136 | 137 | 138 | def filter_libaries( 139 | server_libraries: dict[str, str], 140 | blacklist_library: list[str], 141 | blacklist_library_type: list[str], 142 | whitelist_library: list[str], 143 | whitelist_library_type: list[str], 144 | library_mapping: dict[str, str] | None = None, 145 | ) -> list[str]: 146 | filtered_libaries: list[str] = [] 147 | for library in server_libraries: 148 | skip_reason = check_skip_logic( 149 | library, 150 | server_libraries[library], 151 | blacklist_library, 152 | whitelist_library, 153 | blacklist_library_type, 154 | whitelist_library_type, 155 | library_mapping, 156 | ) 157 | 158 | if skip_reason: 159 | logger.info(f"Skipping library {library}: {skip_reason}") 160 | continue 161 | 162 | filtered_libaries.append(library) 163 | 164 | return filtered_libaries 165 | 166 | 167 | def setup_libraries( 168 | server_1: Plex | Jellyfin | Emby, 169 | server_2: Plex | Jellyfin | Emby, 170 | blacklist_library: list[str], 171 | blacklist_library_type: list[str], 172 | whitelist_library: list[str], 173 | whitelist_library_type: list[str], 174 | library_mapping: dict[str, str] | None = None, 175 | ) -> tuple[list[str], list[str]]: 176 | server_1_libraries = server_1.get_libraries() 177 | server_2_libraries = server_2.get_libraries() 178 | 179 | logger.debug(f"{server_1.server_type}: Libraries and types {server_1_libraries}") 180 | logger.debug(f"{server_2.server_type}: Libraries and types {server_2_libraries}") 181 | 182 | # Filter out all blacklist, whitelist libaries 183 | filtered_server_1_libraries = filter_libaries( 184 | server_1_libraries, 185 | blacklist_library, 186 | blacklist_library_type, 187 | whitelist_library, 188 | whitelist_library_type, 189 | library_mapping, 190 | ) 191 | filtered_server_2_libraries = filter_libaries( 192 | server_2_libraries, 193 | blacklist_library, 194 | blacklist_library_type, 195 | whitelist_library, 196 | whitelist_library_type, 197 | library_mapping, 198 | ) 199 | 200 | output_server_1_libaries = match_list( 201 | filtered_server_1_libraries, filtered_server_2_libraries, library_mapping 202 | ) 203 | output_server_2_libaries = match_list( 204 | filtered_server_2_libraries, filtered_server_1_libraries, library_mapping 205 | ) 206 | 207 | return output_server_1_libaries, output_server_2_libaries 208 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import traceback 3 | import json 4 | import sys 5 | from dotenv import load_dotenv 6 | from time import sleep, perf_counter 7 | from loguru import logger 8 | 9 | from src.emby import Emby 10 | from src.jellyfin import Jellyfin 11 | from src.plex import Plex 12 | from src.library import setup_libraries 13 | from src.functions import ( 14 | parse_string_to_list, 15 | str_to_bool, 16 | ) 17 | from src.users import setup_users 18 | from src.watched import ( 19 | cleanup_watched, 20 | ) 21 | from src.black_white import setup_black_white_lists 22 | from src.connection import generate_server_connections 23 | 24 | load_dotenv(override=True) 25 | 26 | log_file = os.getenv("LOG_FILE", os.getenv("LOGFILE", "log.log")) 27 | level = os.getenv("DEBUG_LEVEL", "INFO").upper() 28 | 29 | 30 | def configure_logger() -> None: 31 | # Remove default logger to configure our own 32 | logger.remove() 33 | 34 | # Choose log level based on environment 35 | # If in debug mode with a "debug" level, use DEBUG; otherwise, default to INFO. 36 | 37 | if level not in ["INFO", "DEBUG", "TRACE"]: 38 | logger.add(sys.stdout) 39 | raise Exception("Invalid DEBUG_LEVEL, please choose between INFO, DEBUG, TRACE") 40 | 41 | # Add a sink for file logging and the console. 42 | logger.add(log_file, level=level, mode="w") 43 | logger.add(sys.stdout, level=level) 44 | 45 | 46 | def should_sync_server( 47 | server_1: Plex | Jellyfin | Emby, 48 | server_2: Plex | Jellyfin | Emby, 49 | ) -> bool: 50 | sync_from_plex_to_jellyfin = str_to_bool( 51 | os.getenv("SYNC_FROM_PLEX_TO_JELLYFIN", "True") 52 | ) 53 | sync_from_plex_to_plex = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_PLEX", "True")) 54 | sync_from_plex_to_emby = str_to_bool(os.getenv("SYNC_FROM_PLEX_TO_EMBY", "True")) 55 | 56 | sync_from_jelly_to_plex = str_to_bool( 57 | os.getenv("SYNC_FROM_JELLYFIN_TO_PLEX", "True") 58 | ) 59 | sync_from_jelly_to_jellyfin = str_to_bool( 60 | os.getenv("SYNC_FROM_JELLYFIN_TO_JELLYFIN", "True") 61 | ) 62 | sync_from_jelly_to_emby = str_to_bool( 63 | os.getenv("SYNC_FROM_JELLYFIN_TO_EMBY", "True") 64 | ) 65 | 66 | sync_from_emby_to_plex = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_PLEX", "True")) 67 | sync_from_emby_to_jellyfin = str_to_bool( 68 | os.getenv("SYNC_FROM_EMBY_TO_JELLYFIN", "True") 69 | ) 70 | sync_from_emby_to_emby = str_to_bool(os.getenv("SYNC_FROM_EMBY_TO_EMBY", "True")) 71 | 72 | if isinstance(server_1, Plex): 73 | if isinstance(server_2, Jellyfin) and not sync_from_plex_to_jellyfin: 74 | logger.info("Sync from plex -> jellyfin is disabled") 75 | return False 76 | 77 | if isinstance(server_2, Emby) and not sync_from_plex_to_emby: 78 | logger.info("Sync from plex -> emby is disabled") 79 | return False 80 | 81 | if isinstance(server_2, Plex) and not sync_from_plex_to_plex: 82 | logger.info("Sync from plex -> plex is disabled") 83 | return False 84 | 85 | if isinstance(server_1, Jellyfin): 86 | if isinstance(server_2, Plex) and not sync_from_jelly_to_plex: 87 | logger.info("Sync from jellyfin -> plex is disabled") 88 | return False 89 | 90 | if isinstance(server_2, Jellyfin) and not sync_from_jelly_to_jellyfin: 91 | logger.info("Sync from jellyfin -> jellyfin is disabled") 92 | return False 93 | 94 | if isinstance(server_2, Emby) and not sync_from_jelly_to_emby: 95 | logger.info("Sync from jellyfin -> emby is disabled") 96 | return False 97 | 98 | if isinstance(server_1, Emby): 99 | if isinstance(server_2, Plex) and not sync_from_emby_to_plex: 100 | logger.info("Sync from emby -> plex is disabled") 101 | return False 102 | 103 | if isinstance(server_2, Jellyfin) and not sync_from_emby_to_jellyfin: 104 | logger.info("Sync from emby -> jellyfin is disabled") 105 | return False 106 | 107 | if isinstance(server_2, Emby) and not sync_from_emby_to_emby: 108 | logger.info("Sync from emby -> emby is disabled") 109 | return False 110 | 111 | return True 112 | 113 | 114 | def main_loop() -> None: 115 | dryrun = str_to_bool(os.getenv("DRYRUN", "False")) 116 | logger.info(f"Dryrun: {dryrun}") 117 | 118 | user_mapping_env = os.getenv("USER_MAPPING", None) 119 | user_mapping = None 120 | if user_mapping_env: 121 | user_mapping = json.loads(user_mapping_env.lower()) 122 | logger.info(f"User Mapping: {user_mapping}") 123 | 124 | library_mapping_env = os.getenv("LIBRARY_MAPPING", None) 125 | library_mapping = None 126 | if library_mapping_env: 127 | library_mapping = json.loads(library_mapping_env) 128 | logger.info(f"Library Mapping: {library_mapping}") 129 | 130 | # Create (black/white)lists 131 | logger.info("Creating (black/white)lists") 132 | blacklist_library = parse_string_to_list(os.getenv("BLACKLIST_LIBRARY", None)) 133 | whitelist_library = parse_string_to_list(os.getenv("WHITELIST_LIBRARY", None)) 134 | blacklist_library_type = parse_string_to_list( 135 | os.getenv("BLACKLIST_LIBRARY_TYPE", None) 136 | ) 137 | whitelist_library_type = parse_string_to_list( 138 | os.getenv("WHITELIST_LIBRARY_TYPE", None) 139 | ) 140 | blacklist_users = parse_string_to_list(os.getenv("BLACKLIST_USERS", None)) 141 | whitelist_users = parse_string_to_list(os.getenv("WHITELIST_USERS", None)) 142 | 143 | ( 144 | blacklist_library, 145 | whitelist_library, 146 | blacklist_library_type, 147 | whitelist_library_type, 148 | blacklist_users, 149 | whitelist_users, 150 | ) = setup_black_white_lists( 151 | blacklist_library, 152 | whitelist_library, 153 | blacklist_library_type, 154 | whitelist_library_type, 155 | blacklist_users, 156 | whitelist_users, 157 | library_mapping, 158 | user_mapping, 159 | ) 160 | 161 | # Create server connections 162 | logger.info("Creating server connections") 163 | servers = generate_server_connections() 164 | 165 | for server_1 in servers: 166 | # If server is the final server in the list, then we are done with the loop 167 | if server_1 == servers[-1]: 168 | break 169 | 170 | # Start server_2 at the next server in the list 171 | for server_2 in servers[servers.index(server_1) + 1 :]: 172 | # Check if server 1 and server 2 are going to be synced in either direction, skip if not 173 | if not should_sync_server(server_1, server_2) and not should_sync_server( 174 | server_2, server_1 175 | ): 176 | continue 177 | 178 | logger.info(f"Server 1: {type(server_1)}: {server_1.info()}") 179 | logger.info(f"Server 2: {type(server_2)}: {server_2.info()}") 180 | 181 | # Create users list 182 | logger.info("Creating users list") 183 | server_1_users, server_2_users = setup_users( 184 | server_1, server_2, blacklist_users, whitelist_users, user_mapping 185 | ) 186 | 187 | server_1_libraries, server_2_libraries = setup_libraries( 188 | server_1, 189 | server_2, 190 | blacklist_library, 191 | blacklist_library_type, 192 | whitelist_library, 193 | whitelist_library_type, 194 | library_mapping, 195 | ) 196 | logger.info(f"Server 1 syncing libraries: {server_1_libraries}") 197 | logger.info(f"Server 2 syncing libraries: {server_2_libraries}") 198 | 199 | logger.info("Creating watched lists", 1) 200 | server_1_watched = server_1.get_watched(server_1_users, server_1_libraries) 201 | logger.info("Finished creating watched list server 1") 202 | 203 | server_2_watched = server_2.get_watched(server_2_users, server_2_libraries) 204 | logger.info("Finished creating watched list server 2") 205 | 206 | logger.trace(f"Server 1 watched: {server_1_watched}") 207 | logger.trace(f"Server 2 watched: {server_2_watched}") 208 | 209 | logger.info("Cleaning Server 1 Watched", 1) 210 | server_1_watched_filtered = cleanup_watched( 211 | server_1_watched, server_2_watched, user_mapping, library_mapping 212 | ) 213 | 214 | logger.info("Cleaning Server 2 Watched", 1) 215 | server_2_watched_filtered = cleanup_watched( 216 | server_2_watched, server_1_watched, user_mapping, library_mapping 217 | ) 218 | 219 | logger.debug( 220 | f"server 1 watched that needs to be synced to server 2:\n{server_1_watched_filtered}", 221 | ) 222 | logger.debug( 223 | f"server 2 watched that needs to be synced to server 1:\n{server_2_watched_filtered}", 224 | ) 225 | 226 | if should_sync_server(server_2, server_1): 227 | logger.info(f"Syncing {server_2.info()} -> {server_1.info()}") 228 | server_1.update_watched( 229 | server_2_watched_filtered, 230 | user_mapping, 231 | library_mapping, 232 | dryrun, 233 | ) 234 | 235 | if should_sync_server(server_1, server_2): 236 | logger.info(f"Syncing {server_1.info()} -> {server_2.info()}") 237 | server_2.update_watched( 238 | server_1_watched_filtered, 239 | user_mapping, 240 | library_mapping, 241 | dryrun, 242 | ) 243 | 244 | 245 | @logger.catch 246 | def main() -> None: 247 | run_only_once = str_to_bool(os.getenv("RUN_ONLY_ONCE", "False")) 248 | sleep_duration = float(os.getenv("SLEEP_DURATION", "3600")) 249 | times: list[float] = [] 250 | while True: 251 | try: 252 | start = perf_counter() 253 | # Reconfigure the logger on each loop so the logs are rotated on each run 254 | configure_logger() 255 | main_loop() 256 | end = perf_counter() 257 | times.append(end - start) 258 | 259 | if len(times) > 0: 260 | logger.info(f"Average time: {sum(times) / len(times)}") 261 | 262 | if run_only_once: 263 | break 264 | 265 | logger.info(f"Looping in {sleep_duration}") 266 | sleep(sleep_duration) 267 | 268 | except Exception as error: 269 | if isinstance(error, list): 270 | for message in error: 271 | logger.error(message) 272 | else: 273 | logger.error(error) 274 | 275 | logger.error(traceback.format_exc()) 276 | 277 | if run_only_once: 278 | break 279 | 280 | logger.info(f"Retrying in {sleep_duration}") 281 | sleep(sleep_duration) 282 | 283 | except KeyboardInterrupt: 284 | if len(times) > 0: 285 | logger.info(f"Average time: {sum(times) / len(times)}") 286 | logger.info("Exiting") 287 | os._exit(0) 288 | -------------------------------------------------------------------------------- /src/plex.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from dotenv import load_dotenv 4 | from loguru import logger 5 | 6 | from urllib3.poolmanager import PoolManager 7 | from math import floor 8 | 9 | from requests.adapters import HTTPAdapter as RequestsHTTPAdapter 10 | 11 | from plexapi.video import Show, Episode, Movie 12 | from plexapi.server import PlexServer 13 | from plexapi.myplex import MyPlexAccount, MyPlexUser 14 | from plexapi.library import MovieSection, ShowSection 15 | 16 | from src.functions import ( 17 | search_mapping, 18 | log_marked, 19 | str_to_bool, 20 | ) 21 | from src.watched import ( 22 | LibraryData, 23 | MediaIdentifiers, 24 | MediaItem, 25 | WatchedStatus, 26 | Series, 27 | UserData, 28 | check_same_identifiers, 29 | ) 30 | 31 | load_dotenv(override=True) 32 | 33 | generate_guids = str_to_bool(os.getenv("GENERATE_GUIDS", "True")) 34 | generate_locations = str_to_bool(os.getenv("GENERATE_LOCATIONS", "True")) 35 | 36 | 37 | # Bypass hostname validation for ssl. Taken from https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186 38 | class HostNameIgnoringAdapter(RequestsHTTPAdapter): 39 | def init_poolmanager( 40 | self, connections: int, maxsize: int | None, block=..., **pool_kwargs 41 | ) -> None: 42 | self.poolmanager = PoolManager( 43 | num_pools=connections, 44 | maxsize=maxsize, 45 | block=block, 46 | assert_hostname=False, 47 | **pool_kwargs, 48 | ) 49 | 50 | 51 | def extract_guids_from_item(item: Movie | Show | Episode) -> dict[str, str]: 52 | # If GENERATE_GUIDS is set to False, then return an empty dict 53 | if not generate_guids: 54 | return {} 55 | 56 | guids: dict[str, str] = dict( 57 | guid.id.split("://") 58 | for guid in item.guids 59 | if guid.id and len(guid.id.strip()) > 0 60 | ) 61 | 62 | return guids 63 | 64 | 65 | def extract_identifiers_from_item(item: Movie | Show | Episode) -> MediaIdentifiers: 66 | guids = extract_guids_from_item(item) 67 | 68 | return MediaIdentifiers( 69 | title=item.title, 70 | locations=( 71 | tuple([location.split("/")[-1] for location in item.locations]) 72 | if generate_locations 73 | else tuple() 74 | ), 75 | imdb_id=guids.get("imdb"), 76 | tvdb_id=guids.get("tvdb"), 77 | tmdb_id=guids.get("tmdb"), 78 | ) 79 | 80 | 81 | def get_mediaitem(item: Movie | Episode, completed: bool) -> MediaItem: 82 | return MediaItem( 83 | identifiers=extract_identifiers_from_item(item), 84 | status=WatchedStatus(completed=completed, time=item.viewOffset), 85 | ) 86 | 87 | 88 | def update_user_watched( 89 | user: MyPlexAccount, 90 | user_plex: PlexServer, 91 | library_data: LibraryData, 92 | library_name: str, 93 | dryrun: bool, 94 | ) -> None: 95 | # If there are no movies or shows to update, exit early. 96 | if not library_data.series and not library_data.movies: 97 | return 98 | 99 | logger.info(f"Plex: Updating watched for {user.title} in library {library_name}") 100 | library_section = user_plex.library.section(library_name) 101 | if not library_section: 102 | logger.error( 103 | f"Plex: Library {library_name} not found for {user.title}, skipping", 104 | ) 105 | return 106 | 107 | # Update movies. 108 | if library_data.movies: 109 | # Search for Plex movies that are currently marked as unwatched. 110 | for plex_movie in library_section.search(unwatched=True): 111 | plex_identifiers = extract_identifiers_from_item(plex_movie) 112 | # Check each stored movie for a match. 113 | for stored_movie in library_data.movies: 114 | if check_same_identifiers(plex_identifiers, stored_movie.identifiers): 115 | # If the stored movie is marked as watched (or has enough progress), 116 | # update the Plex movie accordingly. 117 | if stored_movie.status.completed: 118 | msg = f"Plex: {plex_movie.title} as watched for {user.title} in {library_name}" 119 | if not dryrun: 120 | try: 121 | plex_movie.markWatched() 122 | except Exception as e: 123 | logger.error( 124 | f"Plex: Failed to mark {plex_movie.title} as watched, Error: {e}" 125 | ) 126 | continue 127 | 128 | logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}") 129 | log_marked( 130 | "Plex", 131 | user_plex.friendlyName, 132 | user.title, 133 | library_name, 134 | plex_movie.title, 135 | None, 136 | None, 137 | ) 138 | else: 139 | msg = f"Plex: {plex_movie.title} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user.title} in {library_name}" 140 | if not dryrun: 141 | try: 142 | plex_movie.updateTimeline(stored_movie.status.time) 143 | except Exception as e: 144 | logger.error( 145 | f"Plex: Failed to update {plex_movie.title} timeline, Error: {e}" 146 | ) 147 | continue 148 | 149 | logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}") 150 | log_marked( 151 | "Plex", 152 | user_plex.friendlyName, 153 | user.title, 154 | library_name, 155 | plex_movie.title, 156 | duration=stored_movie.status.time, 157 | ) 158 | # Once matched, no need to check further. 159 | break 160 | 161 | # Update TV Shows (series/episodes). 162 | if library_data.series: 163 | # For each Plex show in the library section: 164 | plex_shows = library_section.search(unwatched=True) 165 | for plex_show in plex_shows: 166 | # Extract identifiers from the Plex show. 167 | plex_show_identifiers = extract_identifiers_from_item(plex_show) 168 | # Try to find a matching series in your stored library. 169 | for stored_series in library_data.series: 170 | if check_same_identifiers( 171 | plex_show_identifiers, stored_series.identifiers 172 | ): 173 | logger.trace(f"Found matching show for '{plex_show.title}'") 174 | # Now update episodes. 175 | # Get the list of Plex episodes for this show. 176 | plex_episodes = plex_show.episodes() 177 | for plex_episode in plex_episodes: 178 | plex_episode_identifiers = extract_identifiers_from_item( 179 | plex_episode 180 | ) 181 | for stored_ep in stored_series.episodes: 182 | if check_same_identifiers( 183 | plex_episode_identifiers, stored_ep.identifiers 184 | ): 185 | if stored_ep.status.completed: 186 | msg = f"Plex: {plex_show.title} {plex_episode.title} as watched for {user.title} in {library_name}" 187 | if not dryrun: 188 | try: 189 | plex_episode.markWatched() 190 | except Exception as e: 191 | logger.error( 192 | f"Plex: Failed to mark {plex_show.title} {plex_episode.title} as watched, Error: {e}" 193 | ) 194 | continue 195 | 196 | logger.success( 197 | f"{'[DRYRUN] ' if dryrun else ''}{msg}" 198 | ) 199 | log_marked( 200 | "Plex", 201 | user_plex.friendlyName, 202 | user.title, 203 | library_name, 204 | plex_show.title, 205 | plex_episode.title, 206 | ) 207 | else: 208 | 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}" 209 | if not dryrun: 210 | try: 211 | plex_episode.updateTimeline( 212 | stored_ep.status.time 213 | ) 214 | except Exception as e: 215 | logger.error( 216 | f"Plex: Failed to update {plex_show.title} {plex_episode.title} timeline, Error: {e}" 217 | ) 218 | continue 219 | 220 | logger.success( 221 | f"{'[DRYRUN] ' if dryrun else ''}{msg}" 222 | ) 223 | log_marked( 224 | "Plex", 225 | user_plex.friendlyName, 226 | user.title, 227 | library_name, 228 | plex_show.title, 229 | plex_episode.title, 230 | stored_ep.status.time, 231 | ) 232 | break # Found a matching episode. 233 | break # Found a matching show. 234 | 235 | 236 | # class plex accept base url and token and username and password but default with none 237 | class Plex: 238 | def __init__( 239 | self, 240 | base_url: str | None = None, 241 | token: str | None = None, 242 | user_name: str | None = None, 243 | password: str | None = None, 244 | server_name: str | None = None, 245 | ssl_bypass: bool = False, 246 | session: requests.Session | None = None, 247 | ) -> None: 248 | self.server_type: str = "Plex" 249 | self.ssl_bypass: bool = ssl_bypass 250 | if ssl_bypass: 251 | # Session for ssl bypass 252 | session = requests.Session() 253 | # By pass ssl hostname check https://github.com/pkkid/python-plexapi/issues/143#issuecomment-775485186 254 | session.mount("https://", HostNameIgnoringAdapter()) 255 | self.session = session 256 | self.plex: PlexServer = self.login( 257 | base_url, token, user_name, password, server_name 258 | ) 259 | 260 | self.base_url: str = self.plex._baseurl 261 | 262 | self.admin_user: MyPlexAccount = self.plex.myPlexAccount() 263 | self.users: list[MyPlexUser | MyPlexAccount] = self.get_users() 264 | 265 | def login( 266 | self, 267 | base_url: str | None, 268 | token: str | None, 269 | user_name: str | None, 270 | password: str | None, 271 | server_name: str | None, 272 | ) -> PlexServer: 273 | try: 274 | if base_url and token: 275 | plex: PlexServer = PlexServer(base_url, token, session=self.session) 276 | elif user_name and password and server_name: 277 | # Login via plex account 278 | account = MyPlexAccount(user_name, password) 279 | plex = account.resource(server_name).connect() 280 | else: 281 | raise Exception("No complete plex credentials provided") 282 | 283 | return plex 284 | except Exception as e: 285 | if user_name: 286 | msg = f"Failed to login via plex account {user_name}" 287 | logger.error(f"Plex: Failed to login, {msg}, Error: {e}") 288 | else: 289 | logger.error(f"Plex: Failed to login, Error: {e}") 290 | raise Exception(e) 291 | 292 | def info(self) -> str: 293 | return f"Plex {self.plex.friendlyName}: {self.plex.version}" 294 | 295 | def get_users(self) -> list[MyPlexUser | MyPlexAccount]: 296 | try: 297 | users: list[MyPlexUser | MyPlexAccount] = self.plex.myPlexAccount().users() 298 | 299 | # append self to users 300 | users.append(self.plex.myPlexAccount()) 301 | 302 | return users 303 | except Exception as e: 304 | logger.error(f"Plex: Failed to get users, Error: {e}") 305 | raise Exception(e) 306 | 307 | def get_libraries(self) -> dict[str, str]: 308 | try: 309 | output = {} 310 | 311 | libraries = self.plex.library.sections() 312 | logger.debug( 313 | f"Plex: All Libraries {[library.title for library in libraries]}" 314 | ) 315 | 316 | for library in libraries: 317 | library_title = library.title 318 | library_type = library.type 319 | 320 | if library_type not in ["movie", "show"]: 321 | logger.debug( 322 | f"Plex: Skipping Library {library_title} found type {library_type}", 323 | ) 324 | continue 325 | 326 | output[library_title] = library_type 327 | 328 | return output 329 | except Exception as e: 330 | logger.error(f"Plex: Failed to get libraries, Error: {e}") 331 | raise Exception(e) 332 | 333 | def get_user_library_watched( 334 | self, user_name: str, user_plex: PlexServer, library: MovieSection | ShowSection 335 | ) -> LibraryData: 336 | try: 337 | logger.info( 338 | f"Plex: Generating watched for {user_name} in library {library.title}", 339 | ) 340 | watched = LibraryData(title=library.title) 341 | 342 | library_videos = user_plex.library.section(library.title) 343 | 344 | if library.type == "movie": 345 | for video in library_videos.search( 346 | unwatched=False 347 | ) + library_videos.search(inProgress=True): 348 | if video.isWatched or video.viewOffset >= 60000: 349 | watched.movies.append(get_mediaitem(video, video.isWatched)) 350 | 351 | elif library.type == "show": 352 | # Keep track of processed shows to reduce duplicate shows 353 | processed_shows = [] 354 | for show in library_videos.search( 355 | unwatched=False 356 | ) + library_videos.search(inProgress=True): 357 | if show.key in processed_shows: 358 | continue 359 | processed_shows.append(show.key) 360 | show_guids = extract_guids_from_item(show) 361 | episode_mediaitem = [] 362 | 363 | # Fetch watched or partially watched episodes 364 | for episode in show.watched() + show.episodes( 365 | viewOffset__gte=60_000 366 | ): 367 | episode_mediaitem.append( 368 | get_mediaitem(episode, episode.isWatched) 369 | ) 370 | 371 | if episode_mediaitem: 372 | watched.series.append( 373 | Series( 374 | identifiers=MediaIdentifiers( 375 | title=show.title, 376 | locations=( 377 | tuple( 378 | [ 379 | location.split("/")[-1] 380 | for location in show.locations 381 | ] 382 | ) 383 | if generate_locations 384 | else tuple() 385 | ), 386 | imdb_id=show_guids.get("imdb"), 387 | tvdb_id=show_guids.get("tvdb"), 388 | tmdb_id=show_guids.get("tmdb"), 389 | ), 390 | episodes=episode_mediaitem, 391 | ) 392 | ) 393 | 394 | return watched 395 | 396 | except Exception as e: 397 | logger.error( 398 | f"Plex: Failed to get watched for {user_name} in library {library.title}, Error: {e}", 399 | ) 400 | return LibraryData(title=library.title) 401 | 402 | def get_watched( 403 | self, users: list[MyPlexUser | MyPlexAccount], sync_libraries: list[str] 404 | ) -> dict[str, UserData]: 405 | try: 406 | users_watched: dict[str, UserData] = {} 407 | 408 | for user in users: 409 | if self.admin_user == user: 410 | user_plex = self.plex 411 | else: 412 | token = user.get_token(self.plex.machineIdentifier) 413 | if token: 414 | user_plex = self.login(self.base_url, token, None, None, None) 415 | else: 416 | logger.error( 417 | f"Plex: Failed to get token for {user.title}, skipping", 418 | ) 419 | continue 420 | 421 | user_name: str = ( 422 | user.username.lower() if user.username else user.title.lower() 423 | ) 424 | 425 | libraries = user_plex.library.sections() 426 | 427 | for library in libraries: 428 | if library.title not in sync_libraries: 429 | continue 430 | 431 | library_data = self.get_user_library_watched( 432 | user_name, user_plex, library 433 | ) 434 | 435 | if user_name not in users_watched: 436 | users_watched[user_name] = UserData() 437 | 438 | users_watched[user_name].libraries[library.title] = library_data 439 | 440 | return users_watched 441 | except Exception as e: 442 | logger.error(f"Plex: Failed to get watched, Error: {e}") 443 | return {} 444 | 445 | def update_watched( 446 | self, 447 | watched_list: dict[str, UserData], 448 | user_mapping: dict[str, str] | None = None, 449 | library_mapping: dict[str, str] | None = None, 450 | dryrun: bool = False, 451 | ) -> None: 452 | for user, user_data in watched_list.items(): 453 | user_other = None 454 | # If type of user is dict 455 | if user_mapping: 456 | user_other = search_mapping(user_mapping, user) 457 | 458 | for index, value in enumerate(self.users): 459 | username_title = ( 460 | value.username.lower() if value.username else value.title.lower() 461 | ) 462 | 463 | if user.lower() == username_title: 464 | user = self.users[index] 465 | break 466 | elif user_other and user_other.lower() == username_title: 467 | user = self.users[index] 468 | break 469 | 470 | if self.admin_user == user: 471 | user_plex = self.plex 472 | else: 473 | if isinstance(user, str): 474 | logger.debug( 475 | f"Plex: {user} is not a plex object, attempting to get object for user", 476 | ) 477 | user = self.plex.myPlexAccount().user(user) 478 | 479 | if not isinstance(user, MyPlexUser): 480 | logger.error(f"Plex: {user} failed to get PlexUser") 481 | continue 482 | 483 | token = user.get_token(self.plex.machineIdentifier) 484 | if token: 485 | user_plex = PlexServer( 486 | self.base_url, 487 | token, 488 | session=self.session, 489 | ) 490 | else: 491 | logger.error( 492 | f"Plex: Failed to get token for {user.title}, skipping", 493 | ) 494 | continue 495 | 496 | if not user_plex: 497 | logger.error(f"Plex: {user} Failed to get PlexServer") 498 | continue 499 | 500 | for library_name in user_data.libraries: 501 | library_data = user_data.libraries[library_name] 502 | library_other = None 503 | if library_mapping: 504 | library_other = search_mapping(library_mapping, library_name) 505 | # if library in plex library list 506 | library_list = user_plex.library.sections() 507 | if library_name.lower() not in [x.title.lower() for x in library_list]: 508 | if library_other: 509 | if library_other.lower() in [ 510 | x.title.lower() for x in library_list 511 | ]: 512 | logger.info( 513 | f"Plex: Library {library_name} not found, but {library_other} found, using {library_other}", 514 | ) 515 | library_name = library_other 516 | else: 517 | logger.info( 518 | f"Plex: Library {library_name} or {library_other} not found in library list", 519 | ) 520 | continue 521 | else: 522 | logger.info( 523 | f"Plex: Library {library_name} not found in library list", 524 | ) 525 | continue 526 | 527 | try: 528 | update_user_watched( 529 | user, 530 | user_plex, 531 | library_data, 532 | library_name, 533 | dryrun, 534 | ) 535 | except Exception as e: 536 | logger.error( 537 | f"Plex: Failed to update watched for {user.title} in {library_name}, Error: {e}", 538 | ) 539 | continue 540 | -------------------------------------------------------------------------------- /src/users.py: -------------------------------------------------------------------------------- 1 | from plexapi.myplex import MyPlexAccount, MyPlexUser 2 | from loguru import logger 3 | 4 | from src.emby import Emby 5 | from src.jellyfin import Jellyfin 6 | from src.plex import Plex 7 | from src.functions import search_mapping 8 | 9 | 10 | def generate_user_list(server: Plex | Jellyfin | Emby) -> list[str]: 11 | # generate list of users from server 1 and server 2 12 | 13 | server_users: list[str] = [] 14 | if isinstance(server, Plex): 15 | for user in server.users: 16 | server_users.append( 17 | user.username.lower() if user.username else user.title.lower() 18 | ) 19 | 20 | elif isinstance(server, (Jellyfin, Emby)): 21 | server_users = [key.lower() for key in server.users.keys()] 22 | 23 | return server_users 24 | 25 | 26 | def combine_user_lists( 27 | server_1_users: list[str], 28 | server_2_users: list[str], 29 | user_mapping: dict[str, str] | None, 30 | ) -> dict[str, str]: 31 | # combined list of overlapping users from plex and jellyfin 32 | users: dict[str, str] = {} 33 | 34 | for server_1_user in server_1_users: 35 | if user_mapping: 36 | mapped_user = search_mapping(user_mapping, server_1_user) 37 | if mapped_user in server_2_users: 38 | users[server_1_user] = mapped_user 39 | continue 40 | 41 | if server_1_user in server_2_users: 42 | users[server_1_user] = server_1_user 43 | 44 | for server_2_user in server_2_users: 45 | if user_mapping: 46 | mapped_user = search_mapping(user_mapping, server_2_user) 47 | if mapped_user in server_1_users: 48 | users[mapped_user] = server_2_user 49 | continue 50 | 51 | if server_2_user in server_1_users: 52 | users[server_2_user] = server_2_user 53 | 54 | return users 55 | 56 | 57 | def filter_user_lists( 58 | users: dict[str, str], blacklist_users: list[str], whitelist_users: list[str] 59 | ) -> dict[str, str]: 60 | users_filtered: dict[str, str] = {} 61 | for user in users: 62 | # whitelist_user is not empty and user lowercase is not in whitelist lowercase 63 | if len(whitelist_users) > 0: 64 | if user not in whitelist_users and users[user] not in whitelist_users: 65 | logger.info(f"{user} or {users[user]} is not in whitelist") 66 | continue 67 | 68 | if user not in blacklist_users and users[user] not in blacklist_users: 69 | users_filtered[user] = users[user] 70 | 71 | return users_filtered 72 | 73 | 74 | def generate_server_users( 75 | server: Plex | Jellyfin | Emby, 76 | users: dict[str, str], 77 | ) -> list[MyPlexAccount] | dict[str, str] | None: 78 | if isinstance(server, Plex): 79 | plex_server_users: list[MyPlexAccount] = [] 80 | for plex_user in server.users: 81 | username_title = ( 82 | plex_user.username if plex_user.username else plex_user.title 83 | ) 84 | 85 | if ( 86 | username_title.lower() in users.keys() 87 | or username_title.lower() in users.values() 88 | ): 89 | plex_server_users.append(plex_user) 90 | 91 | return plex_server_users 92 | elif isinstance(server, (Jellyfin, Emby)): 93 | jelly_emby_server_users: dict[str, str] = {} 94 | for jellyfin_user, jellyfin_id in server.users.items(): 95 | if ( 96 | jellyfin_user.lower() in users.keys() 97 | or jellyfin_user.lower() in users.values() 98 | ): 99 | jelly_emby_server_users[jellyfin_user] = jellyfin_id 100 | 101 | return jelly_emby_server_users 102 | 103 | return None 104 | 105 | 106 | def setup_users( 107 | server_1: Plex | Jellyfin | Emby, 108 | server_2: Plex | Jellyfin | Emby, 109 | blacklist_users: list[str], 110 | whitelist_users: list[str], 111 | user_mapping: dict[str, str] | None = None, 112 | ) -> tuple[ 113 | list[MyPlexAccount | MyPlexUser] | dict[str, str], 114 | list[MyPlexAccount | MyPlexUser] | dict[str, str], 115 | ]: 116 | server_1_users = generate_user_list(server_1) 117 | server_2_users = generate_user_list(server_2) 118 | logger.debug(f"Server 1 users: {server_1_users}") 119 | logger.debug(f"Server 2 users: {server_2_users}") 120 | 121 | users = combine_user_lists(server_1_users, server_2_users, user_mapping) 122 | logger.debug(f"User list that exist on both servers {users}") 123 | 124 | users_filtered = filter_user_lists(users, blacklist_users, whitelist_users) 125 | logger.debug(f"Filtered user list {users_filtered}") 126 | 127 | output_server_1_users = generate_server_users(server_1, users_filtered) 128 | output_server_2_users = generate_server_users(server_2, users_filtered) 129 | 130 | # Check if users is none or empty 131 | if output_server_1_users is None or len(output_server_1_users) == 0: 132 | logger.warning( 133 | 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}" 134 | ) 135 | 136 | if output_server_2_users is None or len(output_server_2_users) == 0: 137 | logger.warning( 138 | 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}" 139 | ) 140 | 141 | if ( 142 | output_server_1_users is None 143 | or len(output_server_1_users) == 0 144 | or output_server_2_users is None 145 | or len(output_server_2_users) == 0 146 | ): 147 | raise Exception("No users found for one or both servers") 148 | 149 | logger.info(f"Server 1 users: {output_server_1_users}") 150 | logger.info(f"Server 2 users: {output_server_2_users}") 151 | 152 | return output_server_1_users, output_server_2_users 153 | -------------------------------------------------------------------------------- /src/watched.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from pydantic import BaseModel, Field 3 | from loguru import logger 4 | from typing import Any 5 | 6 | from src.functions import search_mapping 7 | 8 | 9 | class MediaIdentifiers(BaseModel): 10 | title: str | None = None 11 | 12 | # File information, will be folder for series and media file for episode/movie 13 | locations: tuple[str, ...] = tuple() 14 | 15 | # Guids 16 | imdb_id: str | None = None 17 | tvdb_id: str | None = None 18 | tmdb_id: str | None = None 19 | 20 | 21 | class WatchedStatus(BaseModel): 22 | completed: bool 23 | time: int 24 | 25 | 26 | class MediaItem(BaseModel): 27 | identifiers: MediaIdentifiers 28 | status: WatchedStatus 29 | 30 | 31 | class Series(BaseModel): 32 | identifiers: MediaIdentifiers 33 | episodes: list[MediaItem] = Field(default_factory=list) 34 | 35 | 36 | class LibraryData(BaseModel): 37 | title: str 38 | movies: list[MediaItem] = Field(default_factory=list) 39 | series: list[Series] = Field(default_factory=list) 40 | 41 | 42 | class UserData(BaseModel): 43 | libraries: dict[str, LibraryData] = Field(default_factory=dict) 44 | 45 | 46 | def check_same_identifiers(item1: MediaIdentifiers, item2: MediaIdentifiers) -> bool: 47 | # Check for duplicate based on file locations: 48 | if item1.locations and item2.locations: 49 | if set(item1.locations) & set(item2.locations): 50 | return True 51 | 52 | # Check for duplicate based on GUIDs: 53 | if ( 54 | (item1.imdb_id and item2.imdb_id and item1.imdb_id == item2.imdb_id) 55 | or (item1.tvdb_id and item2.tvdb_id and item1.tvdb_id == item2.tvdb_id) 56 | or (item1.tmdb_id and item2.tmdb_id and item1.tmdb_id == item2.tmdb_id) 57 | ): 58 | return True 59 | 60 | return False 61 | 62 | 63 | def check_remove_entry(item1: MediaItem, item2: MediaItem) -> bool: 64 | """ 65 | Returns True if item1 (from watched_list_1) should be removed 66 | in favor of item2 (from watched_list_2), based on: 67 | - Duplicate criteria: 68 | * They match if any file location is shared OR 69 | at least one of imdb_id, tvdb_id, or tmdb_id matches. 70 | - Watched status: 71 | * If one is complete and the other is not, remove the incomplete one. 72 | * If both are incomplete, remove the one with lower progress (time). 73 | * If both are complete, remove item1 as duplicate. 74 | """ 75 | if not check_same_identifiers(item1.identifiers, item2.identifiers): 76 | return False 77 | 78 | # Compare watched statuses. 79 | status1 = item1.status 80 | status2 = item2.status 81 | 82 | # If one is complete and the other isn't, remove the one that's not complete. 83 | if status1.completed != status2.completed: 84 | if not status1.completed and status2.completed: 85 | return True # Remove item1 since it's not complete. 86 | else: 87 | return False # Do not remove item1; it's complete. 88 | 89 | # Both have the same completed status. 90 | if not status1.completed and not status2.completed: 91 | # Both incomplete: remove the one with lower progress (time) 92 | if status1.time < status2.time: 93 | return True # Remove item1 because it has watched less. 94 | elif status1.time > status2.time: 95 | return False # Keep item1 because it has more progress. 96 | else: 97 | # Same progress; Remove duplicate 98 | return True 99 | 100 | # If both are complete, consider item1 the duplicate and remove it. 101 | return True 102 | 103 | 104 | def cleanup_watched( 105 | watched_list_1: dict[str, UserData], 106 | watched_list_2: dict[str, UserData], 107 | user_mapping: dict[str, str] | None = None, 108 | library_mapping: dict[str, str] | None = None, 109 | ) -> dict[str, UserData]: 110 | modified_watched_list_1 = copy.deepcopy(watched_list_1) 111 | 112 | # remove entries from watched_list_1 that are in watched_list_2 113 | for user_1 in watched_list_1: 114 | user_other = None 115 | if user_mapping: 116 | user_other = search_mapping(user_mapping, user_1) 117 | user_2 = get_other(watched_list_2, user_1, user_other) 118 | if user_2 is None: 119 | continue 120 | 121 | for library_1_key in watched_list_1[user_1].libraries: 122 | library_other = None 123 | if library_mapping: 124 | library_other = search_mapping(library_mapping, library_1_key) 125 | library_2_key = get_other( 126 | watched_list_2[user_2].libraries, library_1_key, library_other 127 | ) 128 | if library_2_key is None: 129 | continue 130 | 131 | library_1 = watched_list_1[user_1].libraries[library_1_key] 132 | library_2 = watched_list_2[user_2].libraries[library_2_key] 133 | 134 | filtered_movies = [] 135 | for movie in library_1.movies: 136 | remove_flag = False 137 | for movie2 in library_2.movies: 138 | if check_remove_entry(movie, movie2): 139 | logger.trace(f"Removing movie: {movie.identifiers.title}") 140 | remove_flag = True 141 | break 142 | 143 | if not remove_flag: 144 | filtered_movies.append(movie) 145 | 146 | modified_watched_list_1[user_1].libraries[ 147 | library_1_key 148 | ].movies = filtered_movies 149 | 150 | # TV Shows 151 | filtered_series_list = [] 152 | for series1 in library_1.series: 153 | matching_series = None 154 | for series2 in library_2.series: 155 | if check_same_identifiers(series1.identifiers, series2.identifiers): 156 | matching_series = series2 157 | break 158 | 159 | if matching_series is None: 160 | # No matching show in watched_list_2; keep the series as is. 161 | filtered_series_list.append(series1) 162 | else: 163 | # We have a matching show; now clean up the episodes. 164 | filtered_episodes = [] 165 | for ep1 in series1.episodes: 166 | remove_flag = False 167 | for ep2 in matching_series.episodes: 168 | if check_remove_entry(ep1, ep2): 169 | logger.trace( 170 | f"Removing episode '{ep1.identifiers.title}' from show '{series1.identifiers.title}'", 171 | ) 172 | remove_flag = True 173 | break 174 | if not remove_flag: 175 | filtered_episodes.append(ep1) 176 | 177 | # Only keep the series if there are remaining episodes. 178 | if filtered_episodes: 179 | modified_series1 = copy.deepcopy(series1) 180 | modified_series1.episodes = filtered_episodes 181 | filtered_series_list.append(modified_series1) 182 | else: 183 | logger.trace( 184 | f"Removing entire show '{series1.identifiers.title}' as no episodes remain after cleanup.", 185 | ) 186 | modified_watched_list_1[user_1].libraries[ 187 | library_1_key 188 | ].series = filtered_series_list 189 | 190 | # After processing, remove any library that is completely empty. 191 | for user, user_data in modified_watched_list_1.items(): 192 | new_libraries = {} 193 | for lib_key, library in user_data.libraries.items(): 194 | if library.movies or library.series: 195 | new_libraries[lib_key] = library 196 | else: 197 | logger.trace(f"Removing empty library '{lib_key}' for user '{user}'") 198 | user_data.libraries = new_libraries 199 | 200 | return modified_watched_list_1 201 | 202 | 203 | def get_other( 204 | watched_list: dict[str, Any], object_1: str, object_2: str | None 205 | ) -> str | None: 206 | if object_1 in watched_list: 207 | return object_1 208 | 209 | if object_2 and object_2 in watched_list: 210 | return object_2 211 | 212 | logger.info( 213 | f"{object_1}{' and ' + object_2 if object_2 else ''} not found in watched list 2" 214 | ) 215 | 216 | return None 217 | -------------------------------------------------------------------------------- /test/ci_emby.env: -------------------------------------------------------------------------------- 1 | # Global Settings 2 | 3 | ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. 4 | DRYRUN = "True" 5 | 6 | ## Debugging level, "info" is default, "debug" is more verbose 7 | DEBUG_LEVEL = "trace" 8 | 9 | ## If set to true then the script will only run once and then exit 10 | RUN_ONLY_ONCE = "True" 11 | 12 | ## How often to run the script in seconds 13 | SLEEP_DURATION = 10 14 | 15 | ## Log file where all output will be written to 16 | LOG_FILE = "log.log" 17 | 18 | ## Mark file where all shows/movies that have been marked as played will be written to 19 | MARK_FILE = "mark.log" 20 | 21 | ## Timeout for requests for jellyfin 22 | REQUEST_TIMEOUT = 300 23 | 24 | ## Max threads for processing 25 | MAX_THREADS = 2 26 | 27 | ## Generate guids 28 | ## Generating guids is a slow process, so this is a way to speed up the process 29 | # by using the location only, useful when using same files on multiple servers 30 | GENERATE_GUIDS = "True" 31 | 32 | ## Generate locations 33 | ## Generating locations is a slow process, so this is a way to speed up the process 34 | ## by using the guid only, useful when using different files on multiple servers 35 | GENERATE_LOCATIONS = "True" 36 | 37 | ## Map usernames between servers in the event that they are different, order does not matter 38 | ## Comma seperated for multiple options 39 | USER_MAPPING = {"JellyUser":"jellyplex_watched"} 40 | 41 | ## Map libraries between servers in the even that they are different, order does not matter 42 | ## Comma seperated for multiple options 43 | LIBRARY_MAPPING = { "Shows": "TV Shows" } 44 | 45 | 46 | ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. 47 | ## Comma seperated for multiple options 48 | #BLACKLIST_LIBRARY = "" 49 | #WHITELIST_LIBRARY = "Movies" 50 | #BLACKLIST_LIBRARY_TYPE = "Series" 51 | #WHITELIST_LIBRARY_TYPE = "Movies, movie" 52 | #BLACKLIST_USERS = "" 53 | WHITELIST_USERS = "jellyplex_watched" 54 | 55 | 56 | 57 | # Plex 58 | 59 | ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers 60 | ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly 61 | ## Comma seperated list for multiple servers 62 | PLEX_BASEURL = "http://localhost:32400" 63 | 64 | ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 65 | ## Comma seperated list for multiple servers 66 | PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c" 67 | 68 | ## If not using plex token then use username and password of the server admin along with the servername 69 | ## Comma seperated for multiple options 70 | #PLEX_USERNAME = "PlexUser, PlexUser2" 71 | #PLEX_PASSWORD = "SuperSecret, SuperSecret2" 72 | #PLEX_SERVERNAME = "Plex Server1, Plex Server2" 73 | 74 | ## Skip hostname validation for ssl certificates. 75 | ## Set to True if running into ssl certificate errors 76 | SSL_BYPASS = "True" 77 | 78 | # Jellyfin 79 | 80 | ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly 81 | ## Comma seperated list for multiple servers 82 | JELLYFIN_BASEURL = "http://localhost:8096" 83 | 84 | ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key 85 | ## Comma seperated list for multiple servers 86 | JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" 87 | 88 | # Emby 89 | 90 | ## Emby server URL, use hostname or IP address if the hostname is not resolving correctly 91 | ## Comma seperated list for multiple servers 92 | EMBY_BASEURL = "http://localhost:8097" 93 | 94 | ## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key 95 | ## Comma seperated list for multiple servers 96 | EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" 97 | 98 | 99 | # Syncing Options 100 | 101 | ## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex 102 | ## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers 103 | SYNC_FROM_PLEX_TO_JELLYFIN = "False" 104 | SYNC_FROM_PLEX_TO_PLEX = "False" 105 | SYNC_FROM_PLEX_TO_EMBY = "False" 106 | 107 | SYNC_FROM_JELLYFIN_TO_PLEX = "False" 108 | SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False" 109 | SYNC_FROM_JELLYFIN_TO_EMBY = "False" 110 | 111 | SYNC_FROM_EMBY_TO_PLEX = "True" 112 | SYNC_FROM_EMBY_TO_JELLYFIN = "True" 113 | SYNC_FROM_EMBY_TO_EMBY = "True" -------------------------------------------------------------------------------- /test/ci_guids.env: -------------------------------------------------------------------------------- 1 | # Global Settings 2 | 3 | ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. 4 | DRYRUN = "True" 5 | 6 | ## Debugging level, "info" is default, "debug" is more verbose 7 | DEBUG_LEVEL = "trace" 8 | 9 | ## If set to true then the script will only run once and then exit 10 | RUN_ONLY_ONCE = "True" 11 | 12 | ## How often to run the script in seconds 13 | SLEEP_DURATION = 10 14 | 15 | ## Log file where all output will be written to 16 | LOG_FILE = "log.log" 17 | 18 | ## Mark file where all shows/movies that have been marked as played will be written to 19 | MARK_FILE = "mark.log" 20 | 21 | ## Timeout for requests for jellyfin 22 | REQUEST_TIMEOUT = 300 23 | 24 | ## Max threads for processing 25 | MAX_THREADS = 2 26 | 27 | ## Generate guids 28 | ## Generating guids is a slow process, so this is a way to speed up the process 29 | # by using the location only, useful when using same files on multiple servers 30 | GENERATE_GUIDS = "True" 31 | 32 | ## Generate locations 33 | ## Generating locations is a slow process, so this is a way to speed up the process 34 | ## by using the guid only, useful when using different files on multiple servers 35 | GENERATE_LOCATIONS = "False" 36 | 37 | ## Map usernames between servers in the event that they are different, order does not matter 38 | ## Comma seperated for multiple options 39 | USER_MAPPING = {"JellyUser":"jellyplex_watched"} 40 | 41 | ## Map libraries between servers in the even that they are different, order does not matter 42 | ## Comma seperated for multiple options 43 | LIBRARY_MAPPING = { "Shows": "TV Shows" } 44 | 45 | 46 | ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. 47 | ## Comma seperated for multiple options 48 | #BLACKLIST_LIBRARY = "" 49 | #WHITELIST_LIBRARY = "Movies" 50 | #BLACKLIST_LIBRARY_TYPE = "Series" 51 | #WHITELIST_LIBRARY_TYPE = "Movies, movie" 52 | #BLACKLIST_USERS = "" 53 | WHITELIST_USERS = "jellyplex_watched" 54 | 55 | 56 | 57 | # Plex 58 | 59 | ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers 60 | ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly 61 | ## Comma seperated list for multiple servers 62 | PLEX_BASEURL = "http://localhost:32400" 63 | 64 | ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 65 | ## Comma seperated list for multiple servers 66 | PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c" 67 | 68 | ## If not using plex token then use username and password of the server admin along with the servername 69 | ## Comma seperated for multiple options 70 | #PLEX_USERNAME = "PlexUser, PlexUser2" 71 | #PLEX_PASSWORD = "SuperSecret, SuperSecret2" 72 | #PLEX_SERVERNAME = "Plex Server1, Plex Server2" 73 | 74 | ## Skip hostname validation for ssl certificates. 75 | ## Set to True if running into ssl certificate errors 76 | SSL_BYPASS = "True" 77 | 78 | # Jellyfin 79 | 80 | ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly 81 | ## Comma seperated list for multiple servers 82 | JELLYFIN_BASEURL = "http://localhost:8096" 83 | 84 | ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key 85 | ## Comma seperated list for multiple servers 86 | JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" 87 | 88 | # Emby 89 | 90 | ## Emby server URL, use hostname or IP address if the hostname is not resolving correctly 91 | ## Comma seperated list for multiple servers 92 | EMBY_BASEURL = "http://localhost:8097" 93 | 94 | ## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key 95 | ## Comma seperated list for multiple servers 96 | EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" 97 | 98 | 99 | # Syncing Options 100 | 101 | ## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex 102 | ## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers 103 | SYNC_FROM_PLEX_TO_JELLYFIN = "True" 104 | SYNC_FROM_PLEX_TO_PLEX = "True" 105 | SYNC_FROM_PLEX_TO_EMBY = "True" 106 | 107 | SYNC_FROM_JELLYFIN_TO_PLEX = "True" 108 | SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" 109 | SYNC_FROM_JELLYFIN_TO_EMBY = "True" 110 | 111 | SYNC_FROM_EMBY_TO_PLEX = "True" 112 | SYNC_FROM_EMBY_TO_JELLYFIN = "True" 113 | SYNC_FROM_EMBY_TO_EMBY = "True" -------------------------------------------------------------------------------- /test/ci_jellyfin.env: -------------------------------------------------------------------------------- 1 | # Global Settings 2 | 3 | ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. 4 | DRYRUN = "True" 5 | 6 | ## Debugging level, "info" is default, "debug" is more verbose 7 | DEBUG_LEVEL = "trace" 8 | 9 | ## If set to true then the script will only run once and then exit 10 | RUN_ONLY_ONCE = "True" 11 | 12 | ## How often to run the script in seconds 13 | SLEEP_DURATION = 10 14 | 15 | ## Log file where all output will be written to 16 | LOG_FILE = "log.log" 17 | 18 | ## Mark file where all shows/movies that have been marked as played will be written to 19 | MARK_FILE = "mark.log" 20 | 21 | ## Timeout for requests for jellyfin 22 | REQUEST_TIMEOUT = 300 23 | 24 | ## Max threads for processing 25 | MAX_THREADS = 2 26 | 27 | ## Generate guids 28 | ## Generating guids is a slow process, so this is a way to speed up the process 29 | # by using the location only, useful when using same files on multiple servers 30 | GENERATE_GUIDS = "True" 31 | 32 | ## Generate locations 33 | ## Generating locations is a slow process, so this is a way to speed up the process 34 | ## by using the guid only, useful when using different files on multiple servers 35 | GENERATE_LOCATIONS = "True" 36 | 37 | ## Map usernames between servers in the event that they are different, order does not matter 38 | ## Comma seperated for multiple options 39 | USER_MAPPING = {"JellyUser":"jellyplex_watched"} 40 | 41 | ## Map libraries between servers in the even that they are different, order does not matter 42 | ## Comma seperated for multiple options 43 | LIBRARY_MAPPING = { "Shows": "TV Shows" } 44 | 45 | 46 | ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. 47 | ## Comma seperated for multiple options 48 | #BLACKLIST_LIBRARY = "" 49 | #WHITELIST_LIBRARY = "Movies" 50 | #BLACKLIST_LIBRARY_TYPE = "Series" 51 | #WHITELIST_LIBRARY_TYPE = "Movies, movie" 52 | #BLACKLIST_USERS = "" 53 | WHITELIST_USERS = "jellyplex_watched" 54 | 55 | 56 | 57 | # Plex 58 | 59 | ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers 60 | ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly 61 | ## Comma seperated list for multiple servers 62 | PLEX_BASEURL = "http://localhost:32400" 63 | 64 | ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 65 | ## Comma seperated list for multiple servers 66 | PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c" 67 | 68 | ## If not using plex token then use username and password of the server admin along with the servername 69 | ## Comma seperated for multiple options 70 | #PLEX_USERNAME = "PlexUser, PlexUser2" 71 | #PLEX_PASSWORD = "SuperSecret, SuperSecret2" 72 | #PLEX_SERVERNAME = "Plex Server1, Plex Server2" 73 | 74 | ## Skip hostname validation for ssl certificates. 75 | ## Set to True if running into ssl certificate errors 76 | SSL_BYPASS = "True" 77 | 78 | # Jellyfin 79 | 80 | ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly 81 | ## Comma seperated list for multiple servers 82 | JELLYFIN_BASEURL = "http://localhost:8096" 83 | 84 | ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key 85 | ## Comma seperated list for multiple servers 86 | JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" 87 | 88 | # Emby 89 | 90 | ## Emby server URL, use hostname or IP address if the hostname is not resolving correctly 91 | ## Comma seperated list for multiple servers 92 | EMBY_BASEURL = "http://localhost:8097" 93 | 94 | ## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key 95 | ## Comma seperated list for multiple servers 96 | EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" 97 | 98 | 99 | # Syncing Options 100 | 101 | ## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex 102 | ## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers 103 | SYNC_FROM_PLEX_TO_JELLYFIN = "False" 104 | SYNC_FROM_PLEX_TO_PLEX = "False" 105 | SYNC_FROM_PLEX_TO_EMBY = "False" 106 | 107 | SYNC_FROM_JELLYFIN_TO_PLEX = "True" 108 | SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" 109 | SYNC_FROM_JELLYFIN_TO_EMBY = "True" 110 | 111 | SYNC_FROM_EMBY_TO_PLEX = "False" 112 | SYNC_FROM_EMBY_TO_JELLYFIN = "False" 113 | SYNC_FROM_EMBY_TO_EMBY = "False" -------------------------------------------------------------------------------- /test/ci_locations.env: -------------------------------------------------------------------------------- 1 | # Global Settings 2 | 3 | ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. 4 | DRYRUN = "True" 5 | 6 | ## Debugging level, "info" is default, "debug" is more verbose 7 | DEBUG_LEVEL = "trace" 8 | 9 | ## If set to true then the script will only run once and then exit 10 | RUN_ONLY_ONCE = "True" 11 | 12 | ## How often to run the script in seconds 13 | SLEEP_DURATION = 10 14 | 15 | ## Log file where all output will be written to 16 | LOG_FILE = "log.log" 17 | 18 | ## Mark file where all shows/movies that have been marked as played will be written to 19 | MARK_FILE = "mark.log" 20 | 21 | ## Timeout for requests for jellyfin 22 | REQUEST_TIMEOUT = 300 23 | 24 | ## Max threads for processing 25 | MAX_THREADS = 2 26 | 27 | ## Generate guids 28 | ## Generating guids is a slow process, so this is a way to speed up the process 29 | # by using the location only, useful when using same files on multiple servers 30 | GENERATE_GUIDS = "False" 31 | 32 | ## Generate locations 33 | ## Generating locations is a slow process, so this is a way to speed up the process 34 | ## by using the guid only, useful when using different files on multiple servers 35 | GENERATE_LOCATIONS = "True" 36 | 37 | ## Map usernames between servers in the event that they are different, order does not matter 38 | ## Comma seperated for multiple options 39 | USER_MAPPING = {"JellyUser":"jellyplex_watched"} 40 | 41 | ## Map libraries between servers in the even that they are different, order does not matter 42 | ## Comma seperated for multiple options 43 | LIBRARY_MAPPING = { "Shows": "TV Shows" } 44 | 45 | 46 | ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. 47 | ## Comma seperated for multiple options 48 | #BLACKLIST_LIBRARY = "" 49 | #WHITELIST_LIBRARY = "Movies" 50 | #BLACKLIST_LIBRARY_TYPE = "Series" 51 | #WHITELIST_LIBRARY_TYPE = "Movies, movie" 52 | #BLACKLIST_USERS = "" 53 | WHITELIST_USERS = "jellyplex_watched" 54 | 55 | 56 | 57 | # Plex 58 | 59 | ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers 60 | ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly 61 | ## Comma seperated list for multiple servers 62 | PLEX_BASEURL = "http://localhost:32400" 63 | 64 | ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 65 | ## Comma seperated list for multiple servers 66 | PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c" 67 | 68 | ## If not using plex token then use username and password of the server admin along with the servername 69 | ## Comma seperated for multiple options 70 | #PLEX_USERNAME = "PlexUser, PlexUser2" 71 | #PLEX_PASSWORD = "SuperSecret, SuperSecret2" 72 | #PLEX_SERVERNAME = "Plex Server1, Plex Server2" 73 | 74 | ## Skip hostname validation for ssl certificates. 75 | ## Set to True if running into ssl certificate errors 76 | SSL_BYPASS = "True" 77 | 78 | # Jellyfin 79 | 80 | ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly 81 | ## Comma seperated list for multiple servers 82 | JELLYFIN_BASEURL = "http://localhost:8096" 83 | 84 | ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key 85 | ## Comma seperated list for multiple servers 86 | JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" 87 | 88 | # Emby 89 | 90 | ## Emby server URL, use hostname or IP address if the hostname is not resolving correctly 91 | ## Comma seperated list for multiple servers 92 | EMBY_BASEURL = "http://localhost:8097" 93 | 94 | ## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key 95 | ## Comma seperated list for multiple servers 96 | EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" 97 | 98 | 99 | # Syncing Options 100 | 101 | ## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex 102 | ## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers 103 | SYNC_FROM_PLEX_TO_JELLYFIN = "True" 104 | SYNC_FROM_PLEX_TO_PLEX = "True" 105 | SYNC_FROM_PLEX_TO_EMBY = "True" 106 | 107 | SYNC_FROM_JELLYFIN_TO_PLEX = "True" 108 | SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" 109 | SYNC_FROM_JELLYFIN_TO_EMBY = "True" 110 | 111 | SYNC_FROM_EMBY_TO_PLEX = "True" 112 | SYNC_FROM_EMBY_TO_JELLYFIN = "True" 113 | SYNC_FROM_EMBY_TO_EMBY = "True" -------------------------------------------------------------------------------- /test/ci_plex.env: -------------------------------------------------------------------------------- 1 | # Global Settings 2 | 3 | ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. 4 | DRYRUN = "True" 5 | 6 | ## Debugging level, "info" is default, "debug" is more verbose 7 | DEBUG_LEVEL = "trace" 8 | 9 | ## If set to true then the script will only run once and then exit 10 | RUN_ONLY_ONCE = "True" 11 | 12 | ## How often to run the script in seconds 13 | SLEEP_DURATION = 10 14 | 15 | ## Log file where all output will be written to 16 | LOG_FILE = "log.log" 17 | 18 | ## Mark file where all shows/movies that have been marked as played will be written to 19 | MARK_FILE = "mark.log" 20 | 21 | ## Timeout for requests for jellyfin 22 | REQUEST_TIMEOUT = 300 23 | 24 | ## Max threads for processing 25 | MAX_THREADS = 2 26 | 27 | ## Generate guids 28 | ## Generating guids is a slow process, so this is a way to speed up the process 29 | # by using the location only, useful when using same files on multiple servers 30 | GENERATE_GUIDS = "True" 31 | 32 | ## Generate locations 33 | ## Generating locations is a slow process, so this is a way to speed up the process 34 | ## by using the guid only, useful when using different files on multiple servers 35 | GENERATE_LOCATIONS = "True" 36 | 37 | ## Map usernames between servers in the event that they are different, order does not matter 38 | ## Comma seperated for multiple options 39 | USER_MAPPING = {"JellyUser":"jellyplex_watched"} 40 | 41 | ## Map libraries between servers in the even that they are different, order does not matter 42 | ## Comma seperated for multiple options 43 | LIBRARY_MAPPING = { "Shows": "TV Shows" } 44 | 45 | 46 | ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. 47 | ## Comma seperated for multiple options 48 | #BLACKLIST_LIBRARY = "" 49 | #WHITELIST_LIBRARY = "Movies" 50 | #BLACKLIST_LIBRARY_TYPE = "Series" 51 | #WHITELIST_LIBRARY_TYPE = "Movies, movie" 52 | #BLACKLIST_USERS = "" 53 | WHITELIST_USERS = "jellyplex_watched" 54 | 55 | 56 | 57 | # Plex 58 | 59 | ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers 60 | ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly 61 | ## Comma seperated list for multiple servers 62 | PLEX_BASEURL = "http://localhost:32400" 63 | 64 | ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 65 | ## Comma seperated list for multiple servers 66 | PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c" 67 | 68 | ## If not using plex token then use username and password of the server admin along with the servername 69 | ## Comma seperated for multiple options 70 | #PLEX_USERNAME = "PlexUser, PlexUser2" 71 | #PLEX_PASSWORD = "SuperSecret, SuperSecret2" 72 | #PLEX_SERVERNAME = "Plex Server1, Plex Server2" 73 | 74 | ## Skip hostname validation for ssl certificates. 75 | ## Set to True if running into ssl certificate errors 76 | SSL_BYPASS = "True" 77 | 78 | # Jellyfin 79 | 80 | ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly 81 | ## Comma seperated list for multiple servers 82 | JELLYFIN_BASEURL = "http://localhost:8096" 83 | 84 | ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key 85 | ## Comma seperated list for multiple servers 86 | JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" 87 | 88 | # Emby 89 | 90 | ## Emby server URL, use hostname or IP address if the hostname is not resolving correctly 91 | ## Comma seperated list for multiple servers 92 | EMBY_BASEURL = "http://localhost:8097" 93 | 94 | ## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key 95 | ## Comma seperated list for multiple servers 96 | EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" 97 | 98 | 99 | # Syncing Options 100 | 101 | ## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex 102 | ## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers 103 | SYNC_FROM_PLEX_TO_JELLYFIN = "True" 104 | SYNC_FROM_PLEX_TO_PLEX = "True" 105 | SYNC_FROM_PLEX_TO_EMBY = "True" 106 | 107 | SYNC_FROM_JELLYFIN_TO_PLEX = "False" 108 | SYNC_FROM_JELLYFIN_TO_JELLYFIN = "False" 109 | SYNC_FROM_JELLYFIN_TO_EMBY = "False" 110 | 111 | SYNC_FROM_EMBY_TO_PLEX = "False" 112 | SYNC_FROM_EMBY_TO_JELLYFIN = "False" 113 | SYNC_FROM_EMBY_TO_EMBY = "False" -------------------------------------------------------------------------------- /test/ci_write.env: -------------------------------------------------------------------------------- 1 | # Global Settings 2 | 3 | ## Do not mark any shows/movies as played and instead just output to log if they would of been marked. 4 | DRYRUN = "False" 5 | 6 | ## Debugging level, "info" is default, "debug" is more verbose 7 | DEBUG_LEVEL = "trace" 8 | 9 | ## If set to true then the script will only run once and then exit 10 | RUN_ONLY_ONCE = "True" 11 | 12 | ## How often to run the script in seconds 13 | SLEEP_DURATION = 10 14 | 15 | ## Log file where all output will be written to 16 | LOG_FILE = "log.log" 17 | 18 | ## Mark file where all shows/movies that have been marked as played will be written to 19 | MARK_FILE = "mark.log" 20 | 21 | ## Timeout for requests for jellyfin 22 | REQUEST_TIMEOUT = 300 23 | 24 | ## Max threads for processing 25 | MAX_THREADS = 2 26 | 27 | ## Generate guids 28 | ## Generating guids is a slow process, so this is a way to speed up the process 29 | # by using the location only, useful when using same files on multiple servers 30 | GENERATE_GUIDS = "True" 31 | 32 | ## Generate locations 33 | ## Generating locations is a slow process, so this is a way to speed up the process 34 | ## by using the guid only, useful when using different files on multiple servers 35 | GENERATE_LOCATIONS = "True" 36 | 37 | ## Map usernames between servers in the event that they are different, order does not matter 38 | ## Comma seperated for multiple options 39 | USER_MAPPING = {"JellyUser":"jellyplex_watched"} 40 | 41 | ## Map libraries between servers in the even that they are different, order does not matter 42 | ## Comma seperated for multiple options 43 | LIBRARY_MAPPING = { "Shows": "TV Shows" } 44 | 45 | 46 | ## Blacklisting/Whitelisting libraries, library types such as Movies/TV Shows, and users. Mappings apply so if the mapping for the user or library exist then both will be excluded. 47 | ## Comma seperated for multiple options 48 | #BLACKLIST_LIBRARY = "" 49 | #WHITELIST_LIBRARY = "Movies" 50 | #BLACKLIST_LIBRARY_TYPE = "Series" 51 | #WHITELIST_LIBRARY_TYPE = "Movies, movie" 52 | #BLACKLIST_USERS = "" 53 | WHITELIST_USERS = "jellyplex_watched" 54 | 55 | 56 | 57 | # Plex 58 | 59 | ## Recommended to use token as it is faster to connect as it is direct to the server instead of going through the plex servers 60 | ## URL of the plex server, use hostname or IP address if the hostname is not resolving correctly 61 | ## Comma seperated list for multiple servers 62 | PLEX_BASEURL = "http://localhost:32400" 63 | 64 | ## Plex token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ 65 | ## Comma seperated list for multiple servers 66 | PLEX_TOKEN = "6S28yhwKg4y-vAXYMi1c" 67 | 68 | ## If not using plex token then use username and password of the server admin along with the servername 69 | ## Comma seperated for multiple options 70 | #PLEX_USERNAME = "PlexUser, PlexUser2" 71 | #PLEX_PASSWORD = "SuperSecret, SuperSecret2" 72 | #PLEX_SERVERNAME = "Plex Server1, Plex Server2" 73 | 74 | ## Skip hostname validation for ssl certificates. 75 | ## Set to True if running into ssl certificate errors 76 | SSL_BYPASS = "True" 77 | 78 | # Jellyfin 79 | 80 | ## Jellyfin server URL, use hostname or IP address if the hostname is not resolving correctly 81 | ## Comma seperated list for multiple servers 82 | JELLYFIN_BASEURL = "http://localhost:8096" 83 | 84 | ## Jellyfin api token, created manually by logging in to the jellyfin server admin dashboard and creating an api key 85 | ## Comma seperated list for multiple servers 86 | JELLYFIN_TOKEN = "d773c4db3ecc4b028fc0904d9694804c" 87 | 88 | # Emby 89 | 90 | ## Emby server URL, use hostname or IP address if the hostname is not resolving correctly 91 | ## Comma seperated list for multiple servers 92 | EMBY_BASEURL = "http://localhost:8097" 93 | 94 | ## Emby api token, created manually by logging in to the Emby server admin dashboard and creating an api key 95 | ## Comma seperated list for multiple servers 96 | EMBY_TOKEN = "ed9507cba8d14d469ae4d58e33afc515" 97 | 98 | 99 | # Syncing Options 100 | 101 | ## control the direction of syncing. e.g. SYNC_FROM_PLEX_TO_JELLYFIN set to true will cause the updates from plex 102 | ## to be updated in jellyfin. SYNC_FROM_PLEX_TO_PLEX set to true will sync updates between multiple plex servers 103 | SYNC_FROM_PLEX_TO_JELLYFIN = "True" 104 | SYNC_FROM_PLEX_TO_PLEX = "True" 105 | SYNC_FROM_PLEX_TO_EMBY = "True" 106 | 107 | SYNC_FROM_JELLYFIN_TO_PLEX = "True" 108 | SYNC_FROM_JELLYFIN_TO_JELLYFIN = "True" 109 | SYNC_FROM_JELLYFIN_TO_EMBY = "True" 110 | 111 | SYNC_FROM_EMBY_TO_PLEX = "True" 112 | SYNC_FROM_EMBY_TO_JELLYFIN = "True" 113 | SYNC_FROM_EMBY_TO_EMBY = "True" -------------------------------------------------------------------------------- /test/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==7.3.0 2 | -------------------------------------------------------------------------------- /test/test_black_white.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # getting the name of the directory 5 | # where the this file is present. 6 | current = os.path.dirname(os.path.realpath(__file__)) 7 | 8 | # Getting the parent directory name 9 | # where the current directory is present. 10 | parent = os.path.dirname(current) 11 | 12 | # adding the parent directory to 13 | # the sys.path. 14 | sys.path.append(parent) 15 | 16 | from src.black_white import setup_black_white_lists 17 | 18 | 19 | def test_setup_black_white_lists(): 20 | # Simple 21 | blacklist_library = ["library1", "library2"] 22 | whitelist_library = ["library1", "library2"] 23 | blacklist_library_type = ["library_type1", "library_type2"] 24 | whitelist_library_type = ["library_type1", "library_type2"] 25 | blacklist_users = ["user1", "user2"] 26 | whitelist_users = ["user1", "user2"] 27 | 28 | ( 29 | results_blacklist_library, 30 | return_whitelist_library, 31 | return_blacklist_library_type, 32 | return_whitelist_library_type, 33 | return_blacklist_users, 34 | return_whitelist_users, 35 | ) = setup_black_white_lists( 36 | blacklist_library, 37 | whitelist_library, 38 | blacklist_library_type, 39 | whitelist_library_type, 40 | blacklist_users, 41 | whitelist_users, 42 | ) 43 | 44 | assert results_blacklist_library == ["library1", "library2"] 45 | assert return_whitelist_library == ["library1", "library2"] 46 | assert return_blacklist_library_type == ["library_type1", "library_type2"] 47 | assert return_whitelist_library_type == ["library_type1", "library_type2"] 48 | assert return_blacklist_users == ["user1", "user2"] 49 | assert return_whitelist_users == ["user1", "user2"] 50 | 51 | 52 | def test_library_mapping_black_white_list(): 53 | blacklist_library = ["library1", "library2"] 54 | whitelist_library = ["library1", "library2"] 55 | blacklist_library_type = ["library_type1", "library_type2"] 56 | whitelist_library_type = ["library_type1", "library_type2"] 57 | blacklist_users = ["user1", "user2"] 58 | whitelist_users = ["user1", "user2"] 59 | 60 | # Library Mapping and user mapping 61 | library_mapping = {"library1": "library3"} 62 | user_mapping = {"user1": "user3"} 63 | 64 | ( 65 | results_blacklist_library, 66 | return_whitelist_library, 67 | return_blacklist_library_type, 68 | return_whitelist_library_type, 69 | return_blacklist_users, 70 | return_whitelist_users, 71 | ) = setup_black_white_lists( 72 | blacklist_library, 73 | whitelist_library, 74 | blacklist_library_type, 75 | whitelist_library_type, 76 | blacklist_users, 77 | whitelist_users, 78 | library_mapping, 79 | user_mapping, 80 | ) 81 | 82 | assert results_blacklist_library == ["library1", "library2", "library3"] 83 | assert return_whitelist_library == ["library1", "library2", "library3"] 84 | assert return_blacklist_library_type == ["library_type1", "library_type2"] 85 | assert return_whitelist_library_type == ["library_type1", "library_type2"] 86 | assert return_blacklist_users == ["user1", "user2", "user3"] 87 | assert return_whitelist_users == ["user1", "user2", "user3"] 88 | -------------------------------------------------------------------------------- /test/test_library.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # getting the name of the directory 5 | # where the this file is present. 6 | current = os.path.dirname(os.path.realpath(__file__)) 7 | 8 | # Getting the parent directory name 9 | # where the current directory is present. 10 | parent = os.path.dirname(current) 11 | 12 | # adding the parent directory to 13 | # the sys.path. 14 | sys.path.append(parent) 15 | 16 | from src.functions import ( 17 | search_mapping, 18 | ) 19 | 20 | from src.library import ( 21 | check_skip_logic, 22 | check_blacklist_logic, 23 | check_whitelist_logic, 24 | ) 25 | 26 | blacklist_library = ["TV Shows"] 27 | whitelist_library = ["Movies"] 28 | blacklist_library_type = ["episodes"] 29 | whitelist_library_type = ["movies"] 30 | library_mapping = {"Shows": "TV Shows", "Movie": "Movies"} 31 | 32 | show_list = { 33 | frozenset( 34 | { 35 | ("locations", ("The Last of Us",)), 36 | ("tmdb", "100088"), 37 | ("imdb", "tt3581920"), 38 | ("tvdb", "392256"), 39 | ("title", "The Last of Us"), 40 | } 41 | ): [ 42 | { 43 | "imdb": "tt11957006", 44 | "tmdb": "2181581", 45 | "tvdb": "8444132", 46 | "locations": ( 47 | ( 48 | "The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv", 49 | ) 50 | ), 51 | "status": {"completed": True, "time": 0}, 52 | } 53 | ] 54 | } 55 | movie_list = [ 56 | { 57 | "title": "Coco", 58 | "imdb": "tt2380307", 59 | "tmdb": "354912", 60 | "locations": [("Coco (2017) Remux-2160p.mkv", "Coco (2017) Remux-1080p.mkv")], 61 | "status": {"completed": True, "time": 0}, 62 | } 63 | ] 64 | 65 | show_titles = { 66 | "imdb": ["tt3581920"], 67 | "locations": [("The Last of Us",)], 68 | "tmdb": ["100088"], 69 | "tvdb": ["392256"], 70 | } 71 | episode_titles = { 72 | "imdb": ["tt11957006"], 73 | "locations": [ 74 | ("The Last of Us - S01E01 - When You're Lost in the Darkness WEBDL-1080p.mkv",) 75 | ], 76 | "tmdb": ["2181581"], 77 | "tvdb": ["8444132"], 78 | "completed": [True], 79 | "time": [0], 80 | "show": [ 81 | { 82 | "imdb": "tt3581920", 83 | "locations": ("The Last of Us",), 84 | "title": "The Last of Us", 85 | "tmdb": "100088", 86 | "tvdb": "392256", 87 | } 88 | ], 89 | } 90 | movie_titles = { 91 | "imdb": ["tt2380307"], 92 | "locations": [ 93 | [ 94 | ( 95 | "Coco (2017) Remux-2160p.mkv", 96 | "Coco (2017) Remux-1080p.mkv", 97 | ) 98 | ] 99 | ], 100 | "title": ["coco"], 101 | "tmdb": ["354912"], 102 | "completed": [True], 103 | "time": [0], 104 | } 105 | 106 | 107 | def test_check_skip_logic(): 108 | # Failes 109 | library_title = "Test" 110 | library_type = "movies" 111 | skip_reason = check_skip_logic( 112 | library_title, 113 | library_type, 114 | blacklist_library, 115 | whitelist_library, 116 | blacklist_library_type, 117 | whitelist_library_type, 118 | library_mapping, 119 | ) 120 | 121 | assert skip_reason == "Test is not in whitelist_library" 122 | 123 | library_title = "Shows" 124 | library_type = "episodes" 125 | skip_reason = check_skip_logic( 126 | library_title, 127 | library_type, 128 | blacklist_library, 129 | whitelist_library, 130 | blacklist_library_type, 131 | whitelist_library_type, 132 | library_mapping, 133 | ) 134 | 135 | assert ( 136 | skip_reason 137 | == "episodes is in blacklist_library_type and TV Shows is in blacklist_library and " 138 | + "episodes is not in whitelist_library_type and Shows is not in whitelist_library" 139 | ) 140 | 141 | # Passes 142 | library_title = "Movie" 143 | library_type = "movies" 144 | skip_reason = check_skip_logic( 145 | library_title, 146 | library_type, 147 | blacklist_library, 148 | whitelist_library, 149 | blacklist_library_type, 150 | whitelist_library_type, 151 | library_mapping, 152 | ) 153 | 154 | assert skip_reason is None 155 | 156 | 157 | def test_check_blacklist_logic(): 158 | # Fails 159 | library_title = "Shows" 160 | library_type = "episodes" 161 | library_other = search_mapping(library_mapping, library_title) 162 | skip_reason = check_blacklist_logic( 163 | library_title, 164 | library_type, 165 | blacklist_library, 166 | blacklist_library_type, 167 | library_other, 168 | ) 169 | 170 | assert ( 171 | skip_reason 172 | == "episodes is in blacklist_library_type and TV Shows is in blacklist_library" 173 | ) 174 | 175 | library_title = "TV Shows" 176 | library_type = "episodes" 177 | library_other = search_mapping(library_mapping, library_title) 178 | skip_reason = check_blacklist_logic( 179 | library_title, 180 | library_type, 181 | blacklist_library, 182 | blacklist_library_type, 183 | library_other, 184 | ) 185 | 186 | assert ( 187 | skip_reason 188 | == "episodes is in blacklist_library_type and TV Shows is in blacklist_library" 189 | ) 190 | 191 | # Passes 192 | library_title = "Movie" 193 | library_type = "movies" 194 | library_other = search_mapping(library_mapping, library_title) 195 | skip_reason = check_blacklist_logic( 196 | library_title, 197 | library_type, 198 | blacklist_library, 199 | blacklist_library_type, 200 | library_other, 201 | ) 202 | 203 | assert skip_reason is None 204 | 205 | library_title = "Movies" 206 | library_type = "movies" 207 | library_other = search_mapping(library_mapping, library_title) 208 | skip_reason = check_blacklist_logic( 209 | library_title, 210 | library_type, 211 | blacklist_library, 212 | blacklist_library_type, 213 | library_other, 214 | ) 215 | 216 | assert skip_reason is None 217 | 218 | 219 | def test_check_whitelist_logic(): 220 | # Fails 221 | library_title = "Shows" 222 | library_type = "episodes" 223 | library_other = search_mapping(library_mapping, library_title) 224 | skip_reason = check_whitelist_logic( 225 | library_title, 226 | library_type, 227 | whitelist_library, 228 | whitelist_library_type, 229 | library_other, 230 | ) 231 | 232 | assert ( 233 | skip_reason 234 | == "episodes is not in whitelist_library_type and Shows is not in whitelist_library" 235 | ) 236 | 237 | library_title = "TV Shows" 238 | library_type = "episodes" 239 | library_other = search_mapping(library_mapping, library_title) 240 | skip_reason = check_whitelist_logic( 241 | library_title, 242 | library_type, 243 | whitelist_library, 244 | whitelist_library_type, 245 | library_other, 246 | ) 247 | 248 | assert ( 249 | skip_reason 250 | == "episodes is not in whitelist_library_type and TV Shows is not in whitelist_library" 251 | ) 252 | 253 | # Passes 254 | library_title = "Movie" 255 | library_type = "movies" 256 | library_other = search_mapping(library_mapping, library_title) 257 | skip_reason = check_whitelist_logic( 258 | library_title, 259 | library_type, 260 | whitelist_library, 261 | whitelist_library_type, 262 | library_other, 263 | ) 264 | 265 | assert skip_reason is None 266 | 267 | library_title = "Movies" 268 | library_type = "movies" 269 | library_other = search_mapping(library_mapping, library_title) 270 | skip_reason = check_whitelist_logic( 271 | library_title, 272 | library_type, 273 | whitelist_library, 274 | whitelist_library_type, 275 | library_other, 276 | ) 277 | 278 | assert skip_reason is None 279 | -------------------------------------------------------------------------------- /test/test_users.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # getting the name of the directory 5 | # where the this file is present. 6 | current = os.path.dirname(os.path.realpath(__file__)) 7 | 8 | # Getting the parent directory name 9 | # where the current directory is present. 10 | parent = os.path.dirname(current) 11 | 12 | # adding the parent directory to 13 | # the sys.path. 14 | sys.path.append(parent) 15 | 16 | from src.users import ( 17 | combine_user_lists, 18 | filter_user_lists, 19 | ) 20 | 21 | 22 | def test_combine_user_lists(): 23 | server_1_users = ["test", "test3", "luigi311"] 24 | server_2_users = ["luigi311", "test2", "test3"] 25 | user_mapping = {"test2": "test"} 26 | 27 | combined = combine_user_lists(server_1_users, server_2_users, user_mapping) 28 | 29 | assert combined == {"luigi311": "luigi311", "test": "test2", "test3": "test3"} 30 | 31 | 32 | def test_filter_user_lists(): 33 | users = {"luigi311": "luigi311", "test": "test2", "test3": "test3"} 34 | blacklist_users = ["test3"] 35 | whitelist_users = ["test", "luigi311"] 36 | 37 | filtered = filter_user_lists(users, blacklist_users, whitelist_users) 38 | 39 | assert filtered == {"test": "test2", "luigi311": "luigi311"} 40 | -------------------------------------------------------------------------------- /test/test_watched.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # getting the name of the directory 5 | # where the this file is present. 6 | current = os.path.dirname(os.path.realpath(__file__)) 7 | 8 | # Getting the parent directory name 9 | # where the current directory is present. 10 | parent = os.path.dirname(current) 11 | 12 | # adding the parent directory to 13 | # the sys.path. 14 | sys.path.append(parent) 15 | 16 | from src.watched import ( 17 | LibraryData, 18 | MediaIdentifiers, 19 | MediaItem, 20 | Series, 21 | UserData, 22 | WatchedStatus, 23 | cleanup_watched, 24 | ) 25 | 26 | tv_shows_watched_list_1: list[Series] = [ 27 | Series( 28 | identifiers=MediaIdentifiers( 29 | title="Doctor Who (2005)", 30 | locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",), 31 | imdb_id="tt0436992", 32 | tmdb_id="57243", 33 | tvdb_id="78804", 34 | ), 35 | episodes=[ 36 | MediaItem( 37 | identifiers=MediaIdentifiers( 38 | title="The Unquiet Dead", 39 | locations=("S01E03.mkv",), 40 | imdb_id="tt0563001", 41 | tmdb_id="968589", 42 | tvdb_id="295296", 43 | ), 44 | status=WatchedStatus(completed=True, time=0), 45 | ), 46 | MediaItem( 47 | identifiers=MediaIdentifiers( 48 | title="Aliens of London (1)", 49 | locations=("S01E04.mkv",), 50 | imdb_id="tt0562985", 51 | tmdb_id="968590", 52 | tvdb_id="295297", 53 | ), 54 | status=WatchedStatus(completed=False, time=240000), 55 | ), 56 | MediaItem( 57 | identifiers=MediaIdentifiers( 58 | title="World War Three (2)", 59 | locations=("S01E05.mkv",), 60 | imdb_id="tt0563003", 61 | tmdb_id="968592", 62 | tvdb_id="295298", 63 | ), 64 | status=WatchedStatus(completed=True, time=0), 65 | ), 66 | ], 67 | ), 68 | Series( 69 | identifiers=MediaIdentifiers( 70 | title="Monarch: Legacy of Monsters", 71 | locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",), 72 | imdb_id="tt17220216", 73 | tmdb_id="202411", 74 | tvdb_id="422598", 75 | ), 76 | episodes=[ 77 | MediaItem( 78 | identifiers=MediaIdentifiers( 79 | title="Secrets and Lies", 80 | locations=("S01E03.mkv",), 81 | imdb_id="tt21255044", 82 | tmdb_id="4661246", 83 | tvdb_id="10009418", 84 | ), 85 | status=WatchedStatus(completed=True, time=0), 86 | ), 87 | MediaItem( 88 | identifiers=MediaIdentifiers( 89 | title="Parallels and Interiors", 90 | locations=("S01E04.mkv",), 91 | imdb_id="tt21255050", 92 | tmdb_id="4712059", 93 | tvdb_id="10009419", 94 | ), 95 | status=WatchedStatus(completed=False, time=240000), 96 | ), 97 | MediaItem( 98 | identifiers=MediaIdentifiers( 99 | title="The Way Out", 100 | locations=("S01E05.mkv",), 101 | imdb_id="tt23787572", 102 | tmdb_id="4712061", 103 | tvdb_id="10009420", 104 | ), 105 | status=WatchedStatus(completed=True, time=0), 106 | ), 107 | ], 108 | ), 109 | Series( 110 | identifiers=MediaIdentifiers( 111 | title="My Adventures with Superman", 112 | locations=("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",), 113 | imdb_id="tt14681924", 114 | tmdb_id="125928", 115 | tvdb_id="403172", 116 | ), 117 | episodes=[ 118 | MediaItem( 119 | identifiers=MediaIdentifiers( 120 | title="Adventures of a Normal Man (1)", 121 | locations=("S01E01.mkv",), 122 | imdb_id="tt15699926", 123 | tmdb_id="3070048", 124 | tvdb_id="8438181", 125 | ), 126 | status=WatchedStatus(completed=True, time=0), 127 | ), 128 | MediaItem( 129 | identifiers=MediaIdentifiers( 130 | title="Adventures of a Normal Man (2)", 131 | locations=("S01E02.mkv",), 132 | imdb_id="tt20413322", 133 | tmdb_id="4568681", 134 | tvdb_id="9829910", 135 | ), 136 | status=WatchedStatus(completed=True, time=0), 137 | ), 138 | MediaItem( 139 | identifiers=MediaIdentifiers( 140 | title="My Interview with Superman", 141 | locations=("S01E03.mkv",), 142 | imdb_id="tt20413328", 143 | tmdb_id="4497012", 144 | tvdb_id="9870382", 145 | ), 146 | status=WatchedStatus(completed=True, time=0), 147 | ), 148 | ], 149 | ), 150 | ] 151 | 152 | # ───────────────────────────────────────────────────────────── 153 | # TV Shows Watched list 2 154 | 155 | tv_shows_watched_list_2: list[Series] = [ 156 | Series( 157 | identifiers=MediaIdentifiers( 158 | title="Doctor Who", 159 | locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",), 160 | imdb_id="tt0436992", 161 | tmdb_id="57243", 162 | tvdb_id="78804", 163 | ), 164 | episodes=[ 165 | MediaItem( 166 | identifiers=MediaIdentifiers( 167 | title="Rose", 168 | locations=("S01E01.mkv",), 169 | imdb_id="tt0562992", 170 | tvdb_id="295294", 171 | tmdb_id=None, 172 | ), 173 | status=WatchedStatus(completed=True, time=0), 174 | ), 175 | MediaItem( 176 | identifiers=MediaIdentifiers( 177 | title="The End of the World", 178 | locations=("S01E02.mkv",), 179 | imdb_id="tt0562997", 180 | tvdb_id="295295", 181 | tmdb_id=None, 182 | ), 183 | status=WatchedStatus(completed=False, time=300670), 184 | ), 185 | MediaItem( 186 | identifiers=MediaIdentifiers( 187 | title="World War Three (2)", 188 | locations=("S01E05.mkv",), 189 | imdb_id="tt0563003", 190 | tvdb_id="295298", 191 | tmdb_id=None, 192 | ), 193 | status=WatchedStatus(completed=True, time=0), 194 | ), 195 | ], 196 | ), 197 | Series( 198 | identifiers=MediaIdentifiers( 199 | title="Monarch: Legacy of Monsters", 200 | locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",), 201 | imdb_id="tt17220216", 202 | tmdb_id="202411", 203 | tvdb_id="422598", 204 | ), 205 | episodes=[ 206 | MediaItem( 207 | identifiers=MediaIdentifiers( 208 | title="Aftermath", 209 | locations=("S01E01.mkv",), 210 | imdb_id="tt20412166", 211 | tvdb_id="9959300", 212 | tmdb_id=None, 213 | ), 214 | status=WatchedStatus(completed=True, time=0), 215 | ), 216 | MediaItem( 217 | identifiers=MediaIdentifiers( 218 | title="Departure", 219 | locations=("S01E02.mkv",), 220 | imdb_id="tt22866594", 221 | tvdb_id="10009417", 222 | tmdb_id=None, 223 | ), 224 | status=WatchedStatus(completed=False, time=300741), 225 | ), 226 | MediaItem( 227 | identifiers=MediaIdentifiers( 228 | title="The Way Out", 229 | locations=("S01E05.mkv",), 230 | imdb_id="tt23787572", 231 | tvdb_id="10009420", 232 | tmdb_id=None, 233 | ), 234 | status=WatchedStatus(completed=True, time=0), 235 | ), 236 | ], 237 | ), 238 | Series( 239 | identifiers=MediaIdentifiers( 240 | title="My Adventures with Superman", 241 | locations=("My Adventures with Superman {tvdb-403172} {imdb-tt14681924}",), 242 | imdb_id="tt14681924", 243 | tmdb_id="125928", 244 | tvdb_id="403172", 245 | ), 246 | episodes=[ 247 | MediaItem( 248 | identifiers=MediaIdentifiers( 249 | title="Adventures of a Normal Man (1)", 250 | locations=("S01E01.mkv",), 251 | imdb_id="tt15699926", 252 | tvdb_id="8438181", 253 | tmdb_id=None, 254 | ), 255 | status=WatchedStatus(completed=True, time=0), 256 | ), 257 | MediaItem( 258 | identifiers=MediaIdentifiers( 259 | title="Adventures of a Normal Man (2)", 260 | locations=("S01E02.mkv",), 261 | imdb_id="tt20413322", 262 | tvdb_id="9829910", 263 | tmdb_id=None, 264 | ), 265 | status=WatchedStatus(completed=True, time=0), 266 | ), 267 | MediaItem( 268 | identifiers=MediaIdentifiers( 269 | title="My Interview with Superman", 270 | locations=("S01E03.mkv",), 271 | imdb_id="tt20413328", 272 | tvdb_id="9870382", 273 | tmdb_id=None, 274 | ), 275 | status=WatchedStatus(completed=True, time=0), 276 | ), 277 | ], 278 | ), 279 | ] 280 | 281 | # ───────────────────────────────────────────────────────────── 282 | # Expected TV Shows Watched list 1 (after cleanup) 283 | 284 | expected_tv_show_watched_list_1: list[Series] = [ 285 | Series( 286 | identifiers=MediaIdentifiers( 287 | title="Doctor Who (2005)", 288 | locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",), 289 | imdb_id="tt0436992", 290 | tmdb_id="57243", 291 | tvdb_id="78804", 292 | ), 293 | episodes=[ 294 | MediaItem( 295 | identifiers=MediaIdentifiers( 296 | title="The Unquiet Dead", 297 | locations=("S01E03.mkv",), 298 | imdb_id="tt0563001", 299 | tmdb_id="968589", 300 | tvdb_id="295296", 301 | ), 302 | status=WatchedStatus(completed=True, time=0), 303 | ), 304 | MediaItem( 305 | identifiers=MediaIdentifiers( 306 | title="Aliens of London (1)", 307 | locations=("S01E04.mkv",), 308 | imdb_id="tt0562985", 309 | tmdb_id="968590", 310 | tvdb_id="295297", 311 | ), 312 | status=WatchedStatus(completed=False, time=240000), 313 | ), 314 | ], 315 | ), 316 | Series( 317 | identifiers=MediaIdentifiers( 318 | title="Monarch: Legacy of Monsters", 319 | locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",), 320 | imdb_id="tt17220216", 321 | tmdb_id="202411", 322 | tvdb_id="422598", 323 | ), 324 | episodes=[ 325 | MediaItem( 326 | identifiers=MediaIdentifiers( 327 | title="Secrets and Lies", 328 | locations=("S01E03.mkv",), 329 | imdb_id="tt21255044", 330 | tmdb_id="4661246", 331 | tvdb_id="10009418", 332 | ), 333 | status=WatchedStatus(completed=True, time=0), 334 | ), 335 | MediaItem( 336 | identifiers=MediaIdentifiers( 337 | title="Parallels and Interiors", 338 | locations=("S01E04.mkv",), 339 | imdb_id="tt21255050", 340 | tmdb_id="4712059", 341 | tvdb_id="10009419", 342 | ), 343 | status=WatchedStatus(completed=False, time=240000), 344 | ), 345 | ], 346 | ), 347 | ] 348 | 349 | # ───────────────────────────────────────────────────────────── 350 | # Expected TV Shows Watched list 2 (after cleanup) 351 | 352 | expected_tv_show_watched_list_2: list[Series] = [ 353 | Series( 354 | identifiers=MediaIdentifiers( 355 | title="Doctor Who", 356 | locations=("Doctor Who (2005) {tvdb-78804} {imdb-tt0436992}",), 357 | imdb_id="tt0436992", 358 | tmdb_id="57243", 359 | tvdb_id="78804", 360 | ), 361 | episodes=[ 362 | MediaItem( 363 | identifiers=MediaIdentifiers( 364 | title="Rose", 365 | locations=("S01E01.mkv",), 366 | imdb_id="tt0562992", 367 | tvdb_id="295294", 368 | tmdb_id=None, 369 | ), 370 | status=WatchedStatus(completed=True, time=0), 371 | ), 372 | MediaItem( 373 | identifiers=MediaIdentifiers( 374 | title="The End of the World", 375 | locations=("S01E02.mkv",), 376 | imdb_id="tt0562997", 377 | tvdb_id="295295", 378 | tmdb_id=None, 379 | ), 380 | status=WatchedStatus(completed=False, time=300670), 381 | ), 382 | ], 383 | ), 384 | Series( 385 | identifiers=MediaIdentifiers( 386 | title="Monarch: Legacy of Monsters", 387 | locations=("Monarch - Legacy of Monsters {tvdb-422598} {imdb-tt17220216}",), 388 | imdb_id="tt17220216", 389 | tmdb_id="202411", 390 | tvdb_id="422598", 391 | ), 392 | episodes=[ 393 | MediaItem( 394 | identifiers=MediaIdentifiers( 395 | title="Aftermath", 396 | locations=("S01E01.mkv",), 397 | imdb_id="tt20412166", 398 | tvdb_id="9959300", 399 | tmdb_id=None, 400 | ), 401 | status=WatchedStatus(completed=True, time=0), 402 | ), 403 | MediaItem( 404 | identifiers=MediaIdentifiers( 405 | title="Departure", 406 | locations=("S01E02.mkv",), 407 | imdb_id="tt22866594", 408 | tvdb_id="10009417", 409 | tmdb_id=None, 410 | ), 411 | status=WatchedStatus(completed=False, time=300741), 412 | ), 413 | ], 414 | ), 415 | ] 416 | 417 | # ───────────────────────────────────────────────────────────── 418 | # Movies Watched list 1 419 | 420 | movies_watched_list_1: list[MediaItem] = [ 421 | MediaItem( 422 | identifiers=MediaIdentifiers( 423 | title="Big Buck Bunny", 424 | locations=("Big Buck Bunny.mkv",), 425 | imdb_id="tt1254207", 426 | tmdb_id="10378", 427 | tvdb_id="12352", 428 | ), 429 | status=WatchedStatus(completed=True, time=0), 430 | ), 431 | MediaItem( 432 | identifiers=MediaIdentifiers( 433 | title="The Family Plan", 434 | locations=("The Family Plan (2023).mkv",), 435 | imdb_id="tt16431870", 436 | tmdb_id="1029575", 437 | tvdb_id="351194", 438 | ), 439 | status=WatchedStatus(completed=True, time=0), 440 | ), 441 | MediaItem( 442 | identifiers=MediaIdentifiers( 443 | title="Killers of the Flower Moon", 444 | locations=("Killers of the Flower Moon (2023).mkv",), 445 | imdb_id="tt5537002", 446 | tmdb_id="466420", 447 | tvdb_id="135852", 448 | ), 449 | status=WatchedStatus(completed=False, time=240000), 450 | ), 451 | ] 452 | 453 | # ───────────────────────────────────────────────────────────── 454 | # Movies Watched list 2 455 | 456 | movies_watched_list_2: list[MediaItem] = [ 457 | MediaItem( 458 | identifiers=MediaIdentifiers( 459 | title="The Family Plan", 460 | locations=("The Family Plan (2023).mkv",), 461 | imdb_id="tt16431870", 462 | tmdb_id="1029575", 463 | tvdb_id=None, 464 | ), 465 | status=WatchedStatus(completed=True, time=0), 466 | ), 467 | MediaItem( 468 | identifiers=MediaIdentifiers( 469 | title="Five Nights at Freddy's", 470 | locations=("Five Nights at Freddy's (2023).mkv",), 471 | imdb_id="tt4589218", 472 | tmdb_id="507089", 473 | tvdb_id=None, 474 | ), 475 | status=WatchedStatus(completed=True, time=0), 476 | ), 477 | MediaItem( 478 | identifiers=MediaIdentifiers( 479 | title="The Hunger Games: The Ballad of Songbirds & Snakes", 480 | locations=("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",), 481 | imdb_id="tt10545296", 482 | tmdb_id="695721", 483 | tvdb_id=None, 484 | ), 485 | status=WatchedStatus(completed=False, time=301215), 486 | ), 487 | ] 488 | 489 | # ───────────────────────────────────────────────────────────── 490 | # Expected Movies Watched list 1 491 | 492 | expected_movie_watched_list_1: list[MediaItem] = [ 493 | MediaItem( 494 | identifiers=MediaIdentifiers( 495 | title="Big Buck Bunny", 496 | locations=("Big Buck Bunny.mkv",), 497 | imdb_id="tt1254207", 498 | tmdb_id="10378", 499 | tvdb_id="12352", 500 | ), 501 | status=WatchedStatus(completed=True, time=0), 502 | ), 503 | MediaItem( 504 | identifiers=MediaIdentifiers( 505 | title="Killers of the Flower Moon", 506 | locations=("Killers of the Flower Moon (2023).mkv",), 507 | imdb_id="tt5537002", 508 | tmdb_id="466420", 509 | tvdb_id="135852", 510 | ), 511 | status=WatchedStatus(completed=False, time=240000), 512 | ), 513 | ] 514 | 515 | # ───────────────────────────────────────────────────────────── 516 | # Expected Movies Watched list 2 517 | 518 | expected_movie_watched_list_2: list[MediaItem] = [ 519 | MediaItem( 520 | identifiers=MediaIdentifiers( 521 | title="Five Nights at Freddy's", 522 | locations=("Five Nights at Freddy's (2023).mkv",), 523 | imdb_id="tt4589218", 524 | tmdb_id="507089", 525 | tvdb_id=None, 526 | ), 527 | status=WatchedStatus(completed=True, time=0), 528 | ), 529 | MediaItem( 530 | identifiers=MediaIdentifiers( 531 | title="The Hunger Games: The Ballad of Songbirds & Snakes", 532 | locations=("The Hunger Games The Ballad of Songbirds & Snakes (2023).mkv",), 533 | imdb_id="tt10545296", 534 | tmdb_id="695721", 535 | tvdb_id=None, 536 | ), 537 | status=WatchedStatus(completed=False, time=301215), 538 | ), 539 | ] 540 | 541 | # ───────────────────────────────────────────────────────────── 542 | # TV Shows 2 Watched list 1 (for testing deletion up to the root) 543 | # Here we use a single Series entry for "Criminal Minds" 544 | 545 | tv_shows_2_watched_list_1: list[Series] = [ 546 | Series( 547 | identifiers=MediaIdentifiers( 548 | title="Criminal Minds", 549 | locations=("Criminal Minds",), 550 | imdb_id="tt0452046", 551 | tmdb_id="4057", 552 | tvdb_id="75710", 553 | ), 554 | episodes=[ 555 | MediaItem( 556 | identifiers=MediaIdentifiers( 557 | title="Extreme Aggressor", 558 | locations=( 559 | "Criminal Minds S01E01 Extreme Aggressor WEBDL-720p.mkv", 560 | ), 561 | imdb_id="tt0550489", 562 | tmdb_id="282843", 563 | tvdb_id="176357", 564 | ), 565 | status=WatchedStatus(completed=True, time=0), 566 | ) 567 | ], 568 | ) 569 | ] 570 | 571 | 572 | def test_simple_cleanup_watched(): 573 | user_watched_list_1: dict[str, UserData] = { 574 | "user1": UserData( 575 | libraries={ 576 | "TV Shows": LibraryData( 577 | title="TV Shows", 578 | movies=[], 579 | series=tv_shows_watched_list_1, 580 | ), 581 | "Movies": LibraryData( 582 | title="Movies", 583 | movies=movies_watched_list_1, 584 | series=[], 585 | ), 586 | "Other Shows": LibraryData( 587 | title="Other Shows", 588 | movies=[], 589 | series=tv_shows_2_watched_list_1, 590 | ), 591 | } 592 | ) 593 | } 594 | 595 | user_watched_list_2: dict[str, UserData] = { 596 | "user1": UserData( 597 | libraries={ 598 | "TV Shows": LibraryData( 599 | title="TV Shows", 600 | movies=[], 601 | series=tv_shows_watched_list_2, 602 | ), 603 | "Movies": LibraryData( 604 | title="Movies", 605 | movies=movies_watched_list_2, 606 | series=[], 607 | ), 608 | "Other Shows": LibraryData( 609 | title="Other Shows", 610 | movies=[], 611 | series=tv_shows_2_watched_list_1, 612 | ), 613 | } 614 | ) 615 | } 616 | 617 | expected_watched_list_1: dict[str, UserData] = { 618 | "user1": UserData( 619 | libraries={ 620 | "TV Shows": LibraryData( 621 | title="TV Shows", 622 | movies=[], 623 | series=expected_tv_show_watched_list_1, 624 | ), 625 | "Movies": LibraryData( 626 | title="Movies", 627 | movies=expected_movie_watched_list_1, 628 | series=[], 629 | ), 630 | } 631 | ) 632 | } 633 | 634 | expected_watched_list_2: dict[str, UserData] = { 635 | "user1": UserData( 636 | libraries={ 637 | "TV Shows": LibraryData( 638 | title="TV Shows", 639 | movies=[], 640 | series=expected_tv_show_watched_list_2, 641 | ), 642 | "Movies": LibraryData( 643 | title="Movies", 644 | movies=expected_movie_watched_list_2, 645 | series=[], 646 | ), 647 | } 648 | ) 649 | } 650 | 651 | return_watched_list_1 = cleanup_watched(user_watched_list_1, user_watched_list_2) 652 | return_watched_list_2 = cleanup_watched(user_watched_list_2, user_watched_list_1) 653 | 654 | assert return_watched_list_1 == expected_watched_list_1 655 | assert return_watched_list_2 == expected_watched_list_2 656 | 657 | 658 | # def test_mapping_cleanup_watched(): 659 | # user_watched_list_1 = { 660 | # "user1": { 661 | # "TV Shows": tv_shows_watched_list_1, 662 | # "Movies": movies_watched_list_1, 663 | # "Other Shows": tv_shows_2_watched_list_1, 664 | # }, 665 | # } 666 | # user_watched_list_2 = { 667 | # "user2": { 668 | # "Shows": tv_shows_watched_list_2, 669 | # "Movies": movies_watched_list_2, 670 | # "Other Shows": tv_shows_2_watched_list_1, 671 | # } 672 | # } 673 | # 674 | # expected_watched_list_1 = { 675 | # "user1": { 676 | # "TV Shows": expected_tv_show_watched_list_1, 677 | # "Movies": expected_movie_watched_list_1, 678 | # } 679 | # } 680 | # 681 | # expected_watched_list_2 = { 682 | # "user2": { 683 | # "Shows": expected_tv_show_watched_list_2, 684 | # "Movies": expected_movie_watched_list_2, 685 | # } 686 | # } 687 | # 688 | # user_mapping = {"user1": "user2"} 689 | # library_mapping = {"TV Shows": "Shows"} 690 | # 691 | # return_watched_list_1 = cleanup_watched( 692 | # user_watched_list_1, 693 | # user_watched_list_2, 694 | # user_mapping=user_mapping, 695 | # library_mapping=library_mapping, 696 | # ) 697 | # return_watched_list_2 = cleanup_watched( 698 | # user_watched_list_2, 699 | # user_watched_list_1, 700 | # user_mapping=user_mapping, 701 | # library_mapping=library_mapping, 702 | # ) 703 | # 704 | # assert return_watched_list_1 == expected_watched_list_1 705 | # assert return_watched_list_2 == expected_watched_list_2 706 | -------------------------------------------------------------------------------- /test/validate_ci_marklog.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | from loguru import logger 5 | from collections import Counter 6 | 7 | 8 | class MarkLogError(Exception): 9 | """Custom exception for mark.log validation failures.""" 10 | 11 | pass 12 | 13 | 14 | def parse_args(): 15 | parser = argparse.ArgumentParser( 16 | description="Check the mark.log file that is generated by the CI to make sure it contains the expected values" 17 | ) 18 | group = parser.add_mutually_exclusive_group(required=True) 19 | group.add_argument( 20 | "--guids", action="store_true", help="Check the mark.log file for guids" 21 | ) 22 | group.add_argument( 23 | "--locations", action="store_true", help="Check the mark.log file for locations" 24 | ) 25 | group.add_argument( 26 | "--write", action="store_true", help="Check the mark.log file for write-run" 27 | ) 28 | group.add_argument( 29 | "--plex", action="store_true", help="Check the mark.log file for Plex" 30 | ) 31 | group.add_argument( 32 | "--jellyfin", action="store_true", help="Check the mark.log file for Jellyfin" 33 | ) 34 | group.add_argument( 35 | "--emby", action="store_true", help="Check the mark.log file for Emby" 36 | ) 37 | 38 | return parser.parse_args() 39 | 40 | 41 | def read_marklog(): 42 | marklog = os.path.join(os.getcwd(), "mark.log") 43 | try: 44 | with open(marklog, "r") as f: 45 | lines = [line.strip() for line in f if line.strip()] 46 | return lines 47 | except Exception as e: 48 | raise MarkLogError(f"Error reading {marklog}: {e}") 49 | 50 | 51 | def check_marklog(lines, expected_values): 52 | found_counter = Counter(lines) 53 | expected_counter = Counter(expected_values) 54 | 55 | # Determine missing and extra items by comparing counts 56 | missing = expected_counter - found_counter 57 | extra = found_counter - expected_counter 58 | 59 | if missing or extra: 60 | if missing: 61 | logger.error("Missing expected entries (with counts):") 62 | for entry, count in missing.items(): 63 | logger.error(f" {entry}: missing {count} time(s)") 64 | if extra: 65 | logger.error("Unexpected extra entries found (with counts):") 66 | for entry, count in extra.items(): 67 | logger.error(f" {entry}: found {count} extra time(s)") 68 | 69 | logger.error( 70 | f"Entry count mismatch: found {len(lines)} entries, expected {len(expected_values)} entries." 71 | ) 72 | logger.error("Full mark.log content:") 73 | for line in sorted(lines): 74 | logger.error(f" {line}") 75 | raise MarkLogError("mark.log validation failed.") 76 | 77 | return True 78 | 79 | 80 | def main(): 81 | args = parse_args() 82 | 83 | # Expected values defined for each check 84 | expected_jellyfin = [ 85 | "Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Two (2021)", 86 | "Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 2", 87 | "Plex/JellyPlex-CI/jellyplex_watched/Movies/Five Nights at Freddy's", 88 | "Plex/JellyPlex-CI/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215", 89 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", 90 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670", 91 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath", 92 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741", 93 | "Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie Two", 94 | "Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E02", 95 | "Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan", 96 | "Emby/Emby-Server/jellyplex_watched/Movies/Five Nights at Freddy's", 97 | "Emby/Emby-Server/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5", 98 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", 99 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5", 100 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5", 101 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out", 102 | ] 103 | expected_emby = [ 104 | "Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Three (2022)", 105 | "Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 3", 106 | "Plex/JellyPlex-CI/jellyplex_watched/Movies/Tears of Steel", 107 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath", 108 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429", 109 | "Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie Three (2022)", 110 | "Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E03", 111 | "Jellyfin/Jellyfin-Server/JellyUser/Movies/Tears of Steel", 112 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", 113 | ] 114 | expected_plex = [ 115 | "Jellyfin/Jellyfin-Server/JellyUser/Movies/Big Buck Bunny", 116 | "Jellyfin/Jellyfin-Server/JellyUser/Movies/Killers of the Flower Moon/4", 117 | "Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E01", 118 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/The Unquiet Dead", 119 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/Aliens of London (1)/4", 120 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", 121 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", 122 | "Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie One (2020)", 123 | "Emby/Emby-Server/jellyplex_watched/Movies/Big Buck Bunny", 124 | "Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan", 125 | "Emby/Emby-Server/jellyplex_watched/Movies/Killers of the Flower Moon/4", 126 | "Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E01", 127 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead", 128 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4", 129 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies", 130 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out", 131 | "Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie One", 132 | ] 133 | 134 | expected_locations = expected_emby + expected_plex + expected_jellyfin 135 | # Remove Custom Movies/TV Shows as they should not have guids 136 | expected_guids = [item for item in expected_locations if "Custom" not in item] 137 | 138 | expected_write = [ 139 | "Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Two (2021)", 140 | "Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 2", 141 | "Plex/JellyPlex-CI/jellyplex_watched/Movies/Five Nights at Freddy's", 142 | "Plex/JellyPlex-CI/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/301215", 143 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", 144 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/300670", 145 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Aftermath", 146 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/300741", 147 | "Jellyfin/Jellyfin-Server/JellyUser/Movies/Big Buck Bunny", 148 | "Jellyfin/Jellyfin-Server/JellyUser/Movies/Killers of the Flower Moon/4", 149 | "Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E01", 150 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/The Unquiet Dead", 151 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Doctor Who/Aliens of London (1)/4", 152 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Secrets and Lies", 153 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", 154 | "Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie One (2020)", 155 | "Plex/JellyPlex-CI/jellyplex_watched/Custom Movies/Movie Three (2022)", 156 | "Plex/JellyPlex-CI/jellyplex_watched/Custom TV Shows/Greatest Show Ever 3000/Episode 3", 157 | "Plex/JellyPlex-CI/jellyplex_watched/Movies/Tears of Steel", 158 | "Plex/JellyPlex-CI/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Parallels and Interiors/240429", 159 | "Emby/Emby-Server/jellyplex_watched/Movies/Big Buck Bunny", 160 | "Emby/Emby-Server/jellyplex_watched/Movies/The Family Plan", 161 | "Emby/Emby-Server/jellyplex_watched/Movies/Five Nights at Freddy's", 162 | "Emby/Emby-Server/jellyplex_watched/Movies/The Hunger Games: The Ballad of Songbirds & Snakes/5", 163 | "Emby/Emby-Server/jellyplex_watched/Movies/Killers of the Flower Moon/4", 164 | "Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E01", 165 | "Emby/Emby-Server/jellyplex_watched/Custom TV Shows/Greatest Show Ever (3000)/S01E02", 166 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Rose", 167 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The End of the World/5", 168 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/The Unquiet Dead", 169 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Doctor Who (2005)/Aliens of London (1)/4", 170 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Departure/5", 171 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/Secrets and Lies", 172 | "Emby/Emby-Server/jellyplex_watched/TV Shows/Monarch: Legacy of Monsters/The Way Out", 173 | "Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie One", 174 | "Emby/Emby-Server/jellyplex_watched/Custom Movies/Movie Two", 175 | "Jellyfin/Jellyfin-Server/JellyUser/Custom Movies/Movie Three (2022)", 176 | "Jellyfin/Jellyfin-Server/JellyUser/Custom TV Shows/Greatest Show Ever (3000)/S01E03", 177 | "Jellyfin/Jellyfin-Server/JellyUser/Movies/Tears of Steel", 178 | "Jellyfin/Jellyfin-Server/JellyUser/Shows/Monarch: Legacy of Monsters/Parallels and Interiors/4", 179 | ] 180 | 181 | # Determine which expected values to use based on the command-line flag 182 | if args.guids: 183 | expected_values = expected_guids 184 | check_type = "GUIDs" 185 | elif args.locations: 186 | expected_values = expected_locations 187 | check_type = "locations" 188 | elif args.write: 189 | expected_values = expected_write 190 | check_type = "write-run" 191 | elif args.plex: 192 | expected_values = expected_plex 193 | check_type = "Plex" 194 | elif args.jellyfin: 195 | expected_values = expected_jellyfin 196 | check_type = "Jellyfin" 197 | elif args.emby: 198 | expected_values = expected_emby 199 | check_type = "Emby" 200 | else: 201 | raise MarkLogError("No server specified") 202 | 203 | logger.info(f"Validating mark.log for {check_type}...") 204 | 205 | try: 206 | lines = read_marklog() 207 | check_marklog(lines, expected_values) 208 | except MarkLogError as e: 209 | logger.error(e) 210 | sys.exit(1) 211 | 212 | logger.success("Successfully validated mark.log") 213 | sys.exit(0) 214 | 215 | 216 | if __name__ == "__main__": 217 | main() 218 | --------------------------------------------------------------------------------