├── .dockerignore ├── .envrc ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── docker-compose.yml ├── entrypoint.sh ├── plex-api.sh ├── plextraktsync.bat ├── plextraktsync.sh ├── plextraktsync ├── __init__.py ├── __main__.py ├── cli.py ├── commands │ ├── bug_report.py │ ├── cache.py │ ├── clear_collections.py │ ├── compare_libraries.py │ ├── config.py │ ├── download.py │ ├── imdb_import.py │ ├── info.py │ ├── inspect.py │ ├── login.py │ ├── plex_login.py │ ├── self_update.py │ ├── sync.py │ ├── trakt_login.py │ ├── unmatched.py │ ├── watch.py │ └── watched_shows.py ├── config.default.yml ├── config │ ├── Config.py │ ├── ConfigLoader.py │ ├── ConfigMergeMixin.py │ ├── HttpCacheConfig.py │ ├── PlexServerConfig.py │ ├── RunConfig.py │ ├── ServerConfigFactory.py │ ├── SyncConfig.py │ └── __init__.py ├── decorators │ ├── account_limit.py │ ├── coro.py │ ├── flatten.py │ ├── measure_time.py │ ├── nocache.py │ ├── rate_limit.py │ ├── retry.py │ └── time_limit.py ├── factory │ ├── Factory.py │ └── __init__.py ├── logger │ ├── filter.py │ └── init.py ├── media │ ├── Media.py │ └── MediaFactory.py ├── mixin │ ├── ChangeNotifier.py │ └── SetWindowTitle.py ├── path.py ├── plan │ ├── WalkConfig.py │ ├── WalkPlan.py │ ├── WalkPlanner.py │ └── Walker.py ├── plex │ ├── PlexApi.py │ ├── PlexAudioCodec.py │ ├── PlexId.py │ ├── PlexIdFactory.py │ ├── PlexLibraryItem.py │ ├── PlexLibrarySection.py │ ├── PlexPlaylist.py │ ├── PlexPlaylistCollection.py │ ├── PlexRatings.py │ ├── PlexSectionPager.py │ ├── PlexServerConnection.py │ ├── PlexWatchList.py │ ├── SessionCollection.py │ ├── guid │ │ ├── PlexGuid.py │ │ └── provider │ │ │ ├── Abstract.py │ │ │ ├── Factory.py │ │ │ ├── IMDB.py │ │ │ ├── Local.py │ │ │ ├── Mbid.py │ │ │ ├── TMDB.py │ │ │ ├── TVDB.py │ │ │ └── Youtube.py │ └── types.py ├── plugin │ ├── __init__.py │ └── plugin.py ├── pytrakt_extensions.py ├── queue │ ├── BackgroundTask.py │ ├── Queue.py │ ├── TraktBatchWorker.py │ ├── TraktMarkWatchedWorker.py │ └── TraktScrobbleWorker.py ├── rich │ ├── RichHighlighter.py │ ├── RichMarkup.py │ └── RichProgressBar.py ├── style.py ├── sync │ ├── AddCollectionPlugin.py │ ├── ClearCollectedPlugin.py │ ├── LikedListsPlugin.py │ ├── Sync.py │ ├── SyncRatingsPlugin.py │ ├── SyncWatchedPlugin.py │ ├── TraktListsPlugin.py │ ├── WatchListPlugin.py │ ├── WatchProgressPlugin.py │ └── plugin │ │ ├── SyncPluginInterface.py │ │ ├── SyncPluginManager.py │ │ └── __init__.py ├── trakt │ ├── PartialTraktMedia.py │ ├── ScrobblerCollection.py │ ├── ScrobblerProxy.py │ ├── TraktApi.py │ ├── TraktItem.py │ ├── TraktLookup.py │ ├── TraktRatingCollection.py │ ├── TraktUserList.py │ ├── TraktUserListCollection.py │ ├── TraktWatchlist.py │ ├── WatchProgress.py │ ├── trakt_set.py │ └── types.py ├── util │ ├── Path.py │ ├── Rating.py │ ├── Timer.py │ ├── Version.py │ ├── execp.py │ ├── execx.py │ ├── expand_id.py │ ├── git_version_info.py │ ├── local_url.py │ ├── openurl.py │ ├── packaging.py │ ├── parse_date.py │ └── remove_empty_values.py └── watch │ ├── EventDispatcher.py │ ├── EventFactory.py │ ├── ProgressBar.py │ ├── WatchStateUpdater.py │ ├── WebSocketListener.py │ └── events.py ├── pyproject.toml ├── requirements.pipenv.txt ├── requirements.txt ├── setup.bat ├── setup.cfg ├── trakt-api.sh └── trakt.ico /.dockerignore: -------------------------------------------------------------------------------- 1 | # https://docs.docker.com/build/building/context/#dockerignore-files 2 | /* 3 | !/Pipfile 4 | !/Pipfile.lock 5 | !/entrypoint.sh 6 | !/plextraktsync.sh 7 | !/plextraktsync/ 8 | !/requirements.txt 9 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout python python3.9 2 | 3 | # Pipenv found itself running within a virtual environment, 4 | # so it will automatically use that environment, 5 | # instead of creating its own for any project. 6 | export PIPENV_VERBOSITY=-1 7 | 8 | # To enable deprecations: 9 | #export PYTHONWARNINGS=default 10 | #export PYTHONWARNINGS=error 11 | 12 | [[ -f .envrc.local ]] && source_env .envrc.local 13 | 14 | # vim:ft=bash 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.direnv 2 | /.env 3 | /.envrc.local 4 | /.idea/ 5 | /.pytrakt.json 6 | /.vscode/ 7 | /config.json 8 | /config.json.old 9 | /config.yml 10 | /config/ 11 | /dist/ 12 | /docker-compose.override.yml 13 | /last_update.log 14 | /plextraktsync.log 15 | /servers.yml 16 | /tests/.env 17 | /tests/config.json 18 | /trakt_cache.sqlite 19 | __pycache__/ 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | # To update dependencies here, run 'pre-commit autoupdate' 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: check-yaml 9 | - id: check-json 10 | - id: check-added-large-files 11 | - id: fix-byte-order-marker 12 | - id: check-case-conflict 13 | - id: check-merge-conflict 14 | - id: check-vcs-permalinks 15 | - id: destroyed-symlinks 16 | 17 | - repo: https://github.com/astral-sh/ruff-pre-commit 18 | rev: v0.9.7 19 | hooks: 20 | - id: ruff 21 | args: [ --fix ] 22 | - id: ruff-format 23 | 24 | - repo: https://github.com/dannysepler/rm_unneeded_f_str 25 | rev: v0.2.0 26 | hooks: 27 | - id: rm-unneeded-f-str 28 | 29 | # https://pypi.org/project/validate-pyproject/ 30 | - repo: https://github.com/abravalheri/validate-pyproject 31 | rev: v0.23 32 | hooks: 33 | - id: validate-pyproject 34 | # Optional extra validations from SchemaStore: 35 | additional_dependencies: ["validate-pyproject-schema-store[all]"] 36 | 37 | # vim:ts=2:sw=2:et 38 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /Pipfile.lock 2 | /tests/mock_data/ 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1.3-labs 2 | ARG PYTHON_VERSION=3.13 3 | ARG ALPINE_VERSION=3.21 4 | FROM python:$PYTHON_VERSION-alpine$ALPINE_VERSION AS base 5 | ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_ROOT_USER_ACTION=ignore 6 | WORKDIR /app 7 | 8 | # Create minimal layer with extra tools 9 | FROM base AS tools 10 | RUN apk add util-linux shadow 11 | WORKDIR /dist 12 | RUN </dev/null) 33 | if [ $# -gt 0 ]; then 34 | pip wheel "$@" --wheel-dir=/wheels 35 | fi 36 | eot 37 | 38 | # Install app dependencies 39 | FROM base AS build 40 | RUN apk add git 41 | RUN pip install pipenv 42 | RUN \ 43 | --mount=type=bind,from=wheels,source=/wheels,target=/wheels \ 44 | pipenv run pip install /wheels/*.whl 45 | 46 | # Verify site-packages path 47 | ARG PYTHON_VERSION 48 | RUN du -sh /root/.local/share/virtualenvs/app-*/lib/python$PYTHON_VERSION/site-packages 49 | 50 | FROM base AS compile 51 | ARG APP_VERSION=$APP_VERSION 52 | ENV APP_VERSION=$APP_VERSION 53 | 54 | COPY plextraktsync ./plextraktsync/ 55 | COPY plextraktsync.sh . 56 | # Create __version__ from $APP_VERSION 57 | RUN echo "__version__ = '${APP_VERSION:-unknown}'" > plextraktsync/__init__.py 58 | RUN cat plextraktsync/__init__.py 59 | RUN python -c "from plextraktsync import __version__; print(__version__)" 60 | 61 | # Compile sources 62 | RUN python -m compileall . 63 | RUN chmod -R a+rX,g-w . 64 | 65 | FROM base AS runtime 66 | ENTRYPOINT ["/init"] 67 | 68 | ENV \ 69 | # https://specifications.freedesktop.org/basedir-spec/latest/ar01s03.html 70 | XDG_CACHE_HOME=/app/xdg/cache \ 71 | XDG_CONFIG_HOME=/app/xdg/config \ 72 | XDG_DATA_HOME=/app/xdg/data \ 73 | # https://pypa.github.io/pipx/docs/ 74 | PIPX_BIN_DIR=/app/xdg/bin \ 75 | PIPX_HOME=/app/xdg/pipx \ 76 | # https://stackoverflow.com/questions/2915471/install-a-python-package-into-a-different-directory-using-pip/29103053#29103053 77 | PYTHONUSERBASE=/app/xdg \ 78 | # Fallback for anything else 79 | HOME=/app/xdg \ 80 | \ 81 | PATH=/app/xdg/bin:/app/xdg/.local/bin:/root/.local/bin:$PATH \ 82 | PTS_CONFIG_DIR=/app/config \ 83 | PTS_CACHE_DIR=/app/config \ 84 | PTS_LOG_DIR=/app/config \ 85 | PTS_IN_DOCKER=1 \ 86 | PYTHONUNBUFFERED=1 87 | 88 | VOLUME /app/config 89 | VOLUME $HOME 90 | 91 | # Add user/group 92 | RUN <&2 "* $*" 8 | } 9 | 10 | ensure_dir() { 11 | install -o "$APP_USER" -g "$APP_GROUP" -d "$@" 12 | } 13 | 14 | ensure_owner() { 15 | chown "$APP_USER:$APP_GROUP" "$@" 16 | } 17 | 18 | # change uid/gid of app user if requested 19 | setup_user() { 20 | local uid=${PUID:-} 21 | local gid=${PGID:-} 22 | 23 | if [ -n "$uid" ] && [ "$(id -u $APP_USER)" != "$uid" ]; then 24 | usermod -o -u "$uid" "$APP_USER" 25 | fi 26 | if [ -n "$gid" ] && [ "$(id -g $APP_GROUP)" != "$gid" ]; then 27 | groupmod -o -g "$gid" "$APP_GROUP" 28 | fi 29 | } 30 | 31 | # Run command as app user 32 | # https://github.com/karelzak/util-linux/issues/325 33 | run_user() { 34 | local uid=$(id -u "$APP_USER") 35 | local gid=$(id -g "$APP_GROUP") 36 | 37 | setpriv --euid "$uid" --ruid "$uid" --clear-groups --egid "$gid" --rgid "$gid" -- "$@" 38 | } 39 | 40 | switch_user() { 41 | local uid=$(id -u "$APP_USER") 42 | local gid=$(id -g "$APP_GROUP") 43 | 44 | exec setpriv --euid "$uid" --ruid "$uid" --clear-groups --egid "$gid" --rgid "$gid" -- "$@" 45 | } 46 | 47 | fix_permissions() { 48 | ensure_dir /app/config 49 | ensure_owner /app/config -R 50 | ensure_owner "$HOME" 51 | } 52 | 53 | needs_switch_user() { 54 | local uid=${PUID:-0} 55 | local gid=${PGID:-0} 56 | 57 | # configured to run as non-root 58 | if [ "$uid" -eq 0 ] && [ "$gid" -eq 0 ]; then 59 | return 1 60 | fi 61 | 62 | # must be root to be able to switch user 63 | [ "$(id -u)" -eq 0 ] 64 | } 65 | 66 | set -eu 67 | test -n "$TRACE" && set -x 68 | 69 | # Test docker image health 70 | if [ "${1:-}" = "test" ]; then 71 | # Check tools linkage 72 | ldd /usr/bin/setpriv 73 | ldd /usr/bin/usermod 74 | ldd /usr/bin/groupmod 75 | # Continue with info command 76 | set -- "info" 77 | fi 78 | 79 | # fix permissions and switch user if configured 80 | if needs_switch_user; then 81 | setup_user 82 | fix_permissions 83 | fi 84 | 85 | # Shortcut to pre-install "pipx" and enter as "sh" 86 | if [ "${1:-}" = "pipx" ]; then 87 | # https://github.com/Taxel/PlexTraktSync/blob/main/CONTRIBUTING.md#install-code-from-pull-request 88 | msg "Installing git" 89 | apk add git 90 | msg "Installing pipx" 91 | run_user pip install pipx 92 | if [ ! -x "$PIPX_BIN_DIR/plextraktsync" ]; then 93 | msg "Installing plextraktsync from pipx" 94 | run_user pipx install plextraktsync 95 | fi 96 | set -- "sh" 97 | fi 98 | 99 | # Use "sh" command to passthrough to shell 100 | if [ "${1:-}" != "sh" ]; then 101 | # Prepend default command 102 | set -- python -m plextraktsync "$@" 103 | fi 104 | 105 | # fix permissions and switch user if configured 106 | if needs_switch_user; then 107 | switch_user "$@" 108 | fi 109 | 110 | exec "$@" 111 | -------------------------------------------------------------------------------- /plex-api.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Get PLEX_SERVER value from .env 4 | get_plex_server() { 5 | awk -F= '$1 == "PLEX_SERVER" {print $2}' .env 6 | } 7 | 8 | load_servers() { 9 | python -c 'import yaml; import json; fp=open("servers.yml"); print(json.dumps(yaml.safe_load(fp)["servers"]))' 10 | } 11 | 12 | get_server() { 13 | local name="$1" 14 | 15 | load_servers | jq ".$name" 16 | } 17 | 18 | get_plex_token() { 19 | local name server 20 | 21 | name=${PLEX_SERVER:-$(get_plex_server)} 22 | server=$(get_server "$name") 23 | 24 | echo "$server" | jq -r ".token" 25 | } 26 | 27 | : "${PLEX_TOKEN=$(get_plex_token)}" 28 | 29 | curl -sSf \ 30 | --header "X-Plex-Token: $PLEX_TOKEN" \ 31 | "$@" 32 | -------------------------------------------------------------------------------- /plextraktsync.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Killing an running versions of this script... 3 | taskkill /F /FI "windowtitle eq Plex - Trakt Sync" /T 4 | 5 | SET mypath=%~dp0 6 | cd %mypath%\ 7 | 8 | cls 9 | 10 | :: Check for Python Installation 11 | python --version 2>NUL 12 | if errorlevel 1 goto errorNoPython 13 | 14 | cls 15 | 16 | title Plex - Trakt Sync 17 | 18 | 19 | python -m plextraktsync %* 20 | goto:eof 21 | 22 | :errorNoPython 23 | echo ----------------------------------------------------------------------------------- 24 | echo Plex - Trakt Sync - ERROR: Python not detected! 25 | echo ----------------------------------------------------------------------------------- 26 | echo You will need to download and install Python to use this tool: https://www.python.org/downloads 27 | echo Please download and install Python, then restart this tool. 28 | echo Press any key to exit 29 | echo. 30 | pause > nul 31 | -------------------------------------------------------------------------------- /plextraktsync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | file=$(readlink -f "$0") 5 | dir=$(dirname "$file") 6 | cd "$dir" 7 | 8 | exec python3 -m plextraktsync "$@" 9 | -------------------------------------------------------------------------------- /plextraktsync/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __version__ = "0.34.0dev0" 4 | -------------------------------------------------------------------------------- /plextraktsync/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | if len(__package__) == 0: 4 | import sys 5 | 6 | print( 7 | f""" 8 | 9 | The '__main__' module does not seem to have been run in the context of a 10 | runnable package ... did you forget to add the '-m' flag? 11 | 12 | Usage: {sys.executable} -m plextraktsync {" ".join(sys.argv[1:])} 13 | 14 | """ 15 | ) 16 | sys.exit(2) 17 | 18 | from plextraktsync.cli import cli 19 | 20 | cli() 21 | -------------------------------------------------------------------------------- /plextraktsync/commands/bug_report.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from urllib.parse import urlencode 4 | 5 | from plextraktsync.util.openurl import openurl 6 | 7 | URL_TEMPLATE = "https://github.com/Taxel/PlexTraktSync/issues/new?template=bug.yml&{}" 8 | 9 | 10 | def bug_url(): 11 | from plextraktsync.factory import factory 12 | 13 | config = factory.config 14 | version = factory.version 15 | 16 | q = urlencode( 17 | { 18 | "os": version.py_platform, 19 | "python": version.py_version, 20 | "version": version.full_version, 21 | "config": config.dump(), 22 | } 23 | ) 24 | 25 | return URL_TEMPLATE.format(q) 26 | 27 | 28 | def bug_report(): 29 | url = bug_url() 30 | 31 | print("Opening bug report URL in browser, if that doesn't work open the link manually:") 32 | print("") 33 | print(url) 34 | openurl(url) 35 | -------------------------------------------------------------------------------- /plextraktsync/commands/cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import partial 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import factory 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Generator 10 | from typing import Any 11 | 12 | from requests_cache import CachedRequest, CachedSession 13 | 14 | 15 | def get_sorted_cache(session: CachedSession, sorting: str, reverse: bool): 16 | sorters = { 17 | "size": lambda r: r.size, 18 | "date": lambda r: r.created_at, 19 | "url": lambda r: r.url, 20 | } 21 | sorter = partial(sorted, reverse=reverse, key=sorters[sorting]) 22 | 23 | yield from sorter(session.cache.responses.values()) 24 | 25 | 26 | def responses_by_url(session: CachedSession, url: str) -> Generator[CachedRequest, Any, None]: 27 | return (response for response in session.cache.responses.values() if response.url == url) 28 | 29 | 30 | # https://stackoverflow.com/questions/36106712/how-can-i-limit-iterations-of-a-loop-in-python 31 | def limit_iterator(items, limit: int): 32 | if not limit or limit <= 0: 33 | yield from enumerate(items) 34 | 35 | else: 36 | yield from zip(range(limit), items) 37 | 38 | 39 | def render_xml(data): 40 | from xml.etree import ElementTree 41 | 42 | if not data.strip(): 43 | return None 44 | 45 | root = ElementTree.fromstring(data) 46 | ElementTree.indent(root) 47 | 48 | return ElementTree.tostring(root, encoding="utf8").decode("utf8") 49 | 50 | 51 | def render_json(data): 52 | from json import dumps, loads 53 | 54 | decoded = loads(data) 55 | return dumps(decoded, indent=2) 56 | 57 | 58 | def expire_url(session: CachedSession, url: str): 59 | print(f"Expiring: {url}") 60 | session.cache.delete(urls=[url]) 61 | 62 | 63 | def inspect_url(session: CachedSession, url: str): 64 | matches = responses_by_url(session, url) 65 | for m in matches: 66 | content_type = m.headers["Content-Type"] 67 | if content_type.startswith("text/xml") or content_type.startswith("application/xml"): 68 | print(f"") 69 | for name, value in m.headers.items(): 70 | print(f"") 71 | print(render_xml(m.content)) 72 | elif content_type.startswith("application/json"): 73 | print(f"// {m}") 74 | for name, value in m.headers.items(): 75 | print(f"// {name}: {value}") 76 | print(render_json(m.content)) 77 | else: 78 | print(f"# {content_type}: {m.url}") 79 | for name, value in m.headers.items(): 80 | print(f"# {name}: {value}") 81 | print(m.content) 82 | 83 | 84 | def cache_status(cache): 85 | # https://github.com/requests-cache/requests-cache/commit/35b48cf3486e546a5e4090e8e410b698e8a6b7be#r87356998 86 | return f"Total rows: {len(cache.responses)} responses, {len(cache.redirects)} redirects" 87 | 88 | 89 | def cache(sort: str, limit: int, reverse: bool, expire: bool, url: str): 90 | session = factory.session 91 | print = factory.print 92 | 93 | if expire: 94 | expire_url(session, url) 95 | return 96 | 97 | if url: 98 | inspect_url(session, url) 99 | return 100 | 101 | print(f"Cache status:\n{cache_status(session.cache)}\n") 102 | 103 | print("URLs:") 104 | sorted = get_sorted_cache(session, sort, reverse) 105 | for i, r in limit_iterator(sorted, limit): 106 | print(f"- {i + 1:3d}. {r}", crop=False, overflow="ignore") 107 | -------------------------------------------------------------------------------- /plextraktsync/commands/clear_collections.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.factory import factory, logger 4 | 5 | 6 | def clear_collections(confirm: bool, dry_run: bool, collection: str): 7 | print = factory.print 8 | 9 | if not confirm and not dry_run: 10 | print("You need to pass --confirm or --dry-run option to proceed") 11 | return 12 | 13 | trakt = factory.trakt_api 14 | movies = collection in ["all", "movies"] 15 | shows = collection in ["all", "shows"] 16 | 17 | for movie in trakt.movie_collection if movies else []: 18 | logger.info(f"Deleting from Trakt: {movie}") 19 | if not dry_run: 20 | trakt.remove_from_collection(movie) 21 | 22 | for show in trakt.show_collection if shows else []: 23 | logger.info(f"Deleting from Trakt: {show}") 24 | if not dry_run: 25 | trakt.remove_from_collection(show) 26 | -------------------------------------------------------------------------------- /plextraktsync/commands/compare_libraries.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | 5 | from plexapi.exceptions import NotFound 6 | 7 | from plextraktsync.config.ConfigLoader import ConfigLoader 8 | from plextraktsync.decorators.coro import coro 9 | from plextraktsync.factory import factory 10 | from plextraktsync.plex.PlexApi import PlexApi 11 | from plextraktsync.plex.PlexLibrarySection import PlexLibrarySection 12 | 13 | 14 | def get_plex_from_name(name: str): 15 | try: 16 | server_name, library_name = name.split("/", 2) 17 | except ValueError: 18 | return None 19 | 20 | plex = factory.get_plex_by_name(server_name) 21 | if library_name.isnumeric(): 22 | library = plex.library_sections[int(library_name)] 23 | else: 24 | library = [v for v in plex.library_sections.values() if v.title == library_name][0] 25 | return plex, library 26 | 27 | 28 | def get_walker(plex: PlexApi, library: PlexLibrarySection): 29 | from plextraktsync.plan.WalkConfig import WalkConfig 30 | from plextraktsync.plan.Walker import Walker 31 | 32 | wc = WalkConfig() 33 | wc.add_library(library.title) 34 | 35 | return Walker(plex=plex, trakt=factory.trakt_api, mf=factory.media_factory, config=wc, progressbar=factory.progressbar) 36 | 37 | 38 | async def load_movies(walker1, walker2): 39 | movies1 = set() 40 | async for pm in walker1.get_plex_movies(): 41 | movies1.add(pm) 42 | 43 | movies2 = set() 44 | async for pm in walker2.get_plex_movies(): 45 | movies2.add(pm) 46 | 47 | return movies1, movies2 48 | 49 | 50 | def get_pairs(movies1, movies2): 51 | for pm1 in movies1: 52 | for pm2 in movies2: 53 | if pm1 == pm2: 54 | yield pm1, pm2 55 | 56 | 57 | def cache_key(lib1: PlexLibrarySection, lib2: PlexLibrarySection): 58 | return "-".join( 59 | [ 60 | str(p) 61 | for p in [ 62 | lib1.section._server.machineIdentifier, 63 | lib1.section.key, 64 | lib2.section._server.machineIdentifier, 65 | lib2.section.key, 66 | ] 67 | ] 68 | ) 69 | 70 | 71 | @contextlib.contextmanager 72 | def use_cache(cache_file: str): 73 | cache = dict() # noqa 74 | with contextlib.suppress(FileNotFoundError): 75 | cache = ConfigLoader.load(cache_file) 76 | 77 | yield cache 78 | ConfigLoader.write(cache_file, cache) 79 | 80 | 81 | @coro 82 | async def compare_libraries(library1: str, library2: str, match_watched: bool): 83 | print = factory.print 84 | print(f"Compare contents of '{library1}' and '{library2}'") 85 | plex1, lib1 = get_plex_from_name(library1) 86 | plex2, lib2 = get_plex_from_name(library2) 87 | print(f"Loading contents of {lib1} and {lib2}") 88 | walker1 = get_walker(plex1, lib1) 89 | walker2 = get_walker(plex2, lib2) 90 | 91 | movies1, movies2 = await load_movies(walker1, walker2) 92 | matches = set() 93 | 94 | cache_file = f"compare-cache-{cache_key(lib1, lib2)}.json" 95 | with use_cache(cache_file) as cache: 96 | for pm1, pm2 in get_pairs(movies1, movies2): 97 | cached = cache.get(str(pm1.key)) 98 | if cached: 99 | if not match_watched and cached != "not watched": 100 | continue 101 | if match_watched and not pm1.is_watched: 102 | cache[str(pm1.key)] = "not watched" 103 | continue 104 | 105 | try: 106 | paths1 = set([part.file for part in pm1.parts]) 107 | paths2 = set([part.file for part in pm2.parts]) 108 | except NotFound as e: 109 | print(e) 110 | continue 111 | 112 | print(f"Checking match '{pm1.key}': {pm1.title_link} == {pm2.title_link}") 113 | print(paths1) 114 | print(paths2) 115 | matches.add(pm2) 116 | 117 | print(f"Made {len(matches)} matches") 118 | -------------------------------------------------------------------------------- /plextraktsync/commands/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.factory import factory 4 | 5 | 6 | def dump(data, print=None): 7 | """ 8 | Print config serialized as yaml. 9 | If print is None, return the produced string instead. 10 | """ 11 | from plextraktsync.config.ConfigLoader import ConfigLoader 12 | 13 | dump = ConfigLoader.dump_yaml(None, data) 14 | if print is None: 15 | return dump 16 | print(dump) 17 | 18 | 19 | def config(urls_expire_after: bool, edit: bool, locate: bool): 20 | config = factory.config 21 | print = factory.print 22 | 23 | if edit or locate: 24 | import click 25 | 26 | click.launch(config.config_yml, locate=locate) 27 | return 28 | 29 | if urls_expire_after: 30 | print("# HTTP Cache") 31 | dump(config.http_cache.serialize(), print=print) 32 | return 33 | 34 | print(f"# Config File: {config.config_yml}") 35 | dump(config.serialize(), print=print) 36 | -------------------------------------------------------------------------------- /plextraktsync/commands/download.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from os.path import exists 4 | from pathlib import Path, PureWindowsPath 5 | from typing import TYPE_CHECKING 6 | 7 | from plexapi.exceptions import NotFound 8 | 9 | from plextraktsync.factory import factory, logger 10 | from plextraktsync.util.expand_id import expand_plexid 11 | 12 | if TYPE_CHECKING: 13 | from plextraktsync.plex.PlexApi import PlexApi 14 | from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem 15 | 16 | 17 | def download_media(plex: PlexApi, pm: PlexLibraryItem, savepath: Path): 18 | print(f"Download media for {pm}:") 19 | for index, part in enumerate(pm.parts, start=1): 20 | # Remove directory part (Windows server on Unix) 21 | # plex.download() is able to do that on Unix to Unix server, but not Windows to Unix 22 | filename = PureWindowsPath(part.file).name 23 | filename = Path(savepath, filename) 24 | 25 | if exists(filename): 26 | print(f"Skip existing file: {filename}") 27 | continue 28 | 29 | print(f"Downloading part {index}: {part.file}") 30 | print(f"Saving as {filename}") 31 | plex.download(part, savepath=savepath, filename=filename, showstatus=True) 32 | 33 | 34 | def download_subtitles(plex: PlexApi, pm: PlexLibraryItem, savepath: Path): 35 | print(f"Subtitles for {pm}:") 36 | for index, sub in enumerate(pm.subtitle_streams, start=1): 37 | print(f" Subtitle {index}: ({sub.language}) {sub.title} (codec: {sub.codec}, selected: {sub.selected}, transient: {sub.transient})") 38 | 39 | filename = "".join( 40 | [ 41 | f"{sub.id}. ", 42 | f"{sub.title}" if sub.title else "", 43 | f"{sub.language}." if sub.language else "", 44 | f"{sub.languageCode}.{sub.codec}", 45 | ] 46 | ) 47 | 48 | filename = Path(savepath, filename) 49 | if filename.exists(): 50 | continue 51 | 52 | if not sub.key: 53 | logger.error(f"Subtitle {index}: has no key: Not downloadable") 54 | continue 55 | 56 | try: 57 | plex.download(sub, savepath=savepath, filename=filename, showstatus=True) 58 | except NotFound: 59 | logger.error(f"Subtitle {index}: File doesn't exist: Not downloadable") 60 | continue 61 | print(f"Downloaded: {filename}") 62 | 63 | 64 | def expand_media(input): 65 | plex = factory.plex_api 66 | 67 | for plex_id in expand_plexid(input): 68 | if plex_id.server: 69 | plex = factory.get_plex_by_id(plex_id.server) 70 | pm = plex.fetch_item(plex_id.key) 71 | if not pm: 72 | print(f"Not found: {plex_id} from {plex}. Skipping") 73 | continue 74 | 75 | if pm.type == "season": 76 | for ep in pm.episodes(): 77 | yield plex, ep 78 | return 79 | 80 | yield plex, pm 81 | 82 | 83 | def download(input: list[str], only_subs: bool, target: str): 84 | print = factory.print 85 | 86 | # Expand ~ as HOME 87 | savepath = Path(target).expanduser() 88 | for plex, pm in expand_media(input): 89 | if not pm.has_media: 90 | print(f"{pm} is not a type with media") 91 | continue 92 | 93 | if not only_subs: 94 | download_media(plex, pm, savepath) 95 | 96 | download_subtitles(plex, pm, savepath) 97 | -------------------------------------------------------------------------------- /plextraktsync/commands/imdb_import.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import csv 4 | from dataclasses import dataclass, fields 5 | from functools import cached_property 6 | from typing import TYPE_CHECKING 7 | 8 | from plextraktsync.factory import factory 9 | 10 | if TYPE_CHECKING: 11 | from os import PathLike 12 | 13 | 14 | def read_csv(file: PathLike): 15 | with open(file, newline="") as fh: 16 | reader = csv.DictReader(fh) 17 | for row in reader: 18 | yield Ratings.from_csv(row) 19 | 20 | 21 | @dataclass 22 | class Ratings: 23 | imdb: str 24 | title: str 25 | year: int 26 | rating: int 27 | rate_date: str 28 | type: str 29 | 30 | FIELD_MAPPING = { 31 | "Const": "imdb", 32 | "Your Rating": "rating", 33 | "Date Rated": "rate_date", 34 | "Title": "title", 35 | "Year": "year", 36 | "Title Type": "type", 37 | # 'URL': 'url', 38 | # 'IMDb Rating': 'imdb_rating', 39 | # 'Runtime (mins)': 'runtime', 40 | # 'Genres': 'genres', 41 | # 'Num Votes': 'votes', 42 | # 'Release Date': 'release_date', 43 | # 'Directors': 'directors', 44 | } 45 | 46 | def __post_init__(self): 47 | # cast "int" fields 48 | fieldnames = [f.name for f in fields(self) if f.type == "int"] 49 | for name in fieldnames: 50 | value = self.__dict__[name] 51 | if value is not None and not isinstance(value, int): 52 | self.__dict__[name] = int(value) 53 | 54 | @cached_property 55 | def media_type(self): 56 | if self.type == "tvSeries": 57 | return "show" 58 | 59 | return self.type 60 | 61 | @classmethod 62 | def from_csv(cls, row): 63 | mapping = cls.FIELD_MAPPING 64 | data = {} 65 | for k, v in row.items(): 66 | if k not in mapping: 67 | continue 68 | data[mapping[k]] = v 69 | 70 | return cls(**data) 71 | 72 | 73 | def imdb_import(input: PathLike, dry_run: bool): 74 | trakt = factory.trakt_api 75 | print = factory.print 76 | 77 | for r in read_csv(input): 78 | print(f"Importing [blue]{r.media_type} {r.imdb}[/]: {r.title} ({r.year}), rated at {r.rate_date}") 79 | m = trakt.search_by_id(r.imdb, "imdb", r.media_type) 80 | rating = trakt.rating(m) 81 | if r.rating == rating: 82 | print(f"Rating {rating} already exists") 83 | continue 84 | print(f"{'Would rate' if dry_run else 'Rating'} {m} with {r.rating} (was {rating})") 85 | if not dry_run: 86 | trakt.rate(m, r.rating, r.rate_date) 87 | -------------------------------------------------------------------------------- /plextraktsync/commands/info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.factory import factory 4 | from plextraktsync.path import cache_dir, config_dir, log_dir, servers_config 5 | 6 | 7 | def info(print=factory.print): 8 | version = factory.version 9 | print(f"PlexTraktSync Version: {version.full_version}") 10 | 11 | print(f"Python Version: {version.py_full_version}") 12 | print(f"Plex API Version: {version.plex_api_version}") 13 | print(f"Trakt API Version: {version.trakt_api_version}") 14 | print(f"Cache Dir: {cache_dir}") 15 | print(f"Config Dir: {config_dir}") 16 | print(f"Log Dir: {log_dir}") 17 | 18 | config = factory.config 19 | print(f"Log File: {config.log_file}") 20 | print(f"Cache File: {config.cache_path}.sqlite") 21 | print(f"Config File: {config.config_yml}") 22 | print(f"Servers Config File: {servers_config}") 23 | 24 | print(f"Plex username: {config['PLEX_USERNAME']}") 25 | print(f"Trakt username: {config['TRAKT_USERNAME']}") 26 | 27 | print(f"Plex Server Name: {factory.server_config.name}") 28 | 29 | if factory.has_plex_token: 30 | plex = factory.plex_api 31 | print(f"Plex Server version: {plex.version}, updated at: {plex.updated_at}") 32 | 33 | sections = plex.library_sections 34 | print(f"Enabled {len(sections.keys())} libraries in Plex Server:") 35 | for id, section in sorted(sections.items()): 36 | print(f" - {id}: {section.title_link}") 37 | -------------------------------------------------------------------------------- /plextraktsync/commands/inspect.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from urllib.parse import quote_plus 5 | 6 | from humanize import naturalsize 7 | from plexapi.utils import millisecondToHumanstr 8 | from rich.markup import escape 9 | 10 | from plextraktsync.factory import factory 11 | from plextraktsync.plex.PlexId import PlexId 12 | from plextraktsync.util.expand_id import expand_plexid 13 | 14 | if TYPE_CHECKING: 15 | from plextraktsync.media.Media import Media 16 | from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem 17 | 18 | 19 | def inspect_media(plex_id: PlexId): 20 | plex = plex_id.plex 21 | mf = factory.media_factory 22 | print = factory.print 23 | 24 | print("") 25 | pm: PlexLibraryItem = plex.fetch_item(plex_id) 26 | if not pm: 27 | print(f"Inspecting {plex_id}: Not found from {plex}") 28 | return 29 | 30 | print(f"Inspecting {plex_id}: {pm}") 31 | 32 | print("--- Plex") 33 | if pm.library: 34 | print(f"Library: {pm.library.title}") 35 | 36 | print(f"Plex Web URL: {pm.web_url}") 37 | if pm.discover_url: 38 | print(f"Discover URL: {pm.discover_url}") 39 | 40 | media = pm.item 41 | print(f"Title: {media.title}") 42 | if media.type == "movie" and pm.edition_title: 43 | print(f"Edition Title: {pm.edition_title}") 44 | if pm.has_media: 45 | print(f"Media.Duration: {pm.duration}") 46 | print(f"Media.Type: '{media.type}'") 47 | print(f"Media.Guid: '{media.guid}'") 48 | if not pm.is_legacy_agent: 49 | print(f"Media.Guids: {media.guids}") 50 | 51 | if not pm.is_discover and media.type in ["episode", "movie"]: 52 | audio = pm.audio_streams[0] 53 | print(f"Audio: '{audio.audioChannelLayout}', '{audio.displayTitle}'") 54 | 55 | video = pm.video_streams[0] 56 | print(f"Video: '{video.codec}'") 57 | 58 | print("Subtitles:") 59 | for index, subtitle in enumerate(pm.subtitle_streams, start=1): 60 | print( 61 | f" Subtitle {index}: ({subtitle.language}) {subtitle.title}" 62 | f" (codec: {subtitle.codec}, selected: {subtitle.selected}, transient: {subtitle.transient})" 63 | ) 64 | 65 | print("Parts:") 66 | pm.item.reload(checkFiles=True) 67 | for index, part in enumerate(pm.parts, start=1): 68 | size = naturalsize(part.size, binary=True) 69 | file_link = f"[link=file://{quote_plus(part.file)}]{escape(part.file)}[/link]" 70 | print(f" Part {index} (exists: {part.exists}): {file_link} {size}") 71 | 72 | print("Markers:") 73 | for marker in pm.markers: 74 | start = millisecondToHumanstr(marker.start) 75 | end = millisecondToHumanstr(marker.end) 76 | print(f" {marker.type}: {start} - {end}") 77 | 78 | print("Guids:") 79 | for guid in pm.guids: 80 | print(f" Guid: {guid.provider_link}, Id: {guid.id}, Provider: '{guid.provider}'") 81 | 82 | print(f"Metadata: {pm.to_json()}") 83 | print(f"Played on Plex: {pm.is_watched}") 84 | print(f"Plex Play Date: {pm.seen_date}") 85 | 86 | history = plex.history(media, device=True, account=True) if not pm.is_discover else [] 87 | print("Plex play history:") 88 | for h in history: 89 | d = h.device 90 | # handle cases like "local" for offline plays 91 | if d.name == "" and d.platform == "": 92 | dn = h.device.clientIdentifier 93 | else: 94 | dn = f"{d.name} with {d.platform}" 95 | print(f"- {h.viewedAt} {h}: by {h.account.name} on {dn}") 96 | 97 | print("--- Trakt") 98 | m: Media = mf.resolve_any(pm) 99 | if not m: 100 | print("Trakt: No match found") 101 | return 102 | 103 | print(f"Trakt: {m.trakt_url}") 104 | print(f"Plex Rating: {m.plex_rating}") 105 | print(f"Trakt Rating: {m.trakt_rating}") 106 | if pm.has_media: 107 | print(f"Watched on Trakt: {m.watched_on_trakt}") 108 | print(f"Collected on Trakt: {m.is_collected}") 109 | 110 | 111 | def inspect(inputs: list[str]): 112 | print = factory.print 113 | print(f"PlexTraktSync [{factory.version.full_version}]") 114 | 115 | for plex_id in expand_plexid(inputs): 116 | inspect_media(plex_id) 117 | -------------------------------------------------------------------------------- /plextraktsync/commands/login.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.commands.plex_login import plex_login_autoconfig 4 | from plextraktsync.commands.trakt_login import has_trakt_token, trakt_login_autoconfig 5 | from plextraktsync.factory import factory 6 | from plextraktsync.style import highlight, success 7 | 8 | 9 | def ensure_login(): 10 | if not factory.has_plex_token: 11 | plex_login_autoconfig() 12 | 13 | if not has_trakt_token(): 14 | trakt_login_autoconfig() 15 | 16 | 17 | def login(): 18 | print = factory.print 19 | 20 | print(highlight("Checking Plex and Trakt login credentials existence")) 21 | print("") 22 | print("It will not test if the credentials are valid, only that they are present.") 23 | print('If you need to re-login use "plex-login" or "trakt-login" commands respectively.') 24 | print("") 25 | ensure_login() 26 | print(success("Done!")) 27 | -------------------------------------------------------------------------------- /plextraktsync/commands/self_update.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.factory import factory 4 | from plextraktsync.util.execp import execp 5 | 6 | 7 | def has_previous_pr(pr: int): 8 | from plextraktsync.util.packaging import pipx_installed 9 | 10 | package = pipx_installed(f"plextraktsync@{pr}") 11 | 12 | return package is not None 13 | 14 | 15 | def pr_number() -> int | None: 16 | """ 17 | Check if current executable is named plextraktsync@ 18 | """ 19 | 20 | import sys 21 | 22 | try: 23 | pr = sys.argv[0].split("@")[1] 24 | except IndexError: 25 | return None 26 | 27 | if pr.isnumeric(): 28 | return int(pr) 29 | return None 30 | 31 | 32 | def self_update(pr: int): 33 | print = factory.print 34 | 35 | if not pr: 36 | pr = pr_number() 37 | if pr: 38 | print(f"Installed as pr #{pr}, enabling pr mode") 39 | 40 | if pr: 41 | if has_previous_pr(pr): 42 | # Uninstall because pipx doesn't update otherwise: 43 | # - https://github.com/pypa/pipx/issues/902 44 | print(f"Uninstalling previous plextraktsync@{pr}") 45 | execp(f"pipx uninstall plextraktsync@{pr}") 46 | 47 | print(f"Updating PlexTraktSync to the pull request #{pr} version using pipx") 48 | execp(f"pipx install --suffix=@{pr} --force git+https://github.com/Taxel/PlexTraktSync@refs/pull/{pr}/head") 49 | return 50 | 51 | print("Updating PlexTraktSync to the latest version using pipx") 52 | execp("pipx upgrade PlexTraktSync") 53 | -------------------------------------------------------------------------------- /plextraktsync/commands/sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.commands.login import ensure_login 4 | from plextraktsync.decorators.coro import coro 5 | from plextraktsync.decorators.measure_time import measure_time 6 | from plextraktsync.factory import factory, logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @coro 12 | async def run_async(runner, **kwargs): 13 | await runner.sync(**kwargs) 14 | 15 | 16 | def sync( 17 | sync_option: str, 18 | library: str, 19 | show: str, 20 | movie: str, 21 | ids: list[str], 22 | server: str, 23 | batch_delay: int, 24 | dry_run: bool, 25 | no_progress_bar: bool, 26 | ): 27 | """ 28 | Perform sync between Plex and Trakt 29 | """ 30 | 31 | logger.info(f"PlexTraktSync [{factory.version.full_version}]") 32 | 33 | movies = sync_option in ["all", "movies"] 34 | shows = sync_option in ["all", "tv", "shows"] 35 | watchlist = sync_option in ["all", "watchlist"] 36 | 37 | config = factory.run_config.update( 38 | dry_run=dry_run, 39 | ) 40 | if server: 41 | logger.warning('"plextraktsync sync --server=" is deprecated use "plextraktsync --server= sync"') 42 | config.update(server=server) 43 | if no_progress_bar: 44 | logger.warning('"plextraktsync sync --no-progress-bar" is deprecated use "plextraktsync --no-progressbar sync"') 45 | config.update(progress=False) 46 | if batch_delay: 47 | logger.warning('"plextraktsync sync --batch-delay=" is deprecated use "plextraktsync ---batch-delay= sync"') 48 | config.update(batch_delay=batch_delay) 49 | 50 | ensure_login() 51 | wc = factory.walk_config.update(movies=movies, shows=shows, watchlist=watchlist) 52 | w = factory.walker 53 | 54 | if ids: 55 | for id in ids: 56 | wc.add_id(id) 57 | if library: 58 | wc.add_library(library) 59 | if show: 60 | wc.add_show(show) 61 | if movie: 62 | wc.add_movie(movie) 63 | 64 | if not wc.is_valid: 65 | print("Nothing to sync, this is likely due conflicting options given.") 66 | return 67 | 68 | with measure_time("Completed full sync"): 69 | runner = factory.sync 70 | if runner.config.need_library_walk: 71 | w.print_plan(print=logger.info) 72 | if dry_run: 73 | logger.info("Enabled dry-run mode: not making actual changes") 74 | run_async(runner, walker=w, dry_run=config.dry_run) 75 | -------------------------------------------------------------------------------- /plextraktsync/commands/trakt_login.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from json import JSONDecodeError 4 | from os.path import exists 5 | from typing import TYPE_CHECKING 6 | 7 | from rich.prompt import Prompt 8 | from trakt.errors import ForbiddenException 9 | 10 | from plextraktsync.factory import factory 11 | from plextraktsync.path import pytrakt_file 12 | from plextraktsync.style import error, prompt, success, title 13 | 14 | if TYPE_CHECKING: 15 | from plextraktsync.trakt.TraktApi import TraktApi 16 | 17 | PROMPT_TRAKT_CLIENT_ID = prompt("Please enter your client id") 18 | PROMPT_TRAKT_CLIENT_SECRET = prompt("Please enter your client secret") 19 | TRAKT_LOGIN_SUCCESS = success("You are now logged into Trakt. Your Trakt credentials have been added in .env and .pytrakt.json files.") 20 | 21 | 22 | def trakt_authenticate(api: TraktApi): 23 | print = factory.print 24 | 25 | print(title("Sign in to Trakt")) 26 | 27 | print("If you do not have a Trakt client ID and secret:") 28 | print(" 1 - Open https://trakt.tv/oauth/applications on any computer") 29 | print(" 2 - Login to your Trakt account") 30 | print(" 3 - Press the NEW APPLICATION button") 31 | print(" 4 - Set the NAME field = plex") 32 | print(" 5 - Set the REDIRECT URL field = urn:ietf:wg:oauth:2.0:oob") 33 | print(" 6 - Press the SAVE APP button") 34 | print("") 35 | 36 | while True: 37 | client_id = Prompt.ask(PROMPT_TRAKT_CLIENT_ID) 38 | client_secret = Prompt.ask(PROMPT_TRAKT_CLIENT_SECRET, password=True) 39 | 40 | print("Attempting to authenticate with Trakt") 41 | try: 42 | return api.device_auth(client_id=client_id, client_secret=client_secret) 43 | except (ForbiddenException, JSONDecodeError) as e: 44 | print(error(f"Log in to Trakt failed: {e}, Try again.")) 45 | 46 | 47 | def has_trakt_token(): 48 | if not exists(pytrakt_file): 49 | return False 50 | 51 | CONFIG = factory.config 52 | return CONFIG["TRAKT_USERNAME"] is not None 53 | 54 | 55 | def trakt_login_autoconfig(): 56 | login() 57 | 58 | 59 | def trakt_login(): 60 | """ 61 | Log in to Trakt Account to obtain Access Token. 62 | """ 63 | login() 64 | 65 | 66 | def login(): 67 | print = factory.print 68 | api = factory.trakt_api 69 | trakt_authenticate(api) 70 | user = api.me.username 71 | 72 | CONFIG = factory.config 73 | CONFIG["TRAKT_USERNAME"] = user 74 | CONFIG.save() 75 | 76 | print(TRAKT_LOGIN_SUCCESS) 77 | -------------------------------------------------------------------------------- /plextraktsync/commands/unmatched.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.commands.login import ensure_login 4 | from plextraktsync.decorators.coro import coro 5 | from plextraktsync.factory import factory 6 | 7 | 8 | @coro 9 | async def unmatched(no_progress_bar: bool, local: bool): 10 | factory.run_config.update(progressbar=not no_progress_bar) 11 | ensure_login() 12 | plex = factory.plex_api 13 | mf = factory.media_factory 14 | wc = factory.walk_config 15 | walker = factory.walker 16 | 17 | if not wc.is_valid: 18 | print("Nothing to scan, this is likely due conflicting options given.") 19 | return 20 | 21 | failed = [] 22 | if local: 23 | async for pm in walker.get_plex_movies(): 24 | if all(guid.local for guid in pm.guids): 25 | failed.append(pm) 26 | else: 27 | async for pm in walker.get_plex_movies(): 28 | movie = mf.resolve_any(pm) 29 | if not movie: 30 | failed.append(pm) 31 | 32 | for i, pm in enumerate(failed): 33 | p = pm.item 34 | url = plex.media_url(pm) 35 | print("=", i, "=" * 80) 36 | print(f"No match: {pm}") 37 | print(f"URL: {url}") 38 | print(f"Title: {p.title}") 39 | print(f"Year: {p.year}") 40 | print(f"Updated At: {p.updatedAt}") 41 | for location in p.locations: 42 | print(f"Location: {location}") 43 | 44 | print("") 45 | -------------------------------------------------------------------------------- /plextraktsync/commands/watch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.factory import factory 4 | from plextraktsync.watch.events import ( 5 | ActivityNotification, 6 | Error, 7 | PlaySessionStateNotification, 8 | ServerStarted, 9 | TimelineEntry, 10 | ) 11 | 12 | 13 | def watch(server: str): 14 | factory.run_config.update( 15 | server=server, 16 | ) 17 | ws = factory.web_socket_listener 18 | updater = factory.watch_state_updater 19 | 20 | ws.on(ServerStarted, updater.on_start) 21 | ws.on( 22 | PlaySessionStateNotification, 23 | updater.on_play, 24 | state=["playing", "stopped", "paused"], 25 | ) 26 | ws.on( 27 | ActivityNotification, 28 | updater.on_activity, 29 | type="library.refresh.items", 30 | event="ended", 31 | progress=100, 32 | ) 33 | ws.on(TimelineEntry, updater.on_delete, state=9, metadata_state="deleted") 34 | ws.on(Error, updater.on_error) 35 | 36 | ws.listen() 37 | -------------------------------------------------------------------------------- /plextraktsync/commands/watched_shows.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from rich.table import Table 4 | 5 | from plextraktsync.factory import factory 6 | 7 | 8 | def watched_shows(): 9 | trakt = factory.trakt_api 10 | print = factory.print 11 | 12 | table = Table(show_header=True, header_style="bold magenta", title="Watched shows on Trakt") 13 | table.add_column("Id", style="dim", width=6) 14 | table.add_column("Slug") 15 | table.add_column("Seasons", justify="right") 16 | for show_id, progress in sorted(trakt.watched_shows.shows.items()): 17 | id = f"[link=https://trakt.tv/shows/{show_id}]{show_id}[/]" 18 | slug = f"[link=https://trakt.tv/shows/{progress.slug}]{progress.slug}[/]" 19 | table.add_row(id, slug, str(len(progress.seasons))) 20 | 21 | print(table) 22 | -------------------------------------------------------------------------------- /plextraktsync/config.default.yml: -------------------------------------------------------------------------------- 1 | cache: 2 | path: $PTS_CACHE_DIR/trakt_cache 3 | 4 | # You may want to use per server libraries config instead: 5 | # - https://github.com/Taxel/PlexTraktSync#libraries 6 | excluded-libraries: 7 | - Private 8 | - Family Holidays 9 | 10 | config: 11 | dotenv_override: true 12 | 13 | plex: 14 | timeout: 30 15 | 16 | logging: 17 | append: true 18 | # Whether to show timestamps in console messages 19 | console_time: false 20 | debug: false 21 | filename: plextraktsync.log 22 | # Additional logger names to apply filtering 23 | filter_loggers: 24 | # - plexapi 25 | # - requests_cache.backends 26 | # - requests_cache.backends.base 27 | # - requests_cache.backends.sqlite 28 | # - requests_cache.policy.actions 29 | # - requests_cache.session 30 | # - trakt.core 31 | # - urllib3.connectionpool 32 | filter: 33 | # # Filter out all messages with level WARNING 34 | # - level: WARNING 35 | # # Filter out message with level WARNING and containing a text 36 | # - level: WARNING 37 | # message: "not found on Trakt" 38 | # - message: "because provider local has no external Id" 39 | # - message: "because provider none has no external Id" 40 | # - message: "Retry using search for specific Plex Episode" 41 | # # Filter out messages by requests_cache 42 | # - name: requests_cache.backends 43 | # - name: requests_cache.backends.base 44 | # - name: requests_cache.backends.sqlite 45 | # - name: requests_cache.policy.actions 46 | # - name: requests_cache.session 47 | 48 | # settings for 'sync' command 49 | sync: 50 | # Setting for whether ratings from one platform should have priority. 51 | # Valid values are trakt, plex or none. (default: plex) 52 | # none - No rating priority. Existing ratings are not overwritten. 53 | # trakt - Trakt ratings have priority. Existing Plex ratings are overwritten. 54 | # plex - Plex ratings have priority. Existing Trakt ratings are overwritten. 55 | rating_priority: plex 56 | 57 | plex_to_trakt: 58 | collection: false 59 | # Clear collected state of items not present in Plex 60 | clear_collected: false 61 | ratings: true 62 | watched_status: true 63 | # If plex_to_trakt watchlist=false and trakt_to_plex watchlist=true 64 | # the Plex watchlist will be overwritten by Trakt watchlist 65 | watchlist: true 66 | trakt_to_plex: 67 | liked_lists: true 68 | ratings: true 69 | watched_status: true 70 | # If trakt_to_plex watchlist=false and plex_to_trakt watchlist=true 71 | # the Trakt watchlist will be overwritten by Plex watchlist 72 | watchlist: true 73 | # If you prefer to fetch trakt watchlist as a playlist instead of 74 | # plex watchlist, toggle this to true (is read only if watchlist=true) 75 | watchlist_as_playlist: false 76 | # Sync Play Progress from Trakt to Plex 77 | playback_status: false 78 | 79 | # Configuration for liked lists 80 | liked_lists: 81 | # Whether to keep watched items in the list 82 | keep_watched: true 83 | 84 | # Configuration override for specific lists 85 | #liked_list: 86 | # "Saw Collection": 87 | # keep_watched: true 88 | 89 | # settings for 'watch' command 90 | watch: 91 | add_collection: false 92 | remove_collection: false 93 | # what video watched percentage (0 to 100) triggers the watched status 94 | scrobble_threshold: 80 95 | # true to scrobble only what's watched by you, false for all your PMS users 96 | username_filter: true 97 | # Show the progress bar of played media in terminal 98 | media_progressbar: true 99 | # Clients to ignore when listening Play events 100 | ignore_clients: ~ 101 | 102 | xbmc-providers: 103 | movies: imdb 104 | shows: tvdb 105 | ##### Advanced settings below this line, don't edit unless you know what you're doing ##### 106 | #http_cache: 107 | # https://requests-cache.readthedocs.io/en/main/user_guide/expiration.html#url-patterns 108 | # https://requests-cache.readthedocs.io/en/main/user_guide/expiration.html#expiration-values 109 | # 110 | # The value is seconds to cache. 111 | # Or one of the following special values: 112 | # - DO_NOT_CACHE: Skip both reading from and writing to the cache 113 | # - EXPIRE_IMMEDIATELY: Consider the response already expired, but potentially usable 114 | # - NEVER_EXPIRE: Store responses indefinitely 115 | # 116 | # The value can be also suffixed with a time unit: 117 | # - 5m, 1h, 3d 118 | # See full documentation at: 119 | # - https://github.com/wroberts/pytimeparse#pytimeparse-time-expression-parser 120 | # 121 | # NOTE: If there is more than one match, the first match will be used in the order they are defined 122 | # policy: 123 | # "*.trakt.tv/users/me": 1d 124 | # "*.trakt.tv/users/likes/lists": DO_NOT_CACHE 125 | 126 | # vim:ts=2:sw=2:et 127 | -------------------------------------------------------------------------------- /plextraktsync/config/ConfigLoader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class ConfigLoader: 5 | @classmethod 6 | def load(cls, path: str): 7 | if path.endswith(".yml"): 8 | return cls.load_yaml(path) 9 | if path.endswith(".json"): 10 | return cls.load_json(path) 11 | raise RuntimeError(f"Unknown file type: {path}") 12 | 13 | @classmethod 14 | def write(cls, path: str, config): 15 | if path.endswith(".yml"): 16 | return cls.write_yaml(path, config) 17 | if path.endswith(".json"): 18 | return cls.write_json(path, config) 19 | raise RuntimeError(f"Unknown file type: {path}") 20 | 21 | @staticmethod 22 | def copy(src: str, dst: str): 23 | import shutil 24 | 25 | shutil.copyfile(src, dst) 26 | 27 | @staticmethod 28 | def rename(src: str, dst: str): 29 | from os import rename 30 | 31 | rename(src, dst) 32 | 33 | @staticmethod 34 | def load_json(path: str): 35 | from json import JSONDecodeError, load 36 | 37 | with open(path, encoding="utf-8") as fp: 38 | try: 39 | config = load(fp) 40 | except JSONDecodeError as e: 41 | raise RuntimeError(f"Unable to parse {path}: {e}") 42 | return config 43 | 44 | @staticmethod 45 | def load_yaml(path: str): 46 | import yaml 47 | 48 | with open(path, encoding="utf-8") as fp: 49 | try: 50 | config = yaml.safe_load(fp) 51 | except yaml.YAMLError as e: 52 | raise RuntimeError(f"Unable to parse {path}: {e}") 53 | return config 54 | 55 | @staticmethod 56 | def write_json(path: str, config): 57 | import json 58 | 59 | with open(path, "w", encoding="utf-8") as fp: 60 | fp.write(json.dumps(config, indent=4)) 61 | 62 | @classmethod 63 | def write_yaml(cls, path: str, config): 64 | with open(path, "w", encoding="utf-8") as fp: 65 | cls.dump_yaml(fp, config) 66 | 67 | @staticmethod 68 | def dump_yaml(fp, config): 69 | import yaml 70 | 71 | return yaml.dump(config, fp, allow_unicode=True, indent=2, sort_keys=False) 72 | -------------------------------------------------------------------------------- /plextraktsync/config/ConfigMergeMixin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class ConfigMergeMixin: 5 | # https://stackoverflow.com/a/20666342/2314626 6 | def merge(self, source, destination): 7 | for key, value in source.items(): 8 | if isinstance(value, dict): 9 | # get node or create one 10 | node = destination.setdefault(key, {}) 11 | self.merge(value, node) 12 | else: 13 | destination[key] = value 14 | 15 | return destination 16 | -------------------------------------------------------------------------------- /plextraktsync/config/PlexServerConfig.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import asdict, dataclass 4 | 5 | 6 | @dataclass 7 | class PlexServerConfig: 8 | """ 9 | Class to hold single server config 10 | """ 11 | 12 | name: str 13 | token: str 14 | urls: list[str] 15 | # The machineIdentifier value of this server 16 | id: str = None 17 | config: dict = None 18 | 19 | def asdict(self): 20 | data = asdict(self) 21 | del data["name"] 22 | 23 | return data 24 | 25 | @property 26 | def sync_config(self): 27 | return self.get_section("sync", {}) 28 | 29 | @property 30 | def libraries(self): 31 | return self.get_section("libraries") 32 | 33 | @property 34 | def excluded_libraries(self): 35 | return self.get_section("excluded-libraries") 36 | 37 | def get_section(self, section: str, default=None): 38 | if self.config is None: 39 | return default 40 | 41 | return self.config.get(section, default) 42 | -------------------------------------------------------------------------------- /plextraktsync/config/RunConfig.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class RunConfig: 8 | """ 9 | Class to hold runtime config parameters 10 | """ 11 | 12 | dry_run: bool = False 13 | batch_delay: int = 5 14 | progressbar: bool = True 15 | cache: bool = True 16 | server: str | None = None 17 | 18 | def update(self, **kwargs): 19 | for name, value in kwargs.items(): 20 | self.__setattr__(name, value) 21 | 22 | return self 23 | -------------------------------------------------------------------------------- /plextraktsync/config/ServerConfigFactory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from os.path import exists 4 | 5 | from plextraktsync.config.ConfigLoader import ConfigLoader 6 | from plextraktsync.config.ConfigMergeMixin import ConfigMergeMixin 7 | from plextraktsync.config.PlexServerConfig import PlexServerConfig 8 | from plextraktsync.path import servers_config 9 | 10 | 11 | class ServerConfigFactory(ConfigMergeMixin): 12 | config_path = servers_config 13 | loaded = False 14 | 15 | def __init__(self): 16 | self.servers = {} 17 | 18 | def get_server(self, name: str): 19 | self.load() 20 | try: 21 | return PlexServerConfig(name=name, **self.servers[name]) 22 | except KeyError: 23 | raise RuntimeError(f"Server with name '{name}' is not defined") 24 | 25 | def server_by_id(self, id: str): 26 | self.load() 27 | for name, server in self.servers.items(): 28 | if "id" in server and id == server["id"]: 29 | return self.get_server(name) 30 | 31 | def load(self): 32 | if self.loaded: 33 | return self 34 | self.loaded = True 35 | loader = ConfigLoader() 36 | 37 | if exists(self.config_path): 38 | servers = loader.load(self.config_path) 39 | self.merge(servers["servers"], self.servers) 40 | else: 41 | self.migrate() 42 | return self 43 | 44 | def migrate(self): 45 | from plextraktsync.factory import factory 46 | 47 | config = factory.config 48 | self.add_server( 49 | name="default", 50 | urls=[ 51 | config["PLEX_BASEURL"], 52 | config["PLEX_LOCALURL"], 53 | ], 54 | token=config["PLEX_TOKEN"], 55 | ) 56 | self.save() 57 | config["PLEX_SERVER"] = "default" 58 | config.save() 59 | logger = factory.logger 60 | logger.warning(f"Added default server to {self.config_path}") 61 | 62 | def save(self): 63 | loader = ConfigLoader() 64 | loader.write( 65 | self.config_path, 66 | { 67 | "servers": self.servers, 68 | }, 69 | ) 70 | 71 | def add_server(self, **kwargs): 72 | self.load() 73 | config = PlexServerConfig(**kwargs) 74 | servers = { 75 | config.name: config.asdict(), 76 | } 77 | self.merge(servers, self.servers) 78 | -------------------------------------------------------------------------------- /plextraktsync/config/SyncConfig.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from typing import Any 8 | 9 | from plextraktsync.config.Config import Config 10 | from plextraktsync.config.PlexServerConfig import PlexServerConfig 11 | 12 | 13 | class SyncConfig: 14 | config: dict[str, Any] 15 | 16 | def __init__(self, config: Config, server_config: PlexServerConfig): 17 | self.config = dict(config["sync"]) 18 | self.liked_lists = config["liked_lists"] 19 | self.liked_lists_overrides = config.get("liked_list", {}) 20 | self.server_config = server_config.sync_config 21 | 22 | def __getitem__(self, key): 23 | return self.config[key] 24 | 25 | def __contains__(self, key): 26 | return key in self.config 27 | 28 | def get(self, section, key): 29 | if section in self.server_config and key in self.server_config[section]: 30 | return self.server_config[section][key] 31 | 32 | return self[key] if key in self else self[section][key] 33 | 34 | @cached_property 35 | def trakt_to_plex(self): 36 | return { 37 | "watched_status": self.get("trakt_to_plex", "watched_status"), 38 | "ratings": self.get("trakt_to_plex", "ratings"), 39 | "liked_lists": self.get("trakt_to_plex", "liked_lists"), 40 | "watchlist": self.get("trakt_to_plex", "watchlist"), 41 | "watchlist_as_playlist": self.get("trakt_to_plex", "watchlist_as_playlist"), 42 | "playback_status": self.get("trakt_to_plex", "playback_status"), 43 | } 44 | 45 | @cached_property 46 | def plex_to_trakt(self): 47 | return { 48 | "watched_status": self.get("plex_to_trakt", "watched_status"), 49 | "ratings": self.get("plex_to_trakt", "ratings"), 50 | "collection": self.get("plex_to_trakt", "collection"), 51 | "watchlist": self.get("plex_to_trakt", "watchlist"), 52 | "clear_collected": self.get("plex_to_trakt", "clear_collected"), 53 | } 54 | 55 | @cached_property 56 | def sync_ratings(self): 57 | return self.trakt_to_plex["ratings"] or self.plex_to_trakt["ratings"] 58 | 59 | @cached_property 60 | def clear_collected(self): 61 | return self.plex_to_trakt["collection"] and self.plex_to_trakt["clear_collected"] 62 | 63 | @cached_property 64 | def sync_watched_status(self): 65 | return self.trakt_to_plex["watched_status"] or self.plex_to_trakt["watched_status"] 66 | 67 | @property 68 | def liked_lists_keep_watched(self): 69 | return self.liked_lists["keep_watched"] 70 | 71 | @cached_property 72 | def sync_playback_status(self): 73 | return self.trakt_to_plex["playback_status"] 74 | 75 | @cached_property 76 | def update_plex_wl(self): 77 | return self.trakt_to_plex["watchlist"] and not self.trakt_to_plex["watchlist_as_playlist"] 78 | 79 | @cached_property 80 | def update_plex_wl_as_pl(self): 81 | return self.trakt_to_plex["watchlist"] and self.trakt_to_plex["watchlist_as_playlist"] 82 | 83 | @cached_property 84 | def update_trakt_wl(self): 85 | return self.plex_to_trakt["watchlist"] 86 | 87 | @cached_property 88 | def sync_wl(self): 89 | return self.update_plex_wl or self.update_trakt_wl 90 | 91 | @cached_property 92 | def sync_liked_lists(self): 93 | return self.trakt_to_plex["liked_lists"] 94 | 95 | @cached_property 96 | def sync_watchlists(self): 97 | return any( 98 | [ 99 | self.plex_to_trakt["watchlist"], 100 | self.trakt_to_plex["watchlist"], 101 | ] 102 | ) 103 | 104 | @cached_property 105 | def need_library_walk(self): 106 | return any( 107 | [ 108 | self.update_plex_wl_as_pl, 109 | self.sync_watched_status, 110 | self.sync_ratings, 111 | self.plex_to_trakt["collection"], 112 | self.sync_liked_lists, 113 | self.sync_playback_status, 114 | ] 115 | ) 116 | -------------------------------------------------------------------------------- /plextraktsync/config/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Platform name to identify our application 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | PLEX_PLATFORM = "PlexTraktSync" 8 | 9 | """ 10 | Constant in seconds for how much to wait between Trakt POST API calls. 11 | """ 12 | TRAKT_POST_DELAY = 1.1 13 | -------------------------------------------------------------------------------- /plextraktsync/decorators/account_limit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decorator import decorator 4 | from trakt.errors import AccountLimitExceeded 5 | 6 | from plextraktsync.factory import logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | bypassed_lists = set() 11 | 12 | 13 | # https://forums.trakt.tv/t/freemium-experience-more-features-for-all-with-usage-limits/41641 14 | @decorator 15 | def account_limit(fn, *args, **kwargs): 16 | list_name = fn.__name__.replace("add_to_", "") 17 | if list_name in bypassed_lists: 18 | return None 19 | 20 | try: 21 | return fn(*args, **kwargs) 22 | except AccountLimitExceeded as e: 23 | logger.error(f"Trakt Error: {e}") 24 | logger.warning( 25 | f"Account Limit Exceeded for Trakt {list_name}: {e.account_limit} items. " 26 | f"Consider disabling {list_name} sync or upgrading your Trakt account." 27 | ) 28 | logger.debug(e.details) 29 | 30 | if list_name: 31 | bypassed_lists.add(list_name) 32 | return None 33 | -------------------------------------------------------------------------------- /plextraktsync/decorators/coro.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | from decorator import decorator 6 | 7 | 8 | @decorator 9 | def coro(f, *args, **kwargs): 10 | """ 11 | Decorator to get started with async/await with click 12 | 13 | https://github.com/pallets/click/issues/85#issuecomment-503464628 14 | """ 15 | return asyncio.run(f(*args, **kwargs)) 16 | -------------------------------------------------------------------------------- /plextraktsync/decorators/flatten.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decorator import decorator 4 | 5 | 6 | @decorator 7 | def flatten_list(method, *args, **kwargs): 8 | return list(method(*args, **kwargs)) 9 | 10 | 11 | @decorator 12 | def flatten_dict(method, *args, **kwargs): 13 | return dict(method(*args, **kwargs)) 14 | 15 | 16 | @decorator 17 | def flatten_set(method, *args, **kwargs): 18 | return set(method(*args, **kwargs)) 19 | -------------------------------------------------------------------------------- /plextraktsync/decorators/measure_time.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from contextlib import contextmanager 5 | from datetime import timedelta 6 | from time import monotonic 7 | 8 | from humanize.time import precisedelta 9 | 10 | from plextraktsync.factory import logging 11 | 12 | default_logger = logging.getLogger(__name__) 13 | 14 | 15 | @contextmanager 16 | def measure_time(message, *args, level=logging.INFO, logger=None, **kwargs): 17 | start = monotonic() 18 | yield 19 | delta = monotonic() - start 20 | 21 | if inspect.ismethod(logger): 22 | log = logger 23 | else: 24 | 25 | def log(*a, **kw): 26 | (logger or default_logger).log(level, *a, **kw) 27 | 28 | minimum_unit = "microseconds" if delta < 1 else "seconds" 29 | log( 30 | f"{message} in %s", 31 | precisedelta(timedelta(seconds=delta), minimum_unit=minimum_unit), 32 | *args, 33 | **kwargs, 34 | ) 35 | -------------------------------------------------------------------------------- /plextraktsync/decorators/nocache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decorator import decorator 4 | 5 | from plextraktsync.factory import factory 6 | 7 | session = factory.session 8 | 9 | 10 | @decorator 11 | def nocache(method, *args, **kwargs): 12 | with session.cache_disabled(): 13 | return method(*args, **kwargs) 14 | -------------------------------------------------------------------------------- /plextraktsync/decorators/rate_limit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import sleep 4 | 5 | from click import ClickException 6 | from decorator import decorator 7 | from trakt.errors import RateLimitException 8 | 9 | from plextraktsync.factory import logging 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | # https://trakt.docs.apiary.io/#introduction/rate-limiting 15 | @decorator 16 | def rate_limit(fn, retries=5, *args, **kwargs): 17 | retry = 0 18 | while True: 19 | try: 20 | return fn(*args, **kwargs) 21 | except RateLimitException as e: 22 | if retry == retries: 23 | logger.error(f"Trakt Error: {e}") 24 | logger.error(f"Last call: {fn.__module__}.{fn.__name__}({args[1:]}, {kwargs})") 25 | raise ClickException("Trakt API didn't respond properly, script will abort now. Please try again later.") 26 | 27 | seconds = e.retry_after 28 | retry += 1 29 | logger.warning(f"{e} for {fn.__module__}.{fn.__name__}(), retrying after {seconds} seconds (try: {retry}/{retries})") 30 | logger.debug(e.details) 31 | sleep(seconds) 32 | -------------------------------------------------------------------------------- /plextraktsync/decorators/retry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import sleep 4 | 5 | from click import ClickException 6 | from decorator import decorator 7 | from plexapi.exceptions import BadRequest 8 | from requests import ReadTimeout, RequestException 9 | from trakt.errors import ( 10 | BadResponseException, 11 | TraktBadGateway, 12 | TraktInternalException, 13 | TraktUnavailable, 14 | ) 15 | 16 | from plextraktsync.factory import logging 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @decorator 22 | def retry(fn, retries=5, *args, **kwargs): 23 | count = 0 24 | while True: 25 | try: 26 | return fn(*args, **kwargs) 27 | except ( 28 | BadRequest, 29 | BadResponseException, 30 | ReadTimeout, 31 | RequestException, 32 | TraktBadGateway, 33 | TraktUnavailable, 34 | TraktInternalException, 35 | ) as e: 36 | if count == retries: 37 | logger.error(f"Error: {e}") 38 | 39 | if isinstance(e, BadResponseException): 40 | logger.error(f"Details: {e.details}") 41 | if isinstance(e, TraktInternalException): 42 | logger.error(f"Error message: {e.error_message}") 43 | 44 | logger.error(f"Last call: {fn.__module__}.{fn.__name__}({args[1:]}, {kwargs})") 45 | raise ClickException("API didn't respond properly, script will abort now. Please try again later.") 46 | 47 | seconds = 1 + count 48 | count += 1 49 | logger.warning(f"{e} for {fn.__module__}.{fn.__name__}(), retrying after {seconds} seconds (try: {count}/{retries})") 50 | sleep(seconds) 51 | -------------------------------------------------------------------------------- /plextraktsync/decorators/time_limit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decorator import decorator 4 | 5 | from plextraktsync.config import TRAKT_POST_DELAY 6 | from plextraktsync.util.Timer import Timer 7 | 8 | timer = Timer(TRAKT_POST_DELAY) 9 | 10 | 11 | @decorator 12 | def time_limit(fn, *args, **kwargs): 13 | """ 14 | Throttles calls not to be called more often than TRAKT_POST_DELAY 15 | """ 16 | 17 | timer.wait_if_needed() 18 | 19 | return fn(*args, **kwargs) 20 | -------------------------------------------------------------------------------- /plextraktsync/factory/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .Factory import Factory 4 | 5 | factory = Factory() 6 | logger = factory.logger 7 | logging = factory.logging 8 | -------------------------------------------------------------------------------- /plextraktsync/logger/filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from functools import cached_property 5 | from logging import Filter 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from logging import Logger, LogRecord 10 | 11 | 12 | @dataclass 13 | class FilterRule: 14 | """ 15 | Structure to hold log filters 16 | """ 17 | 18 | # filter by name 19 | name: str = None 20 | # filter by level 21 | level: bool = False 22 | # filter by message 23 | message: str = None 24 | 25 | 26 | # https://stackoverflow.com/a/879937/2314626 27 | class LoggerFilter(Filter): 28 | def __init__(self, rules: list[dict], logger: Logger): 29 | super().__init__() 30 | self.logger = logger 31 | self.rules = self.build_rules(rules or []) 32 | 33 | @cached_property 34 | def nrules(self): 35 | return len(self.rules) 36 | 37 | def build_rules(self, rules): 38 | filters = [] 39 | for rule in rules: 40 | try: 41 | f = FilterRule(**rule) 42 | except TypeError as e: 43 | self.logger.error(f"Skip rule: {type(e).__name__}: {e}") 44 | continue 45 | filters.append(f) 46 | return filters 47 | 48 | def filter(self, record: LogRecord): 49 | # quick check to skip filtering 50 | if not self.nrules: 51 | return True 52 | 53 | message = record.getMessage() 54 | for rule in self.rules: 55 | matched = False 56 | # Filter by level 57 | if rule.level: 58 | if rule.level == record.levelname: 59 | matched = True 60 | else: 61 | continue 62 | # Filter by name 63 | if rule.name: 64 | if rule.name == record.name: 65 | matched = True 66 | else: 67 | continue 68 | # Filter by message 69 | if rule.message: 70 | if rule.message in message: 71 | matched = True 72 | else: 73 | continue 74 | 75 | if matched: 76 | return False 77 | return True 78 | -------------------------------------------------------------------------------- /plextraktsync/logger/init.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import re 5 | 6 | from plextraktsync.factory import factory 7 | 8 | 9 | def initialize(config): 10 | # global log level for all messages 11 | log_level = logging.DEBUG if config.log_debug else logging.INFO 12 | 13 | # messages with info and above are printed to stdout 14 | console_handler = factory.console_logger 15 | console_handler.terminator = "" 16 | console_handler.setFormatter(logging.Formatter("%(message)s")) 17 | console_handler.setLevel(logging.INFO) 18 | 19 | # file handler can log down to debug messages 20 | mode = "a" if config.log_append else "w" 21 | file_handler = logging.FileHandler(config.log_file, mode, "utf-8") 22 | file_handler.setFormatter(CustomFormatter("%(asctime)-15s %(levelname)s[%(name)s]:%(message)s")) 23 | file_handler.setLevel(logging.DEBUG) 24 | 25 | handlers = [ 26 | file_handler, 27 | console_handler, 28 | ] 29 | logging.basicConfig(handlers=handlers, level=log_level, force=True) 30 | 31 | # Set debug for other components as well 32 | if log_level == logging.DEBUG: 33 | from plexapi import log as logger 34 | from plexapi import loghandler 35 | 36 | logger.removeHandler(loghandler) 37 | logger.setLevel(logging.DEBUG) 38 | 39 | 40 | class CustomFormatter(logging.Formatter): 41 | MARKUP_PATTERN = re.compile(r"\[link=[^\]]*\]|(\[green\])|(\[\/\])") 42 | 43 | def formatMessage(self, record): 44 | record.message = self.remove_markup(record.message) 45 | return super().formatMessage(record) 46 | 47 | @classmethod 48 | def remove_markup(cls, text: str) -> str: 49 | return cls.MARKUP_PATTERN.sub("", text) 50 | -------------------------------------------------------------------------------- /plextraktsync/media/MediaFactory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plexapi.exceptions import PlexApiException 6 | from requests import RequestException 7 | from trakt.errors import TraktException 8 | 9 | from plextraktsync.factory import logging 10 | from plextraktsync.media.Media import Media 11 | 12 | if TYPE_CHECKING: 13 | from plextraktsync.plex.guid.PlexGuid import PlexGuid 14 | from plextraktsync.plex.PlexApi import PlexApi 15 | from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem 16 | from plextraktsync.trakt.TraktApi import TraktApi 17 | from plextraktsync.trakt.TraktItem import TraktItem 18 | 19 | 20 | class MediaFactory: 21 | """ 22 | Class that is able to resolve Trakt media item from Plex media item and vice versa and return generic Media class 23 | """ 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | def __init__(self, plex: PlexApi, trakt: TraktApi): 28 | self.plex = plex 29 | self.trakt = trakt 30 | 31 | def resolve_any(self, pm: PlexLibraryItem, show: Media = None) -> Media | None: 32 | try: 33 | guids = pm.guids 34 | except (PlexApiException, RequestException) as e: 35 | self.logger.error(f"Skipping {pm}: {e}") 36 | return None 37 | 38 | for guid in guids: 39 | m = self.resolve_guid(guid, show) 40 | if m: 41 | self.logger.debug(f"Resolved {guid} of {guid.pm} to {m}") 42 | return m 43 | 44 | return None 45 | 46 | def resolve_guid(self, guid: PlexGuid, show: Media = None): 47 | if not guid.syncable: 48 | error = f"{guid.title_link}: Skipping {guid} because" 49 | 50 | if guid.unsupported: 51 | level = "debug" 52 | reason = f"unsupported provider '{guid.provider}'" 53 | elif guid.local: 54 | level = "warning" 55 | reason = f"provider '{guid.provider}' has no external Id" 56 | else: 57 | level = "error" 58 | reason = "is not a valid provider" 59 | 60 | getattr(self.logger, level)(f"{error} {reason}", extra={"markup": True}) 61 | 62 | return None 63 | 64 | try: 65 | if show: 66 | tm = self.trakt.find_episode_guid(guid, show.seasons) 67 | else: 68 | tm = self.trakt.find_by_guid(guid) 69 | except (TraktException, RequestException) as e: 70 | self.logger.warning( 71 | f"{guid.title_link}: Skipping {guid}: Trakt errors: {e}", 72 | extra={"markup": True}, 73 | ) 74 | return None 75 | 76 | if tm is None: 77 | self.logger.warning( 78 | f"{guid.title_link}: Skipping {guid} not found on Trakt", 79 | extra={"markup": True}, 80 | ) 81 | return None 82 | 83 | return self.make_media(guid.pm, tm) 84 | 85 | def resolve_trakt(self, tm: TraktItem) -> Media: 86 | """Find Plex media from Trakt id using Plex Search and Discover""" 87 | result = self.plex.search_online(tm.item.title, tm.type) 88 | pm = self._guid_match(result, tm) 89 | return self.make_media(pm, tm.item) 90 | 91 | def make_media(self, plex: PlexLibraryItem, trakt): 92 | return Media(plex, trakt, plex_api=self.plex, trakt_api=self.trakt, mf=self) 93 | 94 | def _guid_match(self, candidates: list[PlexLibraryItem], tm: TraktItem) -> PlexLibraryItem | None: 95 | if candidates: 96 | for pm in candidates: 97 | for guid in pm.guids: 98 | for provider in ["tmdb", "imdb", "tvdb"]: 99 | if guid.provider == provider and hasattr(tm.item, provider) and guid.id == str(getattr(tm.item, provider)): 100 | return pm 101 | return None 102 | -------------------------------------------------------------------------------- /plextraktsync/mixin/ChangeNotifier.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class ChangeNotifier(dict): 5 | """ 6 | MixIn that would make dict object notify listeners when a value is set to dict 7 | """ 8 | 9 | listeners = [] 10 | 11 | def add_listener(self, listener, keys=None): 12 | self.listeners.append((listener, keys)) 13 | 14 | def notify(self, key, value): 15 | for listener, keys in self.listeners: 16 | if keys is not None and key not in keys: 17 | continue 18 | listener(key, value) 19 | 20 | def __setitem__(self, key, value): 21 | dict.__setitem__(self, key, value) 22 | self.notify(key, value) 23 | -------------------------------------------------------------------------------- /plextraktsync/mixin/SetWindowTitle.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | 6 | class SetWindowTitle: 7 | @cached_property 8 | def console(self): 9 | from plextraktsync.factory import factory 10 | 11 | return factory.console 12 | 13 | def clear_window_title(self): 14 | self.console.set_window_title("PlexTraktSync") 15 | 16 | def set_window_title(self, title: str): 17 | self.console.set_window_title(f"PlexTraktSync: {title}") 18 | -------------------------------------------------------------------------------- /plextraktsync/path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.util.Path import Path 4 | 5 | p = Path() 6 | 7 | cache_dir = p.cache_dir 8 | config_dir = p.config_dir 9 | log_dir = p.log_dir 10 | 11 | default_config_file = p.default_config_file 12 | config_file = p.config_file 13 | config_yml = p.config_yml 14 | servers_config = p.servers_config 15 | pytrakt_file = p.pytrakt_file 16 | env_file = p.env_file 17 | -------------------------------------------------------------------------------- /plextraktsync/plan/WalkConfig.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass 7 | class WalkConfig: 8 | walk_movies = True 9 | walk_shows = True 10 | walk_watchlist = True 11 | library: list[str] = field(default_factory=list) 12 | show: list[str] = field(default_factory=list) 13 | movie: list[str] = field(default_factory=list) 14 | id: list[str] = field(default_factory=list) 15 | 16 | def update(self, movies=None, shows=None, watchlist=None): 17 | if movies is not None: 18 | self.walk_movies = movies 19 | if shows is not None: 20 | self.walk_shows = shows 21 | if watchlist is not None: 22 | self.walk_watchlist = watchlist 23 | 24 | return self 25 | 26 | def add_library(self, library: str): 27 | self.library.append(library) 28 | 29 | def add_id(self, id: str): 30 | self.id.append(id) 31 | 32 | def add_show(self, show: str): 33 | self.show.append(show) 34 | 35 | def add_movie(self, movie: str): 36 | self.movie.append(movie) 37 | 38 | @property 39 | def is_partial(self): 40 | """ 41 | Returns true if partial library walk is performed. 42 | Due to the way watchlist is filled, watchlists should only be updated on full walk. 43 | """ 44 | # Single item provided 45 | if self.library or self.movie or self.show or self.id: 46 | return True 47 | 48 | # Must sync both movies and shows to be full sync 49 | return not self.walk_movies or not self.walk_shows 50 | 51 | @property 52 | def is_valid(self): 53 | # Single item provided 54 | if self.library or self.movie or self.show or self.id: 55 | return True 56 | 57 | # Full sync of movies or shows 58 | if self.walk_movies or self.walk_shows: 59 | return True 60 | 61 | if self.walk_watchlist: 62 | return True 63 | 64 | return False 65 | -------------------------------------------------------------------------------- /plextraktsync/plan/WalkPlan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, NamedTuple 4 | 5 | if TYPE_CHECKING: 6 | from plexapi.video import Episode, Movie, Show 7 | 8 | from plextraktsync.plex.PlexLibrarySection import PlexLibrarySection 9 | 10 | 11 | class WalkPlan(NamedTuple): 12 | movie_sections: list[PlexLibrarySection] 13 | show_sections: list[PlexLibrarySection] 14 | movies: list[Movie] 15 | shows: list[Show] 16 | episodes: list[Episode] 17 | -------------------------------------------------------------------------------- /plextraktsync/plan/WalkPlanner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.plan.WalkPlan import WalkPlan 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.plan.WalkConfig import WalkConfig 10 | from plextraktsync.plex.PlexApi import PlexApi 11 | 12 | 13 | class WalkPlanner: 14 | def __init__(self, plex: PlexApi, config: WalkConfig): 15 | self.plex = plex 16 | self.config = config 17 | 18 | def plan(self): 19 | movie_sections, show_sections = self.find_sections() 20 | movies, shows, episodes = self.find_by_id(movie_sections, show_sections) 21 | shows = self.find_from_sections_by_title(show_sections, self.config.show, shows) 22 | movies = self.find_from_sections_by_title(movie_sections, self.config.movie, movies) 23 | 24 | # reset sections if movie/shows have been picked 25 | if movies or shows or episodes: 26 | movie_sections = [] 27 | show_sections = [] 28 | 29 | return WalkPlan( 30 | movie_sections, 31 | show_sections, 32 | movies, 33 | shows, 34 | episodes, 35 | ) 36 | 37 | def find_by_id(self, movie_sections, show_sections): 38 | if not self.config.id: 39 | return [None, None, None] 40 | 41 | results = defaultdict(list) 42 | for id in self.config.id: 43 | found = self.find_from_sections_by_id(show_sections, id, results) if self.config.walk_shows else None 44 | if found: 45 | continue 46 | found = self.find_from_sections_by_id(movie_sections, id, results) if self.config.walk_movies else None 47 | if found: 48 | continue 49 | raise RuntimeError(f"Id '{id}' not found") 50 | 51 | movies = [] 52 | shows = [] 53 | episodes = [] 54 | for mediatype, items in results.items(): 55 | if mediatype == "episode": 56 | episodes.extend(items) 57 | elif mediatype == "show": 58 | shows.extend(items) 59 | elif mediatype == "movie": 60 | movies.extend(items) 61 | else: 62 | raise RuntimeError(f"Unsupported type: {mediatype}") 63 | 64 | return [movies, shows, episodes] 65 | 66 | @staticmethod 67 | def find_from_sections_by_id(sections, id, results): 68 | for section in sections: 69 | m = section.find_by_id(id) 70 | if m: 71 | results[m.type].append(m) 72 | return True 73 | return False 74 | 75 | @staticmethod 76 | def find_from_sections_by_title(sections, names, items): 77 | if not names: 78 | return items 79 | 80 | if not items: 81 | items = [] 82 | 83 | for name in names: 84 | found = False 85 | for section in sections: 86 | m = section.find_by_title(name) 87 | if m: 88 | items.append(m) 89 | found = True 90 | if not found: 91 | raise RuntimeError(f"Show/Movie '{name}' not found") 92 | 93 | return items 94 | 95 | def find_sections(self): 96 | """ 97 | Build movie and show sections based on library and walk_movies/walk_shows. 98 | A valid match must be found if such filter is enabled. 99 | 100 | :return: [movie_sections, show_sections] 101 | """ 102 | if not self.config.library: 103 | movie_sections = self.plex.movie_sections() if self.config.walk_movies else [] 104 | show_sections = self.plex.show_sections() if self.config.walk_shows else [] 105 | return [movie_sections, show_sections] 106 | 107 | movie_sections = [] 108 | show_sections = [] 109 | for library in self.config.library: 110 | movie_section = self.plex.movie_sections(library) if self.config.walk_movies else [] 111 | if movie_section: 112 | movie_sections.extend(movie_section) 113 | continue 114 | show_section = self.plex.show_sections(library) if self.config.walk_shows else [] 115 | if show_section: 116 | show_sections.extend(show_section) 117 | continue 118 | raise RuntimeError(f"Library '{library}' not found") 119 | 120 | return [movie_sections, show_sections] 121 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexAudioCodec.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from functools import cached_property 5 | 6 | 7 | class PlexAudioCodec: 8 | def match(self, codec): 9 | for key, regex in self.audio_codecs.items(): 10 | if key == codec: 11 | return key 12 | 13 | if regex and regex.match(codec): 14 | return key 15 | 16 | return None 17 | 18 | @cached_property 19 | def audio_codecs(self): 20 | codecs = { 21 | "lpcm": "pcm", 22 | "mp3": None, 23 | "aac": None, 24 | "ogg": "vorbis", 25 | "wma": None, 26 | "dts": "(dca|dta)", 27 | "dts_ma": "dtsma", 28 | "dolby_prologic": "dolby.?pro", 29 | "dolby_digital": "ac.?3", 30 | "dolby_digital_plus": "eac.?3", 31 | "dolby_truehd": "truehd", 32 | } 33 | 34 | # compile patterns 35 | for k, v in codecs.items(): 36 | if v is None: 37 | continue 38 | 39 | try: 40 | codecs[k] = re.compile(v, re.IGNORECASE) 41 | except Exception: 42 | raise RuntimeError("Unable to compile regex pattern: %r", v, exc_info=True) 43 | return codecs 44 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexId.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass(unsafe_hash=True) 7 | class PlexId: 8 | key: int | str 9 | media_type: str = None 10 | provider: str = None 11 | server: str = None 12 | 13 | METADATA = "metadata.provider.plex.tv" 14 | METADATA_URL = "https://metadata.provider.plex.tv/library/metadata" 15 | 16 | @property 17 | def plex(self): 18 | from plextraktsync.factory import factory 19 | 20 | if not self.server: 21 | return factory.plex_api 22 | 23 | return factory.get_plex_by_id(self.server) 24 | 25 | @property 26 | def metadata_url(self): 27 | return f"{self.METADATA_URL}/{self.key}" 28 | 29 | @property 30 | def is_discover(self): 31 | return self.provider == self.METADATA 32 | 33 | def __repr__(self): 34 | keys = [self.__class__.__name__, self.server, self.provider, self.key] 35 | fields = map(str, filter(None, keys)) 36 | 37 | return f"<{':'.join(fields)}>" 38 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexIdFactory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from urllib.parse import parse_qs, urlparse 4 | 5 | from .PlexId import PlexId 6 | 7 | 8 | class PlexIdFactory: 9 | @classmethod 10 | def create(cls, key: str | int): 11 | if isinstance(key, int) or key.isnumeric(): 12 | return PlexId(int(key)) 13 | elif key.startswith("https://trakt.tv/"): 14 | return cls.from_trakt_url(key) 15 | elif key.startswith("https://l.plex.tv/"): 16 | return cls.from_plex_redirect_url(key) 17 | elif key.startswith("https://watch.plex.tv/"): 18 | return cls.from_plex_watch_url(key) 19 | elif key.startswith("https:") or key.startswith("http:"): 20 | return cls.from_url(key) 21 | elif key.startswith("plex://"): 22 | return cls.from_plex_guid(key) 23 | 24 | raise RuntimeError(f"Unable to create PlexId: {key}") 25 | 26 | @classmethod 27 | def from_plex_guid(cls, id): 28 | key = id.rsplit("/", 1)[-1] 29 | return PlexId(key, provider=PlexId.METADATA) 30 | 31 | @staticmethod 32 | def from_url(url: str): 33 | """ 34 | Extracts id from urls like: 35 | https://app.plex.tv/desktop/#!/server/abcdefg/details?key=%2Flibrary%2Fmetadata%2F13202 36 | https://app.plex.tv/desktop/#!/server/abcdefg/playHistory?filters=metadataItemID%3D6041&filterTitle=&isParentType=false 37 | https://app.plex.tv/desktop/#!/provider/tv.plex.provider.discover/details?key=%2Flibrary%2Fmetadata%2F5d7768532e80df001ebe18e7 38 | https://app.plex.tv/desktop/#!/provider/tv.plex.provider.discover/details?key=/library/metadata/5d776a8e51dd69001fe24eb8' 39 | https://app.plex.tv/desktop/#!/provider/tv.plex.provider.vod/details?key=%2Flibrary%2Fmetadata%2F5d776b1cad5437001f7936f4 40 | """ 41 | 42 | result = urlparse(url) 43 | if result.fragment[0] != "!": 44 | raise RuntimeError(f"Unable to parse: {url}") 45 | 46 | fragment = urlparse(result.fragment) 47 | parsed = parse_qs(fragment.query) 48 | 49 | if fragment.path.startswith("!/server/"): 50 | server = fragment.path.split("/")[2] 51 | else: 52 | server = None 53 | 54 | if "key" in parsed: 55 | key = ",".join(parsed["key"]) 56 | if key.startswith("/library/metadata/"): 57 | id = key[len("/library/metadata/") :] 58 | if fragment.path == "!/provider/tv.plex.provider.discover/details": 59 | return PlexId(id, provider=PlexId.METADATA) 60 | if fragment.path == "!/provider/tv.plex.provider.vod/details": 61 | return PlexId(id, provider=PlexId.METADATA) 62 | return PlexId(int(id), server=server) 63 | 64 | if "filters" in parsed: 65 | filters = parse_qs(parsed["filters"][0]) 66 | if "metadataItemID" in filters: 67 | return PlexId(int(filters["metadataItemID"][0]), server=server) 68 | 69 | raise RuntimeError(f"Unable to parse: {url}") 70 | 71 | @classmethod 72 | def from_trakt_url(cls, url: str): 73 | path = urlparse(url).path 74 | 75 | if path.startswith("/movies/"): 76 | media_type = "movie" 77 | slug = path[len("/movies/") :] 78 | else: 79 | from click import ClickException 80 | 81 | raise ClickException(f"Unable to create PlexId: {path}") 82 | 83 | from plextraktsync.factory import factory 84 | from plextraktsync.trakt.TraktItem import TraktItem 85 | 86 | tm = TraktItem(factory.trakt_api.find_by_slug(slug, media_type)) 87 | results = factory.plex_api.search_by_guid(tm.guids, libtype=tm.type) 88 | if results is None: 89 | raise RuntimeError(f"Unable to find Plex Match: {url}") 90 | if len(results) > 1: 91 | raise RuntimeError(f"Failed to find unique match: {url}") 92 | pm = results[0] 93 | 94 | return PlexId(pm.key, server=pm.plex.server.machineIdentifier) 95 | 96 | @classmethod 97 | def from_plex_watch_url(cls, url: str): 98 | """ 99 | Extracts id from urls like: 100 | https://watch.plex.tv/movie/heavier-trip 101 | """ 102 | 103 | query = parse_qs(urlparse(url).query) 104 | if "utm_content" not in query: 105 | raise RuntimeError(f"Required 'utm_content' query missing from url: {url}") 106 | 107 | plex_id = ",".join(query["utm_content"]) 108 | key = f"/library/metadata/{plex_id}" 109 | location = f"https://app.plex.tv/desktop/#!/provider/tv.plex.provider.discover/details?key={key}" 110 | 111 | return cls.create(location) 112 | 113 | @classmethod 114 | def from_plex_redirect_url(cls, url: str): 115 | """ 116 | Extracts id from urls like: 117 | https://l.plex.tv/Nd7JNtC 118 | """ 119 | from plextraktsync.factory import factory 120 | 121 | session = factory.session 122 | response = session.head(url) 123 | location = session.get_redirect_target(response) 124 | if location is None: 125 | raise RuntimeError(f"Failed to find redirect from url: {url}") 126 | 127 | return cls.create(location) 128 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexLibrarySection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plexapi.exceptions import NotFound 6 | 7 | from plextraktsync.decorators.retry import retry 8 | from plextraktsync.rich.RichMarkup import RichMarkup 9 | 10 | if TYPE_CHECKING: 11 | from typing import Literal 12 | 13 | from plexapi.library import MovieSection, ShowSection 14 | 15 | from plextraktsync.plex.PlexApi import PlexApi 16 | from plextraktsync.plex.types import PlexMedia 17 | 18 | 19 | class PlexLibrarySection(RichMarkup): 20 | def __init__(self, section: ShowSection | MovieSection, plex: PlexApi = None): 21 | self.section = section 22 | self.plex = plex 23 | 24 | def pager(self, libtype: Literal["episode"] = None): 25 | from plextraktsync.plex.PlexSectionPager import PlexSectionPager 26 | 27 | return PlexSectionPager(section=self.section, plex=self.plex, libtype=libtype) 28 | 29 | @property 30 | def type(self): 31 | return self.section.type 32 | 33 | @property 34 | def title(self): 35 | return self.section.title 36 | 37 | @property 38 | def link(self): 39 | """Return Plex App URL for this section""" 40 | base_url = self.plex.plex_base_url("media") 41 | 42 | return f"{base_url}/com.plexapp.plugins.library?source={self.section.key}" 43 | 44 | @property 45 | def title_link(self): 46 | return self.markup_link(self.link, self.title) 47 | 48 | def find_by_title(self, name: str): 49 | try: 50 | return self.section.get(name) 51 | except NotFound: 52 | return None 53 | 54 | @retry 55 | def search(self, **kwargs): 56 | return self.section.search(**kwargs) 57 | 58 | def find_by_id(self, id: str | int) -> PlexMedia | None: 59 | try: 60 | return self.section.fetchItem(int(id)) 61 | except NotFound: 62 | return None 63 | 64 | def __repr__(self): 65 | return f"<{self.__class__.__name__}:{self.type}:{self.title}>" 66 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexPlaylist.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.decorators.flatten import flatten_dict 7 | from plextraktsync.factory import logging 8 | from plextraktsync.media.Media import Media 9 | from plextraktsync.rich.RichMarkup import RichMarkup 10 | 11 | if TYPE_CHECKING: 12 | from plexapi.playlist import Playlist 13 | from plexapi.server import PlexServer 14 | 15 | from plextraktsync.plex.types import PlexMedia 16 | 17 | 18 | class PlexPlaylist(RichMarkup): 19 | logger = logging.getLogger(__name__) 20 | 21 | def __init__(self, server: PlexServer, name: str): 22 | self.server = server 23 | self.name = name 24 | 25 | def __iter__(self): 26 | return iter(self.items) 27 | 28 | def __len__(self): 29 | return len(self.items.keys()) 30 | 31 | def __contains__(self, m: Media): 32 | return m.plex_key in self.items 33 | 34 | @cached_property 35 | def playlist(self) -> Playlist | None: 36 | try: 37 | playlists = self.server.playlists(title=self.name, title__iexact=self.name) 38 | playlist: Playlist = playlists[0] 39 | if len(playlists) > 1: 40 | self.logger.warning( 41 | f"Found multiple playlists ({len(playlists)}) with same name: '{self.name}', Using first playlist with id {playlist.ratingKey}" 42 | ) 43 | self.logger.debug(f"Loaded plex list: '{self.name}'") 44 | return playlist 45 | except IndexError: 46 | self.logger.debug(f'Unable to find Plex playlist with title "{self.name}".') 47 | return None 48 | 49 | @cached_property 50 | @flatten_dict 51 | def items(self) -> dict[int, PlexMedia]: 52 | if self.playlist is None: 53 | return 54 | for m in self.playlist.items(): 55 | yield m.ratingKey, m 56 | 57 | def update(self, items: list[PlexMedia], description=None) -> bool: 58 | """ 59 | Updates playlist (creates if name missing) replacing contents with items[] 60 | """ 61 | playlist = self.playlist 62 | if playlist is None and len(items) > 0: 63 | # Force reload 64 | del self.__dict__["playlist"] 65 | del self.__dict__["items"] 66 | playlist = self.server.createPlaylist(self.name, items=items) 67 | self.logger.info( 68 | f"Created plex playlist {self.title_link} with {len(items)} items", 69 | extra={"markup": True}, 70 | ) 71 | 72 | # Skip if playlist could not be made/retrieved 73 | if playlist is None: 74 | return False 75 | 76 | updated = False 77 | if description is not None and description != playlist.summary: 78 | playlist.editSummary(summary=description) 79 | self.logger.debug( 80 | f"Updated {self.title_link} description: '{description}'", 81 | extra={"markup": True}, 82 | ) 83 | updated = True 84 | 85 | # Skip if nothing to update 86 | if self.same_list(items, playlist.items()): 87 | return updated 88 | 89 | playlist.removeItems(playlist.items()) 90 | playlist.addItems(items) 91 | self.logger.debug(f"Updated '{self.name}' items") 92 | 93 | return True 94 | 95 | @property 96 | def title_link(self): 97 | if self.playlist is not None: 98 | link = self.playlist._getWebURL() 99 | 100 | return self.markup_link(link, self.name) 101 | 102 | return self.markup_title(self.name) 103 | 104 | @staticmethod 105 | def same_list(list_a: list[PlexMedia], list_b: list[PlexMedia]) -> bool: 106 | """ 107 | Return true if two list contain same Plex items. 108 | The comparison is made on ratingKey property, 109 | the items don't have to actually be identical. 110 | """ 111 | 112 | # Quick way out of lists with different length 113 | if len(list_a) != len(list_b): 114 | return False 115 | 116 | a = [m.ratingKey for m in list_a] 117 | b = [m.ratingKey for m in list_b] 118 | 119 | return a == b 120 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexPlaylistCollection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import UserDict 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.plex.PlexPlaylist import PlexPlaylist 7 | 8 | if TYPE_CHECKING: 9 | from plexapi.server import PlexServer 10 | 11 | 12 | class PlexPlaylistCollection(UserDict): 13 | def __init__(self, server: PlexServer): 14 | super().__init__() 15 | self.server = server 16 | 17 | def __missing__(self, name: str): 18 | self[name] = playlist = PlexPlaylist(self.server, name) 19 | 20 | return playlist 21 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexRatings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cache 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.decorators.flatten import flatten_dict 7 | from plextraktsync.util.Rating import Rating 8 | 9 | if TYPE_CHECKING: 10 | from plextraktsync.plex.PlexApi import PlexApi 11 | from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem 12 | from plextraktsync.plex.PlexLibrarySection import PlexLibrarySection 13 | 14 | 15 | class PlexRatings: 16 | plex: PlexApi 17 | 18 | def __init__(self, plex: PlexApi): 19 | self.plex = plex 20 | 21 | def get(self, m: PlexLibraryItem, show_id: int = None): 22 | section_id = m.item.librarySectionID 23 | 24 | # item is from section that is in excluded-libraries 25 | # this can happen when doing "inspect" 26 | if section_id not in self.plex.library_sections: 27 | return None 28 | if m.media_type not in ["movies", "shows", "episodes"]: 29 | raise RuntimeError(f"Unsupported media type: {m.media_type}") 30 | 31 | section = self.plex.library_sections[section_id] 32 | ratings: dict[int, Rating] = self.ratings(section, m.media_type) 33 | 34 | return ratings.get(m.item.ratingKey, None) 35 | 36 | @staticmethod 37 | @cache 38 | @flatten_dict 39 | def ratings(section: PlexLibrarySection, media_type: str): 40 | key = { 41 | "movies": "userRating", 42 | "episodes": "episode.userRating", 43 | "shows": "show.userRating", 44 | }[media_type] 45 | libtype = { 46 | "movies": "movie", 47 | "episodes": "episode", 48 | "shows": "show", 49 | }[media_type] 50 | 51 | filters = { 52 | "and": [ 53 | {f"{key}>>": -1}, 54 | ] 55 | } 56 | 57 | for item in section.search(filters=filters, libtype=libtype, includeGuids=False): 58 | yield item.ratingKey, Rating.create(item.userRating, item.lastRatedAt) 59 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexSectionPager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.decorators.retry import retry 7 | from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem 8 | 9 | if TYPE_CHECKING: 10 | from plexapi.library import MovieSection, ShowSection 11 | 12 | from plextraktsync.plex.PlexApi import PlexApi 13 | 14 | 15 | class PlexSectionPager: 16 | def __init__(self, section: ShowSection | MovieSection, plex: PlexApi, libtype: str = None): 17 | self.section = section 18 | self.plex = plex 19 | self.libtype = libtype if libtype is not None else section.TYPE 20 | 21 | def __len__(self): 22 | return self.total_size 23 | 24 | @cached_property 25 | @retry 26 | def total_size(self): 27 | return self.section.totalViewSize(libtype=self.libtype, includeCollections=False) 28 | 29 | @retry() 30 | def fetch_items(self, start: int, size: int): 31 | return self.section.search( 32 | libtype=self.libtype, 33 | container_start=start, 34 | container_size=size, 35 | maxresults=size, 36 | ) 37 | 38 | def __iter__(self): 39 | from plexapi import X_PLEX_CONTAINER_SIZE 40 | 41 | max_items = self.total_size 42 | start = 0 43 | size = X_PLEX_CONTAINER_SIZE 44 | 45 | while True: 46 | items = self.fetch_items(start=start, size=size) 47 | 48 | if not len(items): 49 | break 50 | 51 | for ep in items: 52 | yield PlexLibraryItem(ep, plex=self.plex) 53 | 54 | start += size 55 | if start > max_items: 56 | break 57 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexServerConnection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import plexapi 4 | from click import ClickException 5 | from plexapi.exceptions import BadRequest, Unauthorized 6 | from plexapi.server import PlexServer 7 | from requests.exceptions import ConnectionError, SSLError 8 | 9 | from plextraktsync.config import PLEX_PLATFORM 10 | from plextraktsync.factory import Factory, logging 11 | 12 | 13 | class PlexServerConnection: 14 | logger = logging.getLogger(__name__) 15 | 16 | def __init__(self, factory: Factory): 17 | self.factory = factory 18 | 19 | @property 20 | def timeout(self): 21 | return self.config["plex"]["timeout"] 22 | 23 | @property 24 | def config(self): 25 | return self.factory.config 26 | 27 | @property 28 | def session(self): 29 | return self.factory.session 30 | 31 | def connect(self, urls: list[str], token: str): 32 | plexapi.X_PLEX_PLATFORM = PLEX_PLATFORM 33 | plexapi.TIMEOUT = self.timeout 34 | plexapi.BASE_HEADERS["X-Plex-Platform"] = plexapi.X_PLEX_PLATFORM 35 | 36 | # if connection fails, it will try: 37 | # 1. url expected by new ssl certificate 38 | # 2. url without ssl 39 | # 3. local url (localhost) 40 | for url in urls: 41 | self.logger.info(f"Connecting with url: {url}, timeout {self.timeout} seconds") 42 | try: 43 | return PlexServer(baseurl=url, token=token, session=self.session, timeout=self.timeout) 44 | except SSLError as e: 45 | self.logger.error(e) 46 | message = str(e.__context__) 47 | 48 | # 1. 49 | # HTTPSConnectionPool(host='127.0.0.1', port=32400): 50 | # Max retries exceeded with url: / ( 51 | # Caused by SSLError( 52 | # CertificateError( 53 | # "hostname '127.0.0.1' doesn't match '*.5125cc430e5f1919c27226507eab90df.plex.direct'" 54 | # ) 55 | # ) 56 | # ) 57 | if "doesn't match '*." in message and ".plex.direct" in url: 58 | url = self.extract_plex_direct(url, message) 59 | self.logger.warning(f"Adding rewritten plex.direct url to connect with: {url}") 60 | urls.append(url) 61 | continue 62 | 63 | self.logger.error(e) 64 | 65 | except BadRequest as e: 66 | self.logger.error(e) 67 | 68 | except ConnectionError as e: 69 | self.logger.error(e) 70 | # 2. 71 | if url and url[:5] == "https": 72 | url = url.replace("https", "http") 73 | self.logger.warning(f"Adding rewritten http url to connect with: {url}") 74 | urls.append(url) 75 | continue 76 | except Unauthorized as e: 77 | self.logger.error(e) 78 | 79 | raise ClickException("No more methods to connect. Giving up") 80 | 81 | @staticmethod 82 | def extract_plex_direct(url: str, message: str): 83 | """ 84 | Extract .plex.direct url from message. 85 | The url must be with .plex.direct domain. 86 | """ 87 | hash_pos = message.find("*.") + 2 88 | hash_value = message[hash_pos : hash_pos + 32] 89 | end_pos = url.find(".plex.direct") 90 | 91 | return url[: end_pos - 32] + hash_value + url[end_pos:] 92 | -------------------------------------------------------------------------------- /plextraktsync/plex/PlexWatchList.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.decorators.flatten import flatten_set 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.media.Media import Media 10 | from plextraktsync.plex.types import PlexMedia 11 | 12 | 13 | class PlexWatchList: 14 | def __init__(self, watchlist: list[PlexMedia]): 15 | if watchlist is None: 16 | raise RuntimeError("Plex watchlist is None") 17 | self.watchlist = watchlist 18 | 19 | def __iter__(self): 20 | return iter(self.watchlist) 21 | 22 | def __len__(self): 23 | return len(self.watchlist) 24 | 25 | def __contains__(self, m: Media): 26 | return m.plex.item.guid in self.guidmap 27 | 28 | @cached_property 29 | @flatten_set 30 | def guidmap(self) -> set[str]: 31 | """ 32 | Return set() of guid of Plex Watchlist items 33 | """ 34 | for pm in self.watchlist: 35 | yield pm.guid 36 | -------------------------------------------------------------------------------- /plextraktsync/plex/SessionCollection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import UserDict 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from plextraktsync.plex.PlexApi import PlexApi 8 | 9 | 10 | class SessionCollection(UserDict): 11 | def __init__(self, plex: PlexApi): 12 | super().__init__() 13 | self.plex = plex 14 | 15 | def __missing__(self, key: str): 16 | self.update_sessions() 17 | if key not in self: 18 | # Session probably ended 19 | return None 20 | 21 | return self[key] 22 | 23 | def update_sessions(self): 24 | self.clear() 25 | for session in self.plex.sessions: 26 | self[str(session.sessionKey)] = session.usernames[0] 27 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/PlexGuid.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import factory 7 | from plextraktsync.rich.RichMarkup import RichMarkup 8 | 9 | from .provider.Factory import Factory as GuidProviderFactory 10 | 11 | if TYPE_CHECKING: 12 | from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem 13 | 14 | 15 | class PlexGuid(RichMarkup): 16 | def __init__(self, guid: str, type: str, pm: PlexLibraryItem | None = None): 17 | self.guid = guid 18 | self.type = type 19 | self.pm = pm 20 | 21 | @cached_property 22 | def media_type(self): 23 | return f"{self.type}s" 24 | 25 | def __eq__(self, other: PlexGuid): 26 | # These are same guids even they come from different Agent 27 | # compare with 28 | return self.provider == other.provider and self.id == other.id 29 | 30 | @cached_property 31 | def provider(self): 32 | if self.guid_is_imdb_legacy: 33 | return "imdb" 34 | x = self.guid.split("://")[0] 35 | x = x.replace("com.plexapp.agents.", "") 36 | x = x.replace("tv.plex.xmltv", "xmltv") 37 | x = x.replace("tv.plex.agents.", "") 38 | x = x.replace("themoviedb", "tmdb") 39 | x = x.replace("thetvdb", "tvdb") 40 | if x == "xbmcnfo": 41 | CONFIG = factory.config 42 | x = CONFIG["xbmc-providers"][self.media_type] 43 | if x == "xbmcnfotv": 44 | CONFIG = factory.config 45 | x = CONFIG["xbmc-providers"]["shows"] 46 | 47 | return x 48 | 49 | @cached_property 50 | def id(self): 51 | if self.guid_is_imdb_legacy: 52 | return self.guid 53 | x = self.guid.split("://")[1] 54 | x = x.split("?")[0] 55 | return x 56 | 57 | @cached_property 58 | def is_episode(self): 59 | """ 60 | Return true of the id is in form of // 61 | """ 62 | parts = self.id.split("/") 63 | 64 | return len(parts) == 3 and all(x.isnumeric() for x in parts) 65 | 66 | @property 67 | def syncable(self): 68 | """Is the provider syncable with trakt""" 69 | return self.provider in ["imdb", "tmdb", "tvdb"] 70 | 71 | @property 72 | def local(self): 73 | """Is the provider local""" 74 | return self.provider in ["local", "none", "agents.none"] 75 | 76 | @property 77 | def unsupported(self): 78 | """Known providers that can't be synced""" 79 | return self.provider in ["youtube", "xmltv"] 80 | 81 | @cached_property 82 | def show_id(self): 83 | if not self.is_episode: 84 | raise ValueError("show_id is not valid for non-episodes") 85 | 86 | show = self.id.split("/", 1)[0] 87 | if not show.isnumeric(): 88 | raise ValueError(f"show_id is not numeric: {show}") 89 | 90 | return show 91 | 92 | @cached_property 93 | def guid_is_imdb_legacy(self): 94 | guid = self.guid 95 | 96 | # old item, like imdb 'tt0112253' 97 | return guid[0:2] == "tt" and guid[2:].isnumeric() 98 | 99 | @property 100 | def title(self): 101 | return self.pm.item.title 102 | 103 | @property 104 | def title_link(self): 105 | if self.pm: 106 | return self.pm.title_link 107 | 108 | return self.markup_title(str(self)) 109 | 110 | @property 111 | def provider_link(self): 112 | provider = GuidProviderFactory().create(self) 113 | link = provider.link 114 | 115 | if not link: 116 | return self.markup_title(provider.title) 117 | 118 | return self.markup_link(link, provider.title) 119 | 120 | def __str__(self): 121 | return f"" 122 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/Abstract.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.rich.RichMarkup import RichMarkup 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.plex.guid.PlexGuid import PlexGuid 10 | 11 | 12 | class Abstract(RichMarkup): 13 | def __init__(self, guid: PlexGuid): 14 | self.guid = guid 15 | 16 | @cached_property 17 | def title(self): 18 | return f"{self.guid.provider}:{self.guid.type}:{self.guid.id}" 19 | 20 | @cached_property 21 | def link(self) -> str | None: 22 | return None 23 | 24 | @cached_property 25 | def markup(self): 26 | if not self.link: 27 | return self.markup_title(self.title) 28 | 29 | return self.markup_link(self.link, self.title) 30 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/Factory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from .IMDB import IMDB 6 | from .Local import Local 7 | from .Mbid import Mbid 8 | from .TMDB import TMDB 9 | from .TVDB import TVDB 10 | from .Youtube import Youtube 11 | 12 | if TYPE_CHECKING: 13 | from plextraktsync.plex.guid.PlexGuid import PlexGuid 14 | 15 | 16 | class Factory: 17 | @classmethod 18 | def create(cls, guid: PlexGuid): 19 | if guid.provider == "imdb": 20 | return IMDB(guid) 21 | if guid.provider == "tmdb": 22 | return TMDB(guid) 23 | if guid.provider == "tvdb": 24 | return TVDB(guid) 25 | if guid.provider == "mbid": 26 | return Mbid(guid) 27 | if guid.provider == "youtube": 28 | return Youtube(guid) 29 | if guid.provider in ["local", "none"]: 30 | return Local(guid) 31 | 32 | raise RuntimeError(f"Unsupported provider: {guid.provider}") 33 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/IMDB.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | from .Abstract import Abstract 6 | 7 | 8 | class IMDB(Abstract): 9 | @cached_property 10 | def link(self): 11 | return f"https://www.imdb.com/title/{self.guid.id}/" 12 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/Local.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .Abstract import Abstract 4 | 5 | 6 | class Local(Abstract): 7 | pass 8 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/Mbid.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | from .Abstract import Abstract 6 | 7 | 8 | class Mbid(Abstract): 9 | @cached_property 10 | def link(self): 11 | if self.guid.type == "artist": 12 | return f"https://musicbrainz.org/artist/{self.guid.id}" 13 | if self.guid.type == "album": 14 | return f"https://musicbrainz.org/release/{self.guid.id}" 15 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/TMDB.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | from .Abstract import Abstract 6 | 7 | 8 | class TMDB(Abstract): 9 | url = "https://www.themoviedb.org" 10 | 11 | @cached_property 12 | def link(self): 13 | url = f"{self.url}/{self.type}" 14 | type = self.guid.type 15 | 16 | if type in ["show", "season", "episode"]: 17 | url += f"/{self.show_guid.id}" 18 | 19 | if type in ["season", "episode"]: 20 | url += f"/season/{self.season_number}" 21 | 22 | if type == "episode": 23 | url += f"/episode/{self.episode_number}" 24 | 25 | if type == "movie": 26 | url += f"/{self.guid.id}" 27 | 28 | return url 29 | 30 | @property 31 | def show_guid(self): 32 | pm = self.guid.pm 33 | guids = pm.guids if self.guid.type == "show" else pm.show.guids 34 | return next(guid for guid in guids if guid.provider == "tmdb") 35 | 36 | @property 37 | def season_number(self): 38 | return self.guid.pm.season_number 39 | 40 | @property 41 | def episode_number(self): 42 | return self.guid.pm.episode_number 43 | 44 | @property 45 | def type(self): 46 | return { 47 | "show": "tv", 48 | "season": "tv", 49 | "episode": "tv", 50 | "movie": "movie", 51 | }[self.guid.type] 52 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/TVDB.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | from .Abstract import Abstract 6 | 7 | 8 | class TVDB(Abstract): 9 | @cached_property 10 | def link(self): 11 | return f"https://www.thetvdb.com/dereferrer/{self.guid.type}/{self.guid.id}" 12 | -------------------------------------------------------------------------------- /plextraktsync/plex/guid/provider/Youtube.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | from .Abstract import Abstract 6 | 7 | 8 | class Youtube(Abstract): 9 | @property 10 | def id(self): 11 | return self.guid.id.split("|")[1] 12 | 13 | @cached_property 14 | def link(self): 15 | return f"https://www.youtube.com/watch?v={self.id}" 16 | 17 | @cached_property 18 | def title(self): 19 | return f"{self.guid.provider}:{self.guid.type}:{self.id}" 20 | -------------------------------------------------------------------------------- /plextraktsync/plex/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union 4 | 5 | from plexapi.video import Episode, Movie, Show 6 | 7 | PlexMedia = Union[Movie, Show, Episode] 8 | -------------------------------------------------------------------------------- /plextraktsync/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .plugin import hookimpl, hookspec # noqa: F401 4 | -------------------------------------------------------------------------------- /plextraktsync/plugin/plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import apluggy as pluggy 4 | 5 | hookspec = pluggy.HookspecMarker("PlexTraktSync") 6 | hookimpl = pluggy.HookimplMarker("PlexTraktSync") 7 | -------------------------------------------------------------------------------- /plextraktsync/pytrakt_extensions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from trakt.core import get 4 | from trakt.utils import airs_date 5 | 6 | 7 | @get 8 | def allwatched(): 9 | # returns a AllShowProgress object containing all watched shows 10 | data = yield "sync/watched/shows" 11 | yield AllShowsProgress(data) 12 | 13 | 14 | @get 15 | def allcollected(): 16 | # returns a AllShowProgress object containing all collected shows 17 | data = yield "sync/collection/shows" 18 | yield AllShowsProgress(data) 19 | 20 | 21 | class EpisodeProgress: 22 | def __init__( 23 | self, 24 | number=0, 25 | aired=0, 26 | plays=False, 27 | completed=False, 28 | last_watched_at=None, 29 | collected_at=None, 30 | ): 31 | self.number = number 32 | self.aired = aired 33 | self.completed = completed 34 | if plays > 0: 35 | self.completed = True 36 | self.last_watched_at = last_watched_at 37 | self.collected_at = collected_at 38 | 39 | def get_completed(self): 40 | return self.completed 41 | 42 | 43 | class SeasonProgress: 44 | def __init__(self, number=0, title=None, aired=0, completed=False, episodes=None): 45 | self.number = number 46 | self.aired = aired 47 | self.episodes = {} 48 | for episode in episodes: 49 | prog = EpisodeProgress(**episode) 50 | self.episodes[prog.number] = prog 51 | 52 | self.completed = completed == len(episodes) 53 | 54 | def get_completed(self, episode, reset_at): 55 | if self.completed: 56 | return True 57 | elif episode not in self.episodes: 58 | return False 59 | last_watched_at = airs_date(self.episodes[episode].last_watched_at) 60 | if reset_at and reset_at > last_watched_at: 61 | return False 62 | return self.episodes[episode].get_completed() 63 | 64 | 65 | class ShowProgress: 66 | def __init__( 67 | self, 68 | aired=0, 69 | plays=None, 70 | completed=False, 71 | last_watched_at=None, 72 | last_updated_at=None, 73 | reset_at=None, 74 | show=None, 75 | seasons=None, 76 | hidden_seasons=None, 77 | next_episode=0, 78 | last_episode=0, 79 | last_collected_at=None, 80 | ): 81 | self.aired = aired 82 | self.last_watched_at = last_watched_at 83 | self.last_updated_at = last_updated_at 84 | self.last_collected_at = last_collected_at 85 | self.reset_at = reset_at 86 | self.hidden_seasons = hidden_seasons 87 | self.next_episode = next_episode 88 | self.last_episode = last_episode 89 | self.trakt = show["ids"]["trakt"] if show else None 90 | self.slug = show["ids"]["slug"] if show else None 91 | self.seasons = {} 92 | allCompleted = True 93 | for season in seasons: 94 | prog = SeasonProgress(**season) 95 | self.seasons[prog.number] = prog 96 | allCompleted = allCompleted and prog.completed 97 | 98 | self.completed = allCompleted if len(seasons) > 0 else False 99 | 100 | def get_completed(self, season, episode): 101 | if self.completed: 102 | return True 103 | elif season not in self.seasons: 104 | return False 105 | reset_at = airs_date(self.reset_at) 106 | return self.seasons[season].get_completed(episode, reset_at) 107 | 108 | 109 | class AllShowsProgress: 110 | def __init__(self, shows=None): 111 | self.shows = {} 112 | for show in shows: 113 | prog = ShowProgress(**show) 114 | self.shows[prog.trakt] = prog 115 | 116 | def get_completed(self, trakt_id, season, episode): 117 | if trakt_id not in self.shows: 118 | return False 119 | else: 120 | return self.shows[trakt_id].get_completed(season, episode) 121 | 122 | def is_collected(self, trakt_id, season, episode): 123 | if trakt_id not in self.shows or season not in self.shows[trakt_id].seasons: 124 | return False 125 | return episode in self.shows[trakt_id].seasons[season].episodes 126 | 127 | def reset_at(self, trakt_id): 128 | if trakt_id not in self.shows: 129 | return None 130 | else: 131 | return airs_date(self.shows[trakt_id].reset_at) 132 | 133 | def add(self, trakt_id, season, episode): 134 | episode_prog = {"number": episode, "completed": True} 135 | season_prog = {"number": season, "episodes": [episode_prog]} 136 | if trakt_id in self.shows: 137 | if season in self.shows[trakt_id].seasons: 138 | self.shows[trakt_id].seasons[season].episodes[episode] = EpisodeProgress(**episode_prog) 139 | else: 140 | self.shows[trakt_id].seasons[season] = SeasonProgress(**season_prog) 141 | else: 142 | self.shows[trakt_id] = ShowProgress(seasons=[season_prog]) 143 | -------------------------------------------------------------------------------- /plextraktsync/queue/BackgroundTask.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from queue import Empty 5 | from typing import TYPE_CHECKING 6 | 7 | from plextraktsync.factory import logging 8 | 9 | if TYPE_CHECKING: 10 | from queue import SimpleQueue 11 | from typing import Any 12 | 13 | from plextraktsync.util.Timer import Timer 14 | 15 | 16 | class BackgroundTask: 17 | """ 18 | Class to read events from queue and invoke them at tasks to flush them at interval set by the timer 19 | """ 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | def __init__(self, timer: Timer = None, *tasks): 24 | self.queues = defaultdict(list) 25 | self.timer = timer 26 | self.tasks = tasks 27 | 28 | def check_timer(self): 29 | if not self.timer: 30 | return 31 | 32 | self.timer.start() 33 | if self.timer.time_remaining: 34 | return 35 | 36 | self.timed_events() 37 | self.timer.update() 38 | 39 | def timed_events(self): 40 | for task in self.tasks: 41 | try: 42 | task(self.queues) 43 | except Exception as e: 44 | self.logger.error(f"Got exception while working on {task}: {e}") 45 | 46 | def process_message(self, message: (str, Any)): 47 | (queue, data) = message 48 | self.queues[queue].append(data) 49 | 50 | def shutdown(self): 51 | """ 52 | The shutdown handler: run timed events now. 53 | """ 54 | self.logger.debug("Shutdown, run timed events now") 55 | self.timed_events() 56 | 57 | def __call__(self, queue: SimpleQueue): 58 | """ 59 | Process events from queue and invoke timed events. 60 | """ 61 | 62 | while True: 63 | try: 64 | message = queue.get(timeout=1) 65 | except Empty: 66 | pass 67 | else: 68 | if message is None: 69 | self.shutdown() 70 | break 71 | self.process_message(message) 72 | 73 | self.check_timer() 74 | -------------------------------------------------------------------------------- /plextraktsync/queue/Queue.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import atexit 4 | from queue import SimpleQueue 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from typing import Any 9 | 10 | 11 | class Queue: 12 | def __init__(self, runner): 13 | self.queue = SimpleQueue() 14 | self.daemon = self.start_daemon(runner) 15 | atexit.register(self.close) 16 | 17 | def add_to_collection(self, data): 18 | self.add_queue("add_to_collection", data) 19 | 20 | def remove_from_collection(self, data): 21 | self.add_queue("remove_from_collection", data) 22 | 23 | def add_to_watchlist(self, data): 24 | self.add_queue("add_to_watchlist", data) 25 | 26 | def remove_from_watchlist(self, data): 27 | self.add_queue("remove_from_watchlist", data) 28 | 29 | def add_to_history(self, data): 30 | self.add_queue("add_to_history", data) 31 | 32 | def scrobble_update(self, data): 33 | self.add_queue("scrobble_update", data) 34 | 35 | def scrobble_pause(self, data): 36 | self.add_queue("scrobble_pause", data) 37 | 38 | def scrobble_stop(self, data): 39 | self.add_queue("scrobble_stop", data) 40 | 41 | def add_queue(self, queue: str, data: Any): 42 | """ 43 | Add "data" to "queue". Returns immediately 44 | """ 45 | self.queue.put((queue, data)) 46 | 47 | def start_daemon(self, runner): 48 | from threading import Thread 49 | 50 | daemon = Thread(target=runner, args=(self.queue,), daemon=True, name="BackgroundTask") 51 | daemon.start() 52 | 53 | return daemon 54 | 55 | def close(self): 56 | """ 57 | Close queue. 58 | Terminate child thread and stop accepting items to queue. 59 | """ 60 | if self.daemon.is_alive(): 61 | self.queue.put(None) 62 | self.daemon.join() 63 | self.queue = None 64 | -------------------------------------------------------------------------------- /plextraktsync/queue/TraktBatchWorker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | 5 | import trakt.sync 6 | 7 | from plextraktsync.decorators.account_limit import account_limit 8 | from plextraktsync.decorators.rate_limit import rate_limit 9 | from plextraktsync.decorators.retry import retry 10 | from plextraktsync.decorators.time_limit import time_limit 11 | from plextraktsync.factory import logging 12 | from plextraktsync.util.remove_empty_values import remove_empty_values 13 | 14 | 15 | class TraktBatchWorker: 16 | # Queues this Worker can handle 17 | QUEUES = ( 18 | "add_to_collection", 19 | "remove_from_collection", 20 | "add_to_watchlist", 21 | "remove_from_watchlist", 22 | ) 23 | logger = logging.getLogger(__name__) 24 | 25 | def __call__(self, queues): 26 | for name in self.QUEUES: 27 | items = queues[name] 28 | if not len(items): 29 | continue 30 | self.submit(name, items) 31 | queues[name].clear() 32 | 33 | def submit(self, name, items): 34 | method = getattr(self, name) 35 | result = method(self.normalize(items)) 36 | if not result: 37 | return 38 | result = remove_empty_values(result.copy()) 39 | if result: 40 | self.logger.debug(f"Submitted {name}: {result}") 41 | 42 | @rate_limit() 43 | @account_limit() 44 | @time_limit() 45 | @retry() 46 | def add_to_collection(self, items): 47 | return trakt.sync.add_to_collection(items) 48 | 49 | @rate_limit() 50 | @time_limit() 51 | @retry() 52 | def remove_from_collection(self, items): 53 | return trakt.sync.remove_from_collection(items) 54 | 55 | @rate_limit() 56 | @account_limit() 57 | @time_limit() 58 | @retry() 59 | def add_to_watchlist(self, items): 60 | return trakt.sync.add_to_watchlist(items) 61 | 62 | @rate_limit() 63 | @time_limit() 64 | @retry() 65 | def remove_from_watchlist(self, items): 66 | return trakt.sync.remove_from_watchlist(items) 67 | 68 | @staticmethod 69 | def normalize(items): 70 | result = defaultdict(list) 71 | for media_type, item in items: 72 | result[media_type].append(item) 73 | 74 | return result 75 | -------------------------------------------------------------------------------- /plextraktsync/queue/TraktMarkWatchedWorker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | 5 | import trakt.sync 6 | 7 | from plextraktsync.decorators.rate_limit import rate_limit 8 | from plextraktsync.decorators.retry import retry 9 | from plextraktsync.decorators.time_limit import time_limit 10 | from plextraktsync.factory import logging 11 | from plextraktsync.util.remove_empty_values import remove_empty_values 12 | 13 | 14 | class TraktMarkWatchedWorker: 15 | # Queue this Worker can handle 16 | QUEUE = "add_to_history" 17 | logger = logging.getLogger(__name__) 18 | 19 | def __call__(self, queues): 20 | items = queues[self.QUEUE] 21 | if not len(items): 22 | return 23 | self.submit(items) 24 | queues[self.QUEUE].clear() 25 | 26 | def submit(self, items): 27 | items = self.normalize(items) 28 | self.logger.debug(f"Submit add_to_history: {items}") 29 | result = self.add_to_history(items) 30 | result = remove_empty_values(result.copy()) 31 | if result: 32 | self.logger.debug(f"Submitted add_to_history: {result}") 33 | 34 | @rate_limit() 35 | @time_limit() 36 | @retry() 37 | def add_to_history(self, items: dict): 38 | return trakt.sync.add_to_history(items) 39 | 40 | @staticmethod 41 | def normalize(items: list): 42 | result = defaultdict(list) 43 | for m in items: 44 | result[m.media_type].append( 45 | { 46 | "ids": m.ids["ids"], 47 | "watched_at": m.watched_at, 48 | } 49 | ) 50 | 51 | return result 52 | -------------------------------------------------------------------------------- /plextraktsync/queue/TraktScrobbleWorker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from trakt.errors import ConflictException 6 | 7 | from plextraktsync.decorators.rate_limit import rate_limit 8 | from plextraktsync.decorators.retry import retry 9 | from plextraktsync.decorators.time_limit import time_limit 10 | from plextraktsync.factory import logging 11 | 12 | if TYPE_CHECKING: 13 | from trakt.sync import Scrobbler 14 | 15 | from plextraktsync.trakt.types import TraktPlayable 16 | 17 | 18 | class TraktScrobbleWorker: 19 | # Queues this Worker can handle 20 | QUEUES = ( 21 | "scrobble_update", 22 | "scrobble_pause", 23 | "scrobble_stop", 24 | ) 25 | logger = logging.getLogger(__name__) 26 | 27 | def __call__(self, queues): 28 | for name in self.QUEUES: 29 | items = queues[name] 30 | if not len(items): 31 | continue 32 | self.submit(name, items) 33 | queues[name].clear() 34 | 35 | def submit(self, name, items): 36 | name = name.replace("scrobble_", "") 37 | results = [] 38 | for scrobbler, progress in self.normalize(items).items(): 39 | res = self.scrobble(scrobbler, name, progress) 40 | results.append(res) 41 | 42 | if results: 43 | self.logger.debug(f"Submitted {name}: {results}") 44 | 45 | @rate_limit() 46 | @time_limit() 47 | @retry() 48 | def scrobble(self, scrobbler: Scrobbler, name: str, progress: float): 49 | method = getattr(scrobbler, name) 50 | try: 51 | return method(progress) 52 | except ConflictException as e: 53 | self.logger.error(e) 54 | self.logger.debug(e.response.headers) 55 | self.logger.debug(e.response) 56 | 57 | @staticmethod 58 | def normalize(items: list[TraktPlayable]): 59 | result = {} 60 | for scrobbler, progress in items: 61 | result[scrobbler] = progress 62 | 63 | return result 64 | -------------------------------------------------------------------------------- /plextraktsync/rich/RichHighlighter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from rich.highlighter import RegexHighlighter 4 | 5 | 6 | class RichHighlighter(RegexHighlighter): 7 | base_style = "repr." 8 | 9 | highlights = [ 10 | r"(?P\B(\/[\w\.\-\_\+]+)*\/)(?P[\w\.\-\_\+]*)?", 11 | r"(?P(?[\w_]{1,50})=(?P\"?[\w_]+\"?)?", 13 | r"(?P\<)(?P(?:Movie|Episode|Show):\d+:[^>]+)(?P\>)", 14 | r"(?P\<)(?P(?:PlexGuid|Guid|PlexLibrarySection):[^>]+)(?P\>)", 15 | r"(?P\<)(?P(?:imdb|tmdb|tvdb|local):(?:(?:tt)?\d+:)[^>]+)(?P\>)", 16 | r"\b(?PTrue)\b|\b(?PFalse)\b|\b(?PNone)\b", 17 | r"(?b?\'\'\'.*?(?(file|https|http|ws|wss):\/\/[0-9a-zA-Z\$\-\_\+\!`\(\)\,\.\?\/\;\:\&\=\%\#]*)", 19 | ] 20 | -------------------------------------------------------------------------------- /plextraktsync/rich/RichMarkup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from rich.markup import escape 4 | 5 | 6 | class RichMarkup: 7 | def markup_link(self, link: str, title: str): 8 | return f"[link={link}]{self.markup_title(title)}[/]" 9 | 10 | @staticmethod 11 | def markup_title(title: str): 12 | return f"[green]{escape(title)}[/]" 13 | -------------------------------------------------------------------------------- /plextraktsync/rich/RichProgressBar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | 6 | class RichProgressBar: 7 | def __init__(self, iterable, total=None, options=None, desc=""): 8 | self.options = options or {} 9 | self.desc = desc 10 | if total is None: 11 | total = len(iterable) 12 | self.total = total 13 | self.i = 0 14 | 15 | self.iterable_awaitable = False 16 | if iterable is not None: 17 | if hasattr(iterable, "__anext__"): 18 | self.iterable_next = iterable.__anext__ 19 | self.iterable_awaitable = True 20 | elif hasattr(iterable, "__next__"): 21 | self.iterable_next = iterable.__next__ 22 | else: 23 | self.iterable_next = iter(iterable).__next__ 24 | 25 | def __iter__(self): 26 | if self.iterable_awaitable: 27 | raise RuntimeError("Iterable must be awaited") 28 | 29 | return self 30 | 31 | def __aiter__(self): 32 | return self 33 | 34 | def __next__(self): 35 | res = self.iterable_next() 36 | self.update() 37 | return res 38 | 39 | async def __anext__(self): 40 | try: 41 | if self.iterable_awaitable: 42 | res = await self.iterable_next() 43 | else: 44 | res = self.iterable_next() 45 | self.update() 46 | return res 47 | except StopIteration: 48 | raise StopAsyncIteration 49 | 50 | def __enter__(self): 51 | self.progress.__enter__() 52 | return self 53 | 54 | def __exit__(self, *exc): 55 | self.progress.__exit__(*exc) 56 | 57 | def update(self): 58 | self.i += 1 59 | self.progress.update(self.task_id, completed=self.i) 60 | 61 | @cached_property 62 | def task_id(self): 63 | return self.progress.add_task(self.desc, total=self.total) 64 | 65 | @cached_property 66 | def progress(self): 67 | from rich.progress import ( 68 | BarColumn, 69 | Progress, 70 | TimeElapsedColumn, 71 | TimeRemainingColumn, 72 | ) 73 | from tqdm.rich import FractionColumn, RateColumn 74 | 75 | args = ( 76 | "[progress.description]{task.description}[progress.percentage]{task.percentage:>4.0f}%", 77 | BarColumn(bar_width=None), 78 | FractionColumn( 79 | unit_scale=False, 80 | unit_divisor=1000, 81 | ), 82 | "[", 83 | TimeElapsedColumn(), 84 | "<", 85 | TimeRemainingColumn(), 86 | ",", 87 | RateColumn( 88 | unit="it", 89 | unit_scale=False, 90 | unit_divisor=1000, 91 | ), 92 | "]", 93 | ) 94 | progress = Progress(*args, **self.options) 95 | 96 | return progress 97 | -------------------------------------------------------------------------------- /plextraktsync/style.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import partial 4 | 5 | import click 6 | 7 | title = partial(click.style, fg="yellow") 8 | prompt = partial(click.style, fg="yellow") 9 | success = partial(click.style, fg="green") 10 | error = partial(click.style, fg="red") 11 | comment = partial(click.style, fg="bright_cyan") 12 | disabled = partial(click.style, fg="blue") 13 | highlight = partial(click.style, fg="bright_white") 14 | -------------------------------------------------------------------------------- /plextraktsync/sync/AddCollectionPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plextraktsync.factory import logging 6 | from plextraktsync.plugin import hookimpl 7 | 8 | if TYPE_CHECKING: 9 | from .plugin.SyncPluginInterface import Media, SyncConfig 10 | 11 | 12 | class AddCollectionPlugin: 13 | logger = logging.getLogger(__name__) 14 | 15 | @staticmethod 16 | def enabled(config: SyncConfig): 17 | return config.plex_to_trakt["collection"] 18 | 19 | @classmethod 20 | def factory(cls, sync): 21 | return cls() 22 | 23 | @hookimpl 24 | async def walk_movie(self, movie: Media, dry_run: bool): 25 | await self.sync_collection(movie, dry_run=dry_run) 26 | 27 | @hookimpl 28 | async def walk_episode(self, episode: Media, dry_run: bool): 29 | await self.sync_collection(episode, dry_run=dry_run) 30 | 31 | async def sync_collection(self, m: Media, dry_run: bool): 32 | if m.is_collected: 33 | return 34 | 35 | self.logger.info(f"Adding to Trakt collection: {m.title_link}", extra={"markup": True}) 36 | 37 | if not dry_run: 38 | m.add_to_collection() 39 | -------------------------------------------------------------------------------- /plextraktsync/sync/ClearCollectedPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterable 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import logging 7 | from plextraktsync.media.Media import Media 8 | from plextraktsync.plugin import hookimpl 9 | 10 | if TYPE_CHECKING: 11 | from plextraktsync.trakt.TraktApi import TraktApi 12 | from plextraktsync.trakt.types import TraktMedia 13 | 14 | from .plugin.SyncPluginInterface import Sync, SyncConfig, SyncPluginManager 15 | 16 | 17 | class ClearCollectedPlugin: 18 | logger = logging.getLogger(__name__) 19 | 20 | def __init__(self, trakt: TraktApi): 21 | self.trakt = trakt 22 | self.episode_trakt_ids = set() 23 | self.movie_trakt_ids = set() 24 | 25 | @staticmethod 26 | def enabled(config: SyncConfig): 27 | return config.clear_collected 28 | 29 | @classmethod 30 | def factory(cls, sync: Sync): 31 | return cls(sync.trakt) 32 | 33 | @hookimpl 34 | def init(self, pm: SyncPluginManager, is_partial: bool): 35 | if not is_partial: 36 | return 37 | 38 | self.logger.warning("Disabling Clear Collected: Running partial library sync") 39 | pm.unregister(self) 40 | 41 | @hookimpl 42 | async def fini(self, dry_run: bool): 43 | self.clear_collected(self.trakt.movie_collection, self.movie_trakt_ids, dry_run=dry_run) 44 | self.clear_collected(self.trakt.episodes_collection, self.episode_trakt_ids, dry_run=dry_run) 45 | 46 | @hookimpl 47 | async def walk_movie(self, movie: Media): 48 | self.movie_trakt_ids.add(movie.trakt_id) 49 | 50 | @hookimpl 51 | async def walk_episode(self, episode: Media): 52 | self.episode_trakt_ids.add(episode.trakt_id) 53 | 54 | def clear_collected(self, existing_items: Iterable[TraktMedia], keep_ids: set[int], dry_run): 55 | from plextraktsync.trakt.trakt_set import trakt_set 56 | 57 | existing_ids = trakt_set(existing_items) 58 | delete_ids = existing_ids - keep_ids 59 | delete_items = (tm for tm in existing_items if tm.trakt in delete_ids) 60 | 61 | n = len(delete_ids) 62 | for i, tm in enumerate(delete_items, start=1): 63 | self.logger.info(f"Remove from Trakt collection ({i}/{n}): {tm}") 64 | if not dry_run: 65 | self.trakt.remove_from_collection(tm) 66 | -------------------------------------------------------------------------------- /plextraktsync/sync/LikedListsPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plextraktsync.factory import logging 6 | from plextraktsync.plugin import hookimpl 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.trakt.TraktApi import TraktApi 10 | 11 | from .plugin.SyncPluginInterface import Sync, SyncConfig 12 | 13 | 14 | class LikedListsPlugin: 15 | logger = logging.getLogger(__name__) 16 | 17 | def __init__(self, trakt: TraktApi): 18 | self.trakt = trakt 19 | 20 | @staticmethod 21 | def enabled(config: SyncConfig): 22 | return config.sync_liked_lists 23 | 24 | @classmethod 25 | def factory(cls, sync: Sync): 26 | return cls(sync.trakt) 27 | 28 | @hookimpl 29 | def init(self, sync: Sync, is_partial: bool, dry_run: bool): 30 | if is_partial or dry_run: 31 | self.logger.warning("Partial walk, disabling liked lists updating. Liked lists won't update because it needs full library sync.") 32 | if is_partial: 33 | return 34 | 35 | sync.trakt_lists.load_lists(self.trakt.liked_lists) 36 | -------------------------------------------------------------------------------- /plextraktsync/sync/Sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import logging 7 | from plextraktsync.trakt.TraktUserListCollection import TraktUserListCollection 8 | 9 | if TYPE_CHECKING: 10 | from plextraktsync.config.SyncConfig import SyncConfig 11 | from plextraktsync.plan.Walker import Walker 12 | from plextraktsync.plex.PlexApi import PlexApi 13 | from plextraktsync.trakt.TraktApi import TraktApi 14 | 15 | 16 | class Sync: 17 | logger = logging.getLogger(__name__) 18 | 19 | def __init__(self, config: SyncConfig, plex: PlexApi, trakt: TraktApi): 20 | self.config = config 21 | self.plex = plex 22 | self.trakt = trakt 23 | self.walker = None 24 | 25 | @cached_property 26 | def trakt_lists(self): 27 | return TraktUserListCollection( 28 | self.config.liked_lists_keep_watched, 29 | self.config.liked_lists_overrides, 30 | ) 31 | 32 | @cached_property 33 | def pm(self): 34 | from .plugin import SyncPluginManager 35 | 36 | pm = SyncPluginManager() 37 | pm.register_plugins(self) 38 | 39 | return pm 40 | 41 | async def sync(self, walker: Walker, dry_run=False): 42 | self.walker = walker 43 | is_partial = walker.is_partial 44 | 45 | pm = self.pm 46 | pm.hook.init(sync=self, pm=pm, is_partial=is_partial, dry_run=dry_run) 47 | 48 | if self.config.need_library_walk: 49 | async for movie in walker.find_movies(): 50 | await pm.ahook.walk_movie(movie=movie, dry_run=dry_run) 51 | 52 | async for episode in walker.find_episodes(): 53 | await pm.ahook.walk_episode(episode=episode, dry_run=dry_run) 54 | 55 | await pm.ahook.fini(walker=walker, dry_run=dry_run) 56 | -------------------------------------------------------------------------------- /plextraktsync/sync/SyncRatingsPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plextraktsync.factory import logging 6 | from plextraktsync.plugin import hookimpl 7 | 8 | if TYPE_CHECKING: 9 | from .plugin.SyncPluginInterface import Media, Sync, SyncConfig, Walker 10 | 11 | 12 | class SyncRatingsPlugin: 13 | logger = logging.getLogger(__name__) 14 | 15 | def __init__(self, config: SyncConfig): 16 | self.rating_priority = config["rating_priority"] 17 | self.plex_to_trakt = config.plex_to_trakt["ratings"] 18 | self.trakt_to_plex = config.trakt_to_plex["ratings"] 19 | self.shows = None 20 | 21 | @staticmethod 22 | def enabled(config: SyncConfig): 23 | return config.sync_ratings 24 | 25 | @classmethod 26 | def factory(cls, sync: Sync): 27 | return cls(config=sync.config) 28 | 29 | @hookimpl 30 | def init(self): 31 | self.shows = set() 32 | 33 | @hookimpl 34 | async def fini(self, walker: Walker, dry_run: bool): 35 | async for show in walker.walk_shows(self.shows, title="Syncing show ratings"): 36 | await self.sync_ratings(show, dry_run=dry_run) 37 | 38 | @hookimpl 39 | async def walk_movie(self, movie: Media, dry_run: bool): 40 | await self.sync_ratings(movie, dry_run=dry_run) 41 | 42 | @hookimpl 43 | async def walk_episode(self, episode: Media, dry_run: bool): 44 | await self.sync_ratings(episode, dry_run=dry_run) 45 | 46 | if episode.show: 47 | self.shows.add(episode.show) 48 | 49 | async def sync_ratings(self, m: Media, dry_run: bool): 50 | if m.plex_rating == m.trakt_rating: 51 | return 52 | 53 | has_trakt = m.trakt_rating is not None 54 | has_plex = m.plex_rating is not None 55 | rate = None 56 | 57 | if self.rating_priority == "none": 58 | # Only rate items with missing rating 59 | if self.plex_to_trakt and has_plex and not has_trakt: 60 | rate = "trakt" 61 | elif self.trakt_to_plex and has_trakt and not has_plex: 62 | rate = "plex" 63 | 64 | elif self.rating_priority == "trakt": 65 | # If two-way rating sync, Trakt rating takes precedence over Plex rating 66 | if self.trakt_to_plex and has_trakt: 67 | rate = "plex" 68 | elif self.plex_to_trakt and has_plex: 69 | rate = "trakt" 70 | 71 | elif self.rating_priority == "plex": 72 | # If two-way rating sync, Plex rating takes precedence over Trakt rating 73 | if self.plex_to_trakt and has_plex: 74 | rate = "trakt" 75 | elif self.trakt_to_plex and has_trakt: 76 | rate = "plex" 77 | 78 | if rate == "trakt": 79 | self.logger.info( 80 | f"Rating {m.title_link} with {m.plex_rating} on Trakt (was {m.trakt_rating})", 81 | extra={"markup": True}, 82 | ) 83 | if not dry_run: 84 | m.trakt_rate() 85 | 86 | elif rate == "plex": 87 | self.logger.info( 88 | f"Rating {m.title_link} with {m.trakt_rating} on Plex (was {m.plex_rating})", 89 | extra={"markup": True}, 90 | ) 91 | if not dry_run: 92 | m.plex_rate() 93 | -------------------------------------------------------------------------------- /plextraktsync/sync/SyncWatchedPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plextraktsync.factory import logging 6 | from plextraktsync.plugin import hookimpl 7 | 8 | if TYPE_CHECKING: 9 | from .plugin.SyncPluginInterface import Media, Sync, SyncConfig 10 | 11 | 12 | class SyncWatchedPlugin: 13 | logger = logging.getLogger(__name__) 14 | 15 | def __init__(self, config: SyncConfig): 16 | self.plex_to_trakt = config.plex_to_trakt["watched_status"] 17 | self.trakt_to_plex = config.trakt_to_plex["watched_status"] 18 | 19 | @staticmethod 20 | def enabled(config: SyncConfig): 21 | return config.sync_watched_status 22 | 23 | @classmethod 24 | def factory(cls, sync: Sync): 25 | return cls(config=sync.config) 26 | 27 | @hookimpl 28 | async def walk_movie(self, movie: Media, dry_run: bool): 29 | await self.sync_watched(movie, dry_run=dry_run) 30 | 31 | @hookimpl 32 | async def walk_episode(self, episode: Media, dry_run: bool): 33 | await self.sync_watched(episode, dry_run=dry_run) 34 | 35 | async def sync_watched(self, m: Media, dry_run: bool): 36 | if m.watched_on_plex is m.watched_on_trakt: 37 | return 38 | 39 | if m.watched_on_plex: 40 | if not self.plex_to_trakt: 41 | return 42 | 43 | if m.is_episode and m.watched_before_reset: 44 | show = m.plex.item.show() 45 | self.logger.info(f"Show '{show.title}' has been reset in trakt at {m.show_reset_at}.") 46 | self.logger.info(f"Marking '{show.title}' as unwatched in Plex.") 47 | if not dry_run: 48 | m.reset_show() 49 | else: 50 | self.logger.info( 51 | f"Marking as watched in Trakt: {m.title_link}", 52 | extra={"markup": True}, 53 | ) 54 | if not dry_run: 55 | m.mark_watched_trakt() 56 | elif m.watched_on_trakt: 57 | if not self.trakt_to_plex: 58 | return 59 | self.logger.info(f"Marking as watched in Plex: {m.title_link}", extra={"markup": True}) 60 | if not dry_run: 61 | m.mark_watched_plex() 62 | -------------------------------------------------------------------------------- /plextraktsync/sync/TraktListsPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plextraktsync.decorators.measure_time import measure_time 6 | from plextraktsync.factory import logging 7 | from plextraktsync.plugin import hookimpl 8 | 9 | if TYPE_CHECKING: 10 | from .plugin.SyncPluginInterface import Media, Sync, SyncPluginManager 11 | 12 | 13 | class TraktListsPlugin: 14 | """ 15 | Plugin handling syncing of Trakt lists. 16 | """ 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | def __init__(self): 21 | self.trakt_lists = None 22 | 23 | @staticmethod 24 | def enabled(config): 25 | return any( 26 | [ 27 | # LikedListsPlugin 28 | config.sync_liked_lists, 29 | # WatchListPlugin 30 | config.update_plex_wl_as_pl, 31 | ] 32 | ) 33 | 34 | @classmethod 35 | def factory(cls, sync): 36 | return cls() 37 | 38 | @hookimpl(trylast=True) 39 | def init(self, pm: SyncPluginManager, sync: Sync): 40 | # Skip updating lists if it's empty 41 | if sync.trakt_lists.is_empty: 42 | self.logger.warning("Disabling TraktListsPlugin: No lists to process") 43 | pm.unregister(self) 44 | return 45 | 46 | self.trakt_lists = sync.trakt_lists 47 | 48 | @hookimpl 49 | async def fini(self, dry_run: bool): 50 | if dry_run: 51 | return 52 | 53 | with measure_time("Updated Trakt Lists"): 54 | for tl in self.trakt_lists: 55 | updated = tl.plex_list.update(tl.plex_items_sorted) 56 | if not updated: 57 | continue 58 | self.logger.info( 59 | f"Plex list {tl.title_link} ({len(tl.plex_items)} items) updated", 60 | extra={"markup": True}, 61 | ) 62 | 63 | @hookimpl 64 | async def walk_movie(self, movie: Media): 65 | self.trakt_lists.add_to_lists(movie) 66 | 67 | @hookimpl 68 | async def walk_episode(self, episode: Media): 69 | self.trakt_lists.add_to_lists(episode) 70 | -------------------------------------------------------------------------------- /plextraktsync/sync/WatchListPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.decorators.measure_time import measure_time 7 | from plextraktsync.factory import logging 8 | from plextraktsync.plugin import hookimpl 9 | 10 | if TYPE_CHECKING: 11 | from plextraktsync.plex.PlexApi import PlexApi 12 | from plextraktsync.trakt.TraktApi import TraktApi 13 | 14 | from .plugin.SyncPluginInterface import Media, Sync, SyncConfig, Walker 15 | 16 | 17 | class WatchListPlugin: 18 | logger = logging.getLogger(__name__) 19 | 20 | def __init__(self, config: SyncConfig, plex: PlexApi, trakt: TraktApi): 21 | self.config = config 22 | self.plex = plex 23 | self.trakt = trakt 24 | 25 | @staticmethod 26 | def enabled(config: SyncConfig): 27 | return config.sync_watchlists 28 | 29 | @classmethod 30 | def factory(cls, sync: Sync): 31 | return cls( 32 | config=sync.config, 33 | plex=sync.plex, 34 | trakt=sync.trakt, 35 | ) 36 | 37 | @hookimpl 38 | def init(self, sync: Sync, is_partial: bool): 39 | if not self.config.update_plex_wl_as_pl: 40 | return 41 | 42 | if is_partial: 43 | self.logger.warning("Running partial library sync. Watchlist as playlist won't update because it needs full library sync.") 44 | else: 45 | sync.trakt_lists.add_watchlist(self.trakt.watchlist_movies) 46 | 47 | @hookimpl 48 | async def fini(self, walker: Walker, dry_run: bool): 49 | if walker.config.walk_watchlist and self.sync_wl: 50 | with measure_time("Updated watchlist"): 51 | await self.sync_watchlist(walker, dry_run=dry_run) 52 | 53 | if self.config.update_plex_wl_as_pl and dry_run: 54 | self.logger.warning("Running partial library sync. Liked lists won't update because it needs full library sync.") 55 | 56 | @cached_property 57 | def plex_wl(self): 58 | from plextraktsync.plex.PlexWatchList import PlexWatchList 59 | 60 | return PlexWatchList(self.plex.watchlist()) 61 | 62 | @cached_property 63 | def sync_wl(self): 64 | return self.config.sync_wl and len(self.plex_wl) > 0 65 | 66 | @cached_property 67 | def trakt_wl(self): 68 | from plextraktsync.trakt.TraktWatchlist import TraktWatchList 69 | 70 | return TraktWatchList(self.trakt.watchlist_movies + self.trakt.watchlist_shows) 71 | 72 | def watchlist_sync_item(self, m: Media, dry_run: bool): 73 | if m.plex is None: 74 | if self.config.update_plex_wl: 75 | self.logger.info( 76 | f"Skipping {m.title_link} from Trakt watchlist because not found in Plex Discover", 77 | extra={"markup": True}, 78 | ) 79 | elif self.config.update_trakt_wl: 80 | self.logger.info( 81 | f"Removing {m.title_link} from Trakt watchlist", 82 | extra={"markup": True}, 83 | ) 84 | if not dry_run: 85 | m.remove_from_trakt_watchlist() 86 | return 87 | 88 | if m in self.plex_wl: 89 | if m not in self.trakt_wl: 90 | if self.config.update_trakt_wl: 91 | self.logger.info( 92 | f"Adding {m.title_link} to Trakt watchlist", 93 | extra={"markup": True}, 94 | ) 95 | if not dry_run: 96 | m.add_to_trakt_watchlist() 97 | else: 98 | self.logger.info( 99 | f"Removing {m.title_link} from Plex watchlist", 100 | extra={"markup": True}, 101 | ) 102 | if not dry_run: 103 | m.remove_from_plex_watchlist() 104 | else: 105 | # Plex Online search is inaccurate, and it doesn't offer search by id. 106 | # Remove known match from trakt watchlist, so that the search would not be attempted. 107 | # Example, trakt id 187634 where title mismatches: 108 | # - "The Vortex": https://trakt.tv/movies/the-vortex-2012 109 | # - "Big Bad Bugs": https://app.plex.tv/desktop/#!/provider/tv.plex.provider.vod/details?key=%2Flibrary%2Fmetadata%2F5d776b1cad5437001f7936f4 110 | del self.trakt_wl[m] 111 | elif m in self.trakt_wl: 112 | if self.config.update_plex_wl: 113 | self.logger.info(f"Adding {m.title_link} to Plex watchlist", extra={"markup": True}) 114 | if not dry_run: 115 | m.add_to_plex_watchlist() 116 | else: 117 | self.logger.info( 118 | f"Removing {m.title_link} from Trakt watchlist", 119 | extra={"markup": True}, 120 | ) 121 | if not dry_run: 122 | m.remove_from_trakt_watchlist() 123 | 124 | async def sync_watchlist(self, walker: Walker, dry_run: bool): 125 | # NOTE: Plex watchlist sync removes matching items from trakt lists 126 | # See the comment above around "del self.trakt_wl[m]" 127 | async for m in walker.media_from_plexlist(self.plex_wl): 128 | self.watchlist_sync_item(m, dry_run) 129 | 130 | # Because Plex syncing might have emptied the watchlists, skip printing the 0/0 progress 131 | if len(self.trakt_wl): 132 | async for m in walker.media_from_traktlist(self.trakt_wl): 133 | self.watchlist_sync_item(m, dry_run) 134 | -------------------------------------------------------------------------------- /plextraktsync/sync/WatchProgressPlugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import timedelta 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import logging 7 | from plextraktsync.media.Media import Media 8 | from plextraktsync.plugin import hookimpl 9 | 10 | if TYPE_CHECKING: 11 | from plextraktsync.trakt.TraktApi import TraktApi 12 | 13 | from .plugin.SyncPluginInterface import Sync, SyncConfig 14 | 15 | 16 | class WatchProgressPlugin: 17 | logger = logging.getLogger(__name__) 18 | 19 | def __init__(self, trakt: TraktApi): 20 | self.trakt = trakt 21 | 22 | @staticmethod 23 | def enabled(config: SyncConfig): 24 | return config.sync_playback_status 25 | 26 | @classmethod 27 | def factory(cls, sync: Sync): 28 | return cls(sync.trakt) 29 | 30 | @hookimpl 31 | async def walk_movie(self, movie: Media, dry_run: bool): 32 | await self.sync_progress(movie, dry_run=dry_run) 33 | 34 | @hookimpl 35 | async def walk_episode(self, episode: Media, dry_run: bool): 36 | await self.sync_progress(episode, dry_run=dry_run) 37 | 38 | async def sync_progress(self, m: Media, dry_run=False): 39 | p = self.trakt.watch_progress.match(m) 40 | if not p: 41 | return 42 | progress = m.plex.progress_millis(p.progress) 43 | if progress == 0.0: 44 | self.logger.warning( 45 | f"{m.title_link}: Skip progress, setting to 0 will not work", 46 | extra={"markup": True}, 47 | ) 48 | return 49 | 50 | view_offset = timedelta(milliseconds=m.plex.item.viewOffset) 51 | progress_offset = timedelta(milliseconds=progress) 52 | 53 | # Check if progress is at or very close to 100% (≥ 99%) 54 | if p.progress >= 99.0: 55 | self.logger.info( 56 | f"{m.title_link}: Marking as fully watched (progress: {p.progress:.02F}%)", 57 | extra={"markup": True}, 58 | ) 59 | if not dry_run: 60 | m.plex.item.markWatched() 61 | else: 62 | self.logger.info( 63 | f"{m.title_link}: Set watch progress to {p.progress:.02F}%: {view_offset} -> {progress_offset}", 64 | extra={"markup": True}, 65 | ) 66 | if not dry_run: 67 | m.plex.item.updateProgress(progress) 68 | -------------------------------------------------------------------------------- /plextraktsync/sync/plugin/SyncPluginInterface.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plextraktsync.plugin import hookspec 6 | 7 | if TYPE_CHECKING: 8 | from plextraktsync.config.SyncConfig import SyncConfig # noqa: F401 9 | from plextraktsync.media.Media import Media 10 | from plextraktsync.plan.Walker import Walker 11 | from plextraktsync.sync.plugin import SyncPluginManager 12 | from plextraktsync.sync.Sync import Sync 13 | 14 | 15 | class SyncPluginInterface: 16 | """A hook specification namespace.""" 17 | 18 | @hookspec 19 | def init(self, pm: SyncPluginManager, sync: Sync, is_partial: bool, dry_run: bool): 20 | """Hook called at sync process initialization""" 21 | 22 | @hookspec 23 | async def fini(self, walker: Walker, dry_run: bool): 24 | """Hook called at sync process finalization""" 25 | 26 | @hookspec 27 | async def walk_movie(self, movie: Media, dry_run: bool): 28 | """Hook called walk a movie media object""" 29 | 30 | @hookspec 31 | async def walk_episode(self, episode: Media, dry_run: bool): 32 | """Hook called walk a episode media object""" 33 | -------------------------------------------------------------------------------- /plextraktsync/sync/plugin/SyncPluginManager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | import apluggy as pluggy 7 | 8 | from plextraktsync.decorators.measure_time import measure_time 9 | from plextraktsync.factory import logging 10 | 11 | if TYPE_CHECKING: 12 | from plextraktsync.sync.Sync import Sync 13 | 14 | 15 | class SyncPluginManager: 16 | logger = logging.getLogger(__name__) 17 | 18 | @cached_property 19 | def pm(self): 20 | from .SyncPluginInterface import SyncPluginInterface 21 | 22 | pm = pluggy.PluginManager("PlexTraktSync") 23 | pm.add_hookspecs(SyncPluginInterface) 24 | 25 | return pm 26 | 27 | @cached_property 28 | def ahook(self): 29 | return self.pm.ahook 30 | 31 | @cached_property 32 | def hook(self): 33 | return self.pm.hook 34 | 35 | @cached_property 36 | def unregister(self): 37 | return self.pm.unregister 38 | 39 | @property 40 | def plugins(self): 41 | from ..AddCollectionPlugin import AddCollectionPlugin 42 | from ..ClearCollectedPlugin import ClearCollectedPlugin 43 | from ..LikedListsPlugin import LikedListsPlugin 44 | from ..SyncRatingsPlugin import SyncRatingsPlugin 45 | from ..SyncWatchedPlugin import SyncWatchedPlugin 46 | from ..TraktListsPlugin import TraktListsPlugin 47 | from ..WatchListPlugin import WatchListPlugin 48 | from ..WatchProgressPlugin import WatchProgressPlugin 49 | 50 | yield AddCollectionPlugin 51 | yield ClearCollectedPlugin 52 | yield LikedListsPlugin 53 | yield SyncRatingsPlugin 54 | yield SyncWatchedPlugin 55 | yield TraktListsPlugin 56 | yield WatchListPlugin 57 | yield WatchProgressPlugin 58 | 59 | def register_plugins(self, sync: Sync): 60 | for plugin in self.plugins: 61 | enabled = plugin.enabled(sync.config) 62 | self.logger.info(f"Enable sync plugin '{plugin.__name__}': {enabled}") 63 | if not enabled: 64 | continue 65 | with measure_time(f"Created '{plugin.__name__}' plugin", logger=self.logger.debug): 66 | p = plugin.factory(sync) 67 | with measure_time(f"Registered '{plugin.__name__}' plugin", logger=self.logger.debug): 68 | self.pm.register(p) 69 | -------------------------------------------------------------------------------- /plextraktsync/sync/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .SyncPluginManager import SyncPluginManager # noqa: F401 4 | -------------------------------------------------------------------------------- /plextraktsync/trakt/PartialTraktMedia.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from typing import Any 8 | 9 | from plextraktsync.trakt.types import TraktMedia 10 | 11 | 12 | @dataclass 13 | class PartialTraktMedia: 14 | ids: Any 15 | media_type: str 16 | watched_at: str = None 17 | 18 | @classmethod 19 | def create(cls, m: TraktMedia, **extra): 20 | return cls( 21 | **{ 22 | "ids": m.ids, 23 | "media_type": m.media_type, 24 | **extra, 25 | } 26 | ) 27 | -------------------------------------------------------------------------------- /plextraktsync/trakt/ScrobblerCollection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import UserDict 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.trakt.ScrobblerProxy import ScrobblerProxy 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.trakt.TraktApi import TraktApi 10 | from plextraktsync.trakt.types import TraktPlayable 11 | 12 | 13 | class ScrobblerCollection(UserDict): 14 | def __init__(self, trakt: TraktApi, threshold=80): 15 | super().__init__() 16 | self.trakt = trakt 17 | self.threshold = threshold 18 | 19 | def __missing__(self, media: TraktPlayable): 20 | scrobbler = media.scrobble(0, None, None) 21 | self[media] = proxy = ScrobblerProxy(scrobbler, self.threshold) 22 | 23 | return proxy 24 | -------------------------------------------------------------------------------- /plextraktsync/trakt/ScrobblerProxy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import factory, logging 7 | 8 | if TYPE_CHECKING: 9 | from trakt.sync import Scrobbler 10 | 11 | 12 | class ScrobblerProxy: 13 | """ 14 | Proxy to Scrobbler that queues requests to update trakt 15 | """ 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | def __init__(self, scrobbler: Scrobbler, threshold=80): 20 | self.scrobbler = scrobbler 21 | self.threshold = threshold 22 | 23 | def update(self, progress: float): 24 | self.logger.debug(f"update({self.scrobbler.media}): {progress}") 25 | self.queue.scrobble_update((self.scrobbler, progress)) 26 | 27 | def pause(self, progress: float): 28 | self.logger.debug(f"pause({self.scrobbler.media}): {progress}") 29 | self.queue.scrobble_pause((self.scrobbler, progress)) 30 | 31 | def stop(self, progress: float): 32 | if progress >= self.threshold: 33 | self.logger.debug(f"stop({self.scrobbler.media}): {progress}") 34 | self.queue.scrobble_stop((self.scrobbler, progress)) 35 | else: 36 | self.logger.debug(f"pause({self.scrobbler.media}): {progress}") 37 | self.queue.scrobble_pause((self.scrobbler, progress)) 38 | 39 | @cached_property 40 | def queue(self): 41 | return factory.queue 42 | -------------------------------------------------------------------------------- /plextraktsync/trakt/TraktItem.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.rich.RichMarkup import RichMarkup 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.trakt.types import TraktMedia 10 | 11 | 12 | class TraktItem(RichMarkup): 13 | def __init__(self, item: TraktMedia): 14 | self.item = item 15 | 16 | @cached_property 17 | def type(self): 18 | """ 19 | Return "movie", "show", "season", "episode" 20 | """ 21 | # NB: TVSeason does not have "media_type" property 22 | return self.item.media_type[:-1] 23 | 24 | @property 25 | def guids(self): 26 | return {k: v for k, v in self.item.ids["ids"].items() if k in ["imdb", "tmdb", "tvdb"]} 27 | 28 | @property 29 | def title_link(self): 30 | link = f"https://trakt.tv/{self.item.ext}" 31 | 32 | return self.markup_link(link, self.item.title) 33 | -------------------------------------------------------------------------------- /plextraktsync/trakt/TraktLookup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.decorators.retry import retry 7 | from plextraktsync.factory import logging 8 | 9 | EPISODES_ORDERING_WARNING = "episodes ordering is different in Plex and Trakt. Check your Plex media source, TMDB is recommended." 10 | 11 | if TYPE_CHECKING: 12 | from trakt.tv import TVEpisode, TVShow 13 | 14 | from plextraktsync.plex.guid.PlexGuid import PlexGuid 15 | 16 | 17 | class TraktLookup: 18 | """ 19 | Trakt lookup table to find all Trakt episodes of a TVShow 20 | """ 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | def __init__(self, tm: TVShow): 25 | self.provider_table = {} 26 | self.tm = tm 27 | self.same_order = True 28 | 29 | @cached_property 30 | @retry() 31 | def table(self): 32 | """ 33 | Build a lookup-table accessible via table[season][episode] 34 | 35 | - https://github.com/moogar0880/PyTrakt/pull/185 36 | """ 37 | 38 | seasons = {} 39 | for season in self.tm.seasons: 40 | episodes = {} 41 | for episode in season.episodes: 42 | episodes[episode.number] = episode 43 | seasons[season.season] = episodes 44 | return seasons 45 | 46 | def _reverse_lookup(self, provider): 47 | """ 48 | Build a lookup-table accessible via table[provider][id] 49 | only if episodes ordering is different between Plex and Trakt 50 | """ 51 | # NB: side effect, assumes that from_number() is called first to populate self.table 52 | table = {} 53 | for season in self.table: 54 | for te in self.table[season].values(): 55 | table[str(te.ids.get(provider))] = te 56 | self.provider_table[provider] = table 57 | self.logger.debug(f"{self.tm.title}: lookup table build with '{provider}' ids") 58 | 59 | def from_guid(self, guid: PlexGuid): 60 | """ 61 | Find Trakt Episode from Guid of Plex Episode 62 | """ 63 | te = self.from_number(guid.pm.season_number, guid.pm.episode_number) 64 | if self.invalid_match(guid, te): 65 | te = self.from_id(guid.provider, guid.id) 66 | 67 | return te 68 | 69 | @staticmethod 70 | def invalid_match(guid: PlexGuid, episode: TVEpisode | None) -> bool: 71 | """ 72 | Checks if guid and episode don't match by comparing trakt provided id 73 | """ 74 | 75 | if not episode: 76 | # nothing to compare with 77 | return True 78 | if guid.pm.is_legacy_agent: 79 | # check can not be performed 80 | return False 81 | id_from_trakt = getattr(episode, guid.provider, None) 82 | return str(id_from_trakt) != guid.id 83 | 84 | def from_number(self, season: int, number: int): 85 | try: 86 | return self.table[season][number] 87 | except KeyError: 88 | return None 89 | 90 | def from_id(self, provider, id): 91 | # NB: the code assumes from_id is called only if from_number fails 92 | if provider not in self.provider_table: 93 | self._reverse_lookup(provider) 94 | try: 95 | ep = self.provider_table[provider][id] 96 | except KeyError: 97 | return None 98 | if self.same_order: 99 | self.logger.warning(f"'{self.tm.title}' {EPISODES_ORDERING_WARNING}") 100 | self.same_order = False 101 | return ep 102 | -------------------------------------------------------------------------------- /plextraktsync/trakt/TraktRatingCollection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from plextraktsync.decorators.flatten import flatten_dict 6 | from plextraktsync.util.Rating import Rating 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.trakt.TraktApi import TraktApi 10 | 11 | 12 | class TraktRatingCollection(dict): 13 | """ 14 | A dictionary of: 15 | ["movies", "shows", "episodes"] => { 16 | trakt_id => rating 17 | } 18 | """ 19 | 20 | def __init__(self, trakt: TraktApi): 21 | super().__init__() 22 | self.trakt = trakt 23 | 24 | def __missing__(self, media_type: str): 25 | self[media_type] = ratings = self.ratings(media_type) 26 | 27 | return ratings 28 | 29 | @flatten_dict 30 | def ratings(self, media_type: str): 31 | index = media_type.rstrip("s") 32 | for r in self.trakt.get_ratings(media_type): 33 | yield r[index]["ids"]["trakt"], Rating.create(r["rating"], r["rated_at"]) 34 | -------------------------------------------------------------------------------- /plextraktsync/trakt/TraktUserListCollection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import UserList 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import logging 7 | from plextraktsync.trakt.TraktUserList import TraktUserList 8 | 9 | if TYPE_CHECKING: 10 | from plextraktsync.media.Media import Media 11 | from plextraktsync.trakt.types import TraktLikedList, TraktPlayable 12 | 13 | 14 | class TraktUserListCollection(UserList): 15 | logger = logging.getLogger(__name__) 16 | 17 | def __init__(self, keep_watched: bool, trakt_lists_overrides: dict): 18 | super().__init__() 19 | self.keep_watched = keep_watched 20 | self.trakt_lists_overrides = trakt_lists_overrides 21 | 22 | @property 23 | def is_empty(self): 24 | return not len(self) 25 | 26 | def add_to_lists(self, m: Media): 27 | # Skip movie editions 28 | # https://support.plex.tv/articles/multiple-editions/#:~:text=Do%20Multiple%20Editions%20work%20with%20watch%20state%20syncing%3F 29 | if m.plex.edition_title is not None: 30 | return 31 | for tl in self: 32 | tl.add(m) 33 | 34 | def load_lists(self, liked_lists: list[TraktLikedList]): 35 | for liked_list in liked_lists: 36 | self.add_list(liked_list["listid"], liked_list["listname"], liked_list["private"], liked_list["list_type"]) 37 | 38 | def add_watchlist(self, items: list[TraktPlayable]): 39 | tl = TraktUserList.from_watchlist(items) 40 | self.append(tl) 41 | return tl 42 | 43 | def add_list(self, list_id: int, list_name: str, is_private: bool = False, list_type: str = "personal"): 44 | list_config = self.trakt_lists_overrides.get(list_name, {}) 45 | keep_watched = list_config.get("keep_watched", self.keep_watched) 46 | tl = TraktUserList.from_trakt_list(list_id, list_name, keep_watched, is_private, list_type) 47 | self.append(tl) 48 | return tl 49 | -------------------------------------------------------------------------------- /plextraktsync/trakt/TraktWatchlist.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.decorators.flatten import flatten_dict 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.media.Media import Media 10 | from plextraktsync.trakt.types import TraktMedia 11 | 12 | 13 | class TraktWatchList: 14 | def __init__(self, watchlist: list[TraktMedia]): 15 | self.watchlist = watchlist 16 | 17 | def __iter__(self): 18 | return iter(self.watchlist) 19 | 20 | def __len__(self): 21 | return len(self.watchlist) 22 | 23 | def __contains__(self, m: Media): 24 | return m.trakt_id in self.idmap 25 | 26 | def __delitem__(self, m: Media): 27 | for i in (i for i, tm in enumerate(self.watchlist) if tm.trakt == m.trakt_id): 28 | del self.watchlist[i] 29 | 30 | del self.idmap[m.trakt_id] 31 | 32 | @cached_property 33 | @flatten_dict 34 | def idmap(self) -> dict[int]: 35 | """ 36 | Return map of trakt_id of Trakt Watchlist items. 37 | We use dict() rather set() to be able to remove items from it. 38 | """ 39 | for tm in self.watchlist: 40 | yield tm.trakt, None 41 | -------------------------------------------------------------------------------- /plextraktsync/trakt/WatchProgress.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from trakt.sync import PlaybackEntry 7 | 8 | from plextraktsync.media.Media import Media 9 | 10 | 11 | class WatchProgress: 12 | def __init__(self, progress: list[PlaybackEntry]): 13 | self.progress = progress 14 | 15 | def match(self, m: Media): 16 | p = [p for p in self.progress if p == m] 17 | if not len(p): 18 | return None 19 | if len(p) != 1: 20 | raise RuntimeError(f"Unexpected match count {len(p)}") 21 | return p[0] 22 | -------------------------------------------------------------------------------- /plextraktsync/trakt/trakt_set.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from collections.abc import Iterable 7 | 8 | from plextraktsync.trakt.types import TraktMedia 9 | 10 | 11 | def trakt_set(collection: Iterable[TraktMedia]) -> set[int]: 12 | """ 13 | Create set of trakt_id's from collection 14 | """ 15 | return set(map(lambda m: m.trakt, collection)) 16 | -------------------------------------------------------------------------------- /plextraktsync/trakt/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict, Union 4 | 5 | from trakt.movies import Movie 6 | from trakt.tv import TVEpisode, TVSeason, TVShow 7 | 8 | TraktMedia = Union[Movie, TVShow, TVSeason, TVEpisode] 9 | TraktPlayable = Union[Movie, TVEpisode] 10 | 11 | 12 | class TraktLikedList(TypedDict): 13 | listid: int 14 | listname: str 15 | private: bool 16 | list_type: str # 'personal' or 'official' 17 | -------------------------------------------------------------------------------- /plextraktsync/util/Path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from os import getenv, makedirs 5 | from os.path import abspath, dirname, exists, join 6 | 7 | 8 | class Path: 9 | def __init__(self): 10 | self.app_name = "PlexTraktSync" 11 | self.module_path = dirname(dirname(abspath(__file__))) 12 | self.app_path = dirname(self.module_path) 13 | 14 | self.ensure_dir(self.config_dir) 15 | self.ensure_dir(self.log_dir) 16 | self.ensure_dir(self.cache_dir) 17 | 18 | self.default_config_file = join(self.module_path, "config.default.yml") 19 | self.config_file = join(self.config_dir, "config.json") 20 | self.config_yml = join(self.config_dir, "config.yml") 21 | self.servers_config = join(self.config_dir, "servers.yml") 22 | self.pytrakt_file = join(self.config_dir, ".pytrakt.json") 23 | self.env_file = join(self.config_dir, ".env") 24 | 25 | @cached_property 26 | def config_dir(self): 27 | d = self.app_dir.user_config_dir if self.installed else self.app_path 28 | return getenv("PTS_CONFIG_DIR", d) 29 | 30 | @cached_property 31 | def cache_dir(self): 32 | d = self.app_dir.user_cache_dir if self.installed else self.app_path 33 | return getenv("PTS_CACHE_DIR", d) 34 | 35 | @cached_property 36 | def log_dir(self): 37 | d = self.app_dir.user_log_dir if self.installed else self.app_path 38 | return getenv("PTS_LOG_DIR", d) 39 | 40 | @cached_property 41 | def app_dir(self): 42 | from platformdirs import PlatformDirs 43 | 44 | return PlatformDirs(self.app_name) 45 | 46 | @cached_property 47 | def installed(self): 48 | from plextraktsync.util.packaging import installed 49 | 50 | return installed() 51 | 52 | @staticmethod 53 | def ensure_dir(directory): 54 | if not exists(directory): 55 | makedirs(directory) 56 | -------------------------------------------------------------------------------- /plextraktsync/util/Rating.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timezone 4 | from typing import NamedTuple 5 | 6 | from trakt.utils import timestamp 7 | 8 | 9 | class Rating(NamedTuple): 10 | rating: int 11 | rated_at: datetime | None 12 | 13 | RATING_TITLES = { 14 | 1: "Weak Sauce :(", 15 | 2: "Terrible", 16 | 3: "Bad", 17 | 4: "Poor", 18 | 5: "Meh", 19 | 6: "Fair", 20 | 7: "Good", 21 | 8: "Great", 22 | 9: "Superb", 23 | 10: "Totally Ninja!", 24 | } 25 | 26 | def __eq__(self, other): 27 | """Ratings are equal if their rating value is the same""" 28 | if isinstance(other, (int, float)): 29 | return self.rating == int(other) 30 | 31 | if other is None: 32 | return False 33 | 34 | return self.rating == other.rating 35 | 36 | @property 37 | def title(self): 38 | return self.RATING_TITLES[self.rating] 39 | 40 | def __str__(self): 41 | rated_at = f"'{timestamp(self.rated_at)}'" if self.rated_at else None 42 | return f"Rating(rating={self.rating}, rated_at={rated_at}, title={self.title})" 43 | 44 | @classmethod 45 | def create(cls, rating: int | float | None, rated_at: datetime | str | None): 46 | if rating is None: 47 | return None 48 | 49 | rating = int(rating) 50 | 51 | # Treat rating=0 as no rating 52 | # https://github.com/Taxel/PlexTraktSync/issues/2122 53 | if rating == 0: 54 | return None 55 | 56 | if isinstance(rated_at, str): 57 | try: 58 | rated_at = datetime.fromisoformat(rated_at) 59 | except ValueError: 60 | # Handle older Python < 3.11 61 | rated_at = ( 62 | datetime.strptime(rated_at, "%Y-%m-%dT%H:%M:%S.%fZ") 63 | # https://stackoverflow.com/questions/3305413/how-to-preserve-timezone-when-parsing-date-time-strings-with-strptime/63988322#63988322 64 | .replace(tzinfo=timezone.utc) 65 | ) 66 | 67 | return cls(rating, rated_at) 68 | -------------------------------------------------------------------------------- /plextraktsync/util/Timer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import monotonic, sleep 4 | 5 | from plextraktsync.factory import logging 6 | 7 | 8 | class Timer: 9 | """ 10 | Class dealing with limiting that something is not called more often than {delay} 11 | """ 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | def __init__(self, delay: float): 16 | if delay <= 0: 17 | raise ValueError(f"Delay must be a positive number: {delay}") 18 | self.delay = delay 19 | self.last_time = None 20 | 21 | @property 22 | def time_remaining(self): 23 | last_time = self.last_time 24 | if not last_time: 25 | return 0.0 26 | diff_time = monotonic() - last_time 27 | if diff_time < self.delay: 28 | return self.delay - diff_time 29 | return 0.0 30 | 31 | def update(self): 32 | self.last_time = monotonic() 33 | 34 | def start(self): 35 | """ 36 | Start the timer only if not yet started 37 | """ 38 | if not self.last_time: 39 | self.update() 40 | 41 | def wait_if_needed(self): 42 | if not self.last_time: 43 | self.update() 44 | return 45 | 46 | wait = self.time_remaining 47 | if wait: 48 | self.logger.debug(f"Sleeping for {wait:.3f} seconds") 49 | sleep(wait) 50 | self.update() 51 | -------------------------------------------------------------------------------- /plextraktsync/util/Version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | 5 | 6 | class Version: 7 | @property 8 | def version(self): 9 | from plextraktsync import __version__ 10 | 11 | return __version__ 12 | 13 | @cached_property 14 | def full_version(self): 15 | from plextraktsync import __version__ 16 | 17 | # Released in PyPI 18 | if not __version__.endswith(".0dev0"): 19 | return __version__ 20 | 21 | # Print version from pip 22 | if self.pipx_installed: 23 | v = self.vcs_info 24 | return f"{__version__[0:-4]}@pr/{v['pr']}#{v['short_commit_id']}" 25 | 26 | # If installed with Git 27 | gv = self.git_version_info 28 | if gv: 29 | return f"{__version__}: {gv}" 30 | 31 | return __version__ 32 | 33 | @property 34 | def py_full_version(self): 35 | from sys import version 36 | 37 | return version.replace("\n", "") 38 | 39 | @property 40 | def py_version(self): 41 | from platform import python_version 42 | 43 | return python_version() 44 | 45 | @property 46 | def py_platform(self): 47 | from platform import platform 48 | 49 | return platform(terse=True, aliased=True) 50 | 51 | @property 52 | def plex_api_version(self): 53 | from plexapi import VERSION 54 | 55 | return VERSION 56 | 57 | @property 58 | def trakt_api_version(self): 59 | from trakt import __version__ 60 | 61 | return __version__ 62 | 63 | @property 64 | def git_version_info(self): 65 | from plextraktsync.util.git_version_info import git_version_info 66 | 67 | return git_version_info() 68 | 69 | @property 70 | def vcs_info(self): 71 | from plextraktsync.util.packaging import vcs_info 72 | 73 | return vcs_info("PlexTraktSync") 74 | 75 | @property 76 | def pipx_installed(self): 77 | if not self.installed: 78 | return False 79 | 80 | from plextraktsync.util.packaging import pipx_installed, program_name 81 | 82 | package = pipx_installed(program_name()) 83 | 84 | return package is not None 85 | 86 | @property 87 | def installed(self): 88 | from plextraktsync.util.packaging import installed 89 | 90 | return installed() 91 | -------------------------------------------------------------------------------- /plextraktsync/util/execp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from subprocess import call 4 | 5 | 6 | def execp(command: str): 7 | print(command) 8 | call(command, shell=True) 9 | -------------------------------------------------------------------------------- /plextraktsync/util/execx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | 5 | 6 | def execx(command: str | list[str]): 7 | if isinstance(command, str): 8 | command = command.split(" ") 9 | 10 | process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) 11 | return process.communicate()[0] 12 | -------------------------------------------------------------------------------- /plextraktsync/util/expand_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.plex.PlexIdFactory import PlexIdFactory 4 | 5 | 6 | def expand_plexid(input): 7 | for id in input: 8 | yield PlexIdFactory.create(id) 9 | -------------------------------------------------------------------------------- /plextraktsync/util/git_version_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def git_version_info(): 5 | try: 6 | from gitinfo import get_git_info 7 | except (ImportError, TypeError): 8 | return None 9 | 10 | commit = get_git_info() 11 | if not commit: 12 | return None 13 | 14 | message = commit["message"].split("\n")[0] 15 | 16 | return f"{commit['commit'][0:8]}: {message} @{commit['author_date']}" 17 | -------------------------------------------------------------------------------- /plextraktsync/util/local_url.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def local_url(port=32400): 5 | """ 6 | Find url for local plex access. 7 | """ 8 | 9 | from os import environ 10 | 11 | if not environ.get("PTS_IN_DOCKER"): 12 | return f"http://localhost:{port}" 13 | 14 | import socket 15 | 16 | try: 17 | host_ip = socket.gethostbyname("host.docker.internal") 18 | except socket.gaierror: 19 | try: 20 | from subprocess import check_output 21 | 22 | host_ip = check_output("ip -4 route show default | awk '{ print $3 }'", shell=True).decode().rstrip() 23 | except Exception: 24 | host_ip = "172.17.0.1" 25 | 26 | return f"http://{host_ip}:{port}" 27 | -------------------------------------------------------------------------------- /plextraktsync/util/openurl.py: -------------------------------------------------------------------------------- 1 | # minimal version of openurl without six dependency 2 | # https://github.com/panda2134/open-python/blob/32a4b61b44302da185dcf6a72a48d1c726eb3e51/open_python/__init__.py 3 | from __future__ import annotations 4 | 5 | import sys 6 | 7 | from plextraktsync.util.execx import execx 8 | 9 | 10 | def openurl(url: str): 11 | try: 12 | opener = { 13 | "darwin": "open", 14 | "win32": "start", 15 | }[sys.platform] 16 | except KeyError: 17 | opener = "xdg-open" 18 | 19 | try: 20 | execx([opener, url]) 21 | except FileNotFoundError: 22 | return False 23 | 24 | return True 25 | -------------------------------------------------------------------------------- /plextraktsync/util/packaging.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import site 5 | from json import JSONDecodeError 6 | from os.path import dirname 7 | 8 | from plextraktsync.util.execx import execx 9 | 10 | 11 | def installed(): 12 | """ 13 | Return true if this package is installed to site-packages 14 | """ 15 | absdir = dirname(dirname(dirname(__file__))) 16 | paths = site.getsitepackages() 17 | 18 | return absdir in paths 19 | 20 | 21 | def pip_installed(name: str): 22 | import sys 23 | 24 | try: 25 | output = execx(f"{sys.executable} -m pip inspect") 26 | except FileNotFoundError: 27 | return None 28 | 29 | try: 30 | inspect = json.loads(output) 31 | except JSONDecodeError: 32 | return None 33 | 34 | for package in inspect["installed"]: 35 | if package["metadata"]["name"] != name: 36 | continue 37 | return package 38 | 39 | return None 40 | 41 | 42 | def pipx_installed(package: str): 43 | try: 44 | output = execx("pipx list --json") 45 | except FileNotFoundError: 46 | return None 47 | if not output: 48 | return None 49 | 50 | try: 51 | install_data = json.loads(output) 52 | except JSONDecodeError: 53 | return None 54 | if install_data is None: 55 | return None 56 | 57 | try: 58 | package = install_data["venvs"][package]["metadata"]["main_package"] 59 | except KeyError: 60 | return None 61 | 62 | return package 63 | 64 | 65 | def program_name(): 66 | """ 67 | Return current program name: 68 | - pipx: plextraktsync 69 | - pipx for pr 1000: plextraktsync@1000 70 | """ 71 | 72 | import sys 73 | from os.path import basename 74 | 75 | return basename(sys.argv[0]) 76 | 77 | 78 | def vcs_info(package: str): 79 | """ 80 | Return vcs_info from direct_url.json of a .dist-info for the package 81 | """ 82 | data = pip_installed(package) 83 | if not data: 84 | return None 85 | try: 86 | v = data["direct_url"]["vcs_info"] 87 | except KeyError: 88 | return None 89 | 90 | v["pr"] = v["requested_revision"][10:-5] 91 | v["short_commit_id"] = v["commit_id"][:8] 92 | 93 | return v 94 | -------------------------------------------------------------------------------- /plextraktsync/util/parse_date.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import timedelta 4 | 5 | from pytimeparse import parse 6 | 7 | 8 | def parse_date(date: str): 9 | return timedelta(seconds=parse(date)) 10 | -------------------------------------------------------------------------------- /plextraktsync/util/remove_empty_values.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def remove_empty_values(result): 5 | """ 6 | Update result to remove empty changes. 7 | This makes diagnostic printing cleaner if we don't print "changed: 0" 8 | """ 9 | for change_type in ["added", "existing", "updated"]: 10 | if change_type not in result: 11 | continue 12 | for media_type, value in result[change_type].copy().items(): 13 | if value == 0: 14 | del result[change_type][media_type] 15 | if len(result[change_type]) == 0: 16 | del result[change_type] 17 | 18 | for media_type, items in result["not_found"].copy().items(): 19 | if len(items) == 0: 20 | del result["not_found"][media_type] 21 | 22 | if len(result["not_found"]) == 0: 23 | del result["not_found"] 24 | 25 | if len(result) == 0: 26 | return None 27 | 28 | return result 29 | -------------------------------------------------------------------------------- /plextraktsync/watch/EventDispatcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from plextraktsync.factory import logging 4 | from plextraktsync.watch.EventFactory import EventFactory 5 | from plextraktsync.watch.events import Error, ServerStarted 6 | 7 | 8 | class EventDispatcher: 9 | logger = logging.getLogger(__name__) 10 | 11 | def __init__(self): 12 | self.event_listeners = [] 13 | self.event_factory = EventFactory() 14 | 15 | def on(self, event_type, listener, **kwargs): 16 | self.event_listeners.append( 17 | { 18 | "listener": listener, 19 | "event_type": event_type, 20 | "filters": kwargs, 21 | } 22 | ) 23 | return self 24 | 25 | def event_handler(self, data): 26 | self.logger.debug(data) 27 | if isinstance(data, (Error, ServerStarted)): 28 | return self.dispatch(data) 29 | 30 | events = self.event_factory.get_events(data) 31 | for event in events: 32 | self.dispatch(event) 33 | 34 | def dispatch(self, event): 35 | for listener in self.event_listeners: 36 | if not self.match_event(listener, event): 37 | continue 38 | 39 | try: 40 | listener["listener"](event) 41 | except Exception as e: 42 | self.logger.error(f"{type(e).__name__} was raised: {e}") 43 | 44 | import traceback 45 | 46 | self.logger.debug(traceback.format_tb(e.__traceback__)) 47 | 48 | @staticmethod 49 | def match_filter(event, key, match): 50 | if not hasattr(event, key): 51 | return False 52 | value = getattr(event, key) 53 | 54 | # check for arrays 55 | if isinstance(match, list): 56 | return value in match 57 | 58 | # check for scalars 59 | return value == match 60 | 61 | def match_event(self, listener, event): 62 | if not isinstance(event, listener["event_type"]): 63 | return False 64 | 65 | if listener["filters"]: 66 | for name, value in listener["filters"].items(): 67 | if not self.match_filter(event, name, value): 68 | return False 69 | 70 | return True 71 | -------------------------------------------------------------------------------- /plextraktsync/watch/EventFactory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | 5 | 6 | class EventFactory: 7 | EVENTS = { 8 | "account": "AccountUpdateNotification", 9 | "activity": "ActivityNotification", 10 | "backgroundProcessingQueue": "BackgroundProcessingQueueEventNotification", 11 | "playing": "PlaySessionStateNotification", 12 | "preference": "Setting", 13 | "progress": "ProgressNotification", 14 | "reachability": "ReachabilityNotification", 15 | "status": "StatusNotification", 16 | "timeline": "TimelineEntry", 17 | "transcodeSession.end": "TranscodeSession", 18 | "transcodeSession.start": "TranscodeSession", 19 | "transcodeSession.update": "TranscodeSession", 20 | } 21 | 22 | def __init__(self): 23 | self.module = importlib.import_module("plextraktsync.watch.events") 24 | 25 | def get_events(self, message): 26 | if message["size"] != 1: 27 | raise ValueError(f"Unexpected size: {message}") 28 | 29 | message_type = message["type"] 30 | if message_type not in self.EVENTS: 31 | return 32 | class_name = self.EVENTS[message_type] 33 | if class_name not in message: 34 | return 35 | for data in message[class_name]: 36 | event = self.create(class_name, **data) 37 | yield event 38 | 39 | def create(self, name, **kwargs): 40 | cls = getattr(self.module, name) 41 | return cls(**kwargs) 42 | -------------------------------------------------------------------------------- /plextraktsync/watch/ProgressBar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import factory 7 | 8 | if TYPE_CHECKING: 9 | from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem 10 | 11 | 12 | class ProgressBar(dict): 13 | ICONS = { 14 | "playing": "▶️", 15 | "paused": "⏸️", 16 | } 17 | 18 | @cached_property 19 | def progress(self): 20 | from rich.box import MINIMAL 21 | from rich.live import Live 22 | from rich.panel import Panel 23 | from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn 24 | 25 | console = factory.console 26 | progress = Progress( 27 | TextColumn( 28 | " {task.fields[play_state]} [bold blue]{task.description}", 29 | justify="left", 30 | ), 31 | BarColumn(bar_width=None), 32 | "[progress.percentage]{task.percentage:>3.1f}%", 33 | "•", 34 | TimeRemainingColumn(), 35 | console=console, 36 | ) 37 | 38 | # -1 to adjust Kitty terminal issue 39 | # https://github.com/Textualize/rich/issues/3254#issuecomment-1881885471 40 | panel_width = console.size.width - 1 41 | panel = Panel(progress, width=panel_width, padding=(0, 0), box=MINIMAL) 42 | live = Live(panel, console=console).__enter__() 43 | 44 | def stop(): 45 | progress.stop() 46 | live.stop() 47 | 48 | import atexit 49 | 50 | atexit.register(lambda: stop()) 51 | 52 | return progress 53 | 54 | def __missing__(self, m: PlexLibraryItem): 55 | self[m] = task = self.progress.add_task(m.title, play_state="") 56 | 57 | return task 58 | 59 | def play(self, m: PlexLibraryItem, progress: float): 60 | task_id = self[m] 61 | self.progress.update(task_id, completed=progress, play_state=self.ICONS["playing"]) 62 | 63 | def pause(self, m: PlexLibraryItem, progress: float): 64 | task_id = self[m] 65 | self.progress.update(task_id, completed=progress, play_state=self.ICONS["paused"]) 66 | 67 | def stop(self, m: PlexLibraryItem): 68 | task_id = self[m] 69 | self.progress.remove_task(task_id) 70 | del self[m] 71 | -------------------------------------------------------------------------------- /plextraktsync/watch/WebSocketListener.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import sleep 4 | from typing import TYPE_CHECKING 5 | 6 | from plextraktsync.factory import logging 7 | from plextraktsync.watch.EventDispatcher import EventDispatcher 8 | from plextraktsync.watch.events import Error, ServerStarted 9 | 10 | if TYPE_CHECKING: 11 | from plexapi.server import PlexServer 12 | 13 | 14 | class WebSocketListener: 15 | logger = logging.getLogger(__name__) 16 | 17 | def __init__(self, plex: PlexServer, poll_interval=5, restart_interval=15): 18 | self.plex = plex 19 | self.poll_interval = poll_interval 20 | self.restart_interval = restart_interval 21 | self.dispatcher = EventDispatcher() 22 | 23 | def on(self, event_type, listener, **kwargs): 24 | self.dispatcher.on(event_type, listener, **kwargs) 25 | 26 | def listen(self): 27 | self.logger.info("Listening for events!") 28 | while True: 29 | notifier = self.plex.startAlertListener(callback=self.dispatcher.event_handler) 30 | self.dispatcher.event_handler(ServerStarted(notifier=notifier)) 31 | 32 | while notifier.is_alive(): 33 | sleep(self.poll_interval) 34 | 35 | self.dispatcher.event_handler(Error(msg="Server closed connection")) 36 | self.logger.error(f"Listener finished. Restarting in {self.restart_interval} seconds") 37 | sleep(self.restart_interval) 38 | -------------------------------------------------------------------------------- /plextraktsync/watch/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from plexapi.server import PlexServer 7 | 8 | 9 | class Event(dict): 10 | def __str__(self): 11 | return f"{self.__class__}:{str(self.copy())}" 12 | 13 | 14 | class ServerStarted(Event): 15 | @property 16 | def notifier(self): 17 | return self["notifier"] 18 | 19 | @property 20 | def server(self) -> PlexServer: 21 | return self.notifier._server 22 | 23 | 24 | class Error(Event): 25 | @property 26 | def msg(self): 27 | return self["msg"] 28 | 29 | 30 | class AccountUpdateNotification(Event): 31 | pass 32 | 33 | 34 | class ActivityNotification(Event): 35 | @property 36 | def type(self): 37 | return self["Activity"]["type"] 38 | 39 | @property 40 | def progress(self): 41 | return self["Activity"]["progress"] 42 | 43 | @property 44 | def key(self) -> str: 45 | return self["Activity"]["Context"]["key"] 46 | 47 | @property 48 | def event(self): 49 | return self["event"] 50 | 51 | 52 | class BackgroundProcessingQueueEventNotification(Event): 53 | pass 54 | 55 | 56 | class PlaySessionStateNotification(Event): 57 | @property 58 | def key(self): 59 | return self["key"] 60 | 61 | @property 62 | def view_offset(self): 63 | return self["viewOffset"] 64 | 65 | @property 66 | def state(self): 67 | return self["state"] 68 | 69 | @property 70 | def session_key(self): 71 | return self["sessionKey"] 72 | 73 | @property 74 | def client_identifier(self): 75 | return self["clientIdentifier"] 76 | 77 | 78 | class Setting(Event): 79 | pass 80 | 81 | 82 | class ProgressNotification(Event): 83 | pass 84 | 85 | 86 | class ReachabilityNotification(Event): 87 | pass 88 | 89 | 90 | class StatusNotification(Event): 91 | pass 92 | 93 | 94 | class TimelineEntry(Event): 95 | @property 96 | def state(self): 97 | return self["state"] 98 | 99 | @property 100 | def item_id(self): 101 | return int(self["itemID"]) 102 | 103 | @property 104 | def metadata_state(self): 105 | return self["metadataState"] 106 | 107 | @property 108 | def title(self): 109 | return self["title"] 110 | 111 | 112 | class TranscodeSession(Event): 113 | pass 114 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "PlexTraktSync" 3 | dynamic = ["version"] 4 | description = "Plex-Trakt-Sync is a two-way-sync between trakt.tv and Plex Media Server" 5 | readme = "README.md" 6 | license = {file = "LICENSE"} 7 | # See: https://pypi.python.org/pypi?:action=list_classifiers 8 | classifiers = [ 9 | "Development Status :: 5 - Production/Stable", 10 | 11 | # Indicate who your project is intended for 12 | "Environment :: Console", 13 | "Operating System :: OS Independent", 14 | 15 | # Pick your license as you wish (see also "license" above) 16 | "License :: OSI Approved :: MIT License", 17 | 18 | # List of Python versions and their support status: 19 | # https://en.wikipedia.org/wiki/History_of_Python#Support 20 | # https://endoflife.date/python 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | ] 27 | requires-python = ">=3.9" 28 | 29 | [tool.setuptools.dynamic] 30 | version = {attr = "plextraktsync.__version__"} 31 | 32 | [project.urls] 33 | Homepage = "https://github.com/Taxel/PlexTraktSync" 34 | Repository = "https://github.com/Taxel/PlexTraktSync.git" 35 | Issues = "https://github.com/Taxel/PlexTraktSync/issues" 36 | 37 | [build-system] 38 | requires = [ 39 | "setuptools-declarative-requirements>=1.3.0", 40 | "setuptools>=42", 41 | "wheel", 42 | ] 43 | build-backend = "setuptools.build_meta" 44 | 45 | [tool.setuptools] 46 | packages = [ 47 | "plextraktsync", 48 | "plextraktsync.commands", 49 | "plextraktsync.config", 50 | "plextraktsync.decorators", 51 | "plextraktsync.logger", 52 | "plextraktsync.media", 53 | "plextraktsync.mixin", 54 | "plextraktsync.factory", 55 | "plextraktsync.plan", 56 | "plextraktsync.plex", 57 | "plextraktsync.plex.guid", 58 | "plextraktsync.plex.guid.provider", 59 | "plextraktsync.plugin", 60 | "plextraktsync.queue", 61 | "plextraktsync.rich", 62 | "plextraktsync.sync", 63 | "plextraktsync.sync.plugin", 64 | "plextraktsync.trakt", 65 | "plextraktsync.util", 66 | "plextraktsync.watch", 67 | ] 68 | 69 | [project.scripts] 70 | plextraktsync = "plextraktsync.cli:cli" 71 | 72 | [tool.ruff] 73 | # https://docs.astral.sh/ruff/settings/#line-length 74 | line-length = 150 75 | 76 | [tool.ruff.lint] 77 | # https://docs.astral.sh/ruff/linter/#rule-selection 78 | # https://docs.astral.sh/ruff/settings/#lint_extend-select 79 | extend-select = [ 80 | "E", # pycodestyle 81 | "F", # Pyflakes 82 | "UP", # pyupgrade 83 | "B", # flake8-bugbear 84 | "SIM", # flake8-simplify 85 | "I", # isort 86 | ] 87 | extend-ignore = [ 88 | # Not safe changes 89 | "SIM102", 90 | "SIM103", 91 | # TODO 92 | "SIM108", 93 | "B019", # Concious decision 94 | "B904", # Haven't figured out which one to use 95 | ] 96 | 97 | [tool.ruff.lint.isort] 98 | # https://docs.astral.sh/ruff/settings/#lint_isort_required-imports 99 | required-imports = ["from __future__ import annotations"] 100 | -------------------------------------------------------------------------------- /requirements.pipenv.txt: -------------------------------------------------------------------------------- 1 | # 2 | # Try to keep this to version that Dependabot has: 3 | # 4 | # - https://github.com/dependabot/dependabot-core/blob/main/python/helpers/requirements.txt 5 | # 6 | 7 | -i https://pypi.org/simple/ 8 | pipenv==2023.12.1 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | apluggy==1.1.0; python_version >= '3.9' 3 | attrs==25.3.0; python_version >= '3.8' 4 | cattrs==24.1.3; python_version >= '3.8' 5 | certifi==2025.4.26; python_version >= '3.6' 6 | charset-normalizer==3.4.2; python_version >= '3.7' 7 | click==8.1.8; python_version >= '3.7' 8 | decorator==5.2.1; python_version >= '3.8' 9 | deprecated==1.2.18; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 10 | exceptiongroup==1.3.0; python_version >= '3.7' 11 | humanize==4.12.3; python_version >= '3.9' 12 | idna==3.10; python_version >= '3.6' 13 | inquirerpy==0.3.4 14 | markdown-it-py==3.0.0; python_version >= '3.8' 15 | mdurl==0.1.2; python_version >= '3.7' 16 | oauthlib==3.2.2; python_version >= '3.6' 17 | pfzy==0.3.4 18 | platformdirs==4.3.8; python_version >= '3.9' 19 | plexapi==4.17.0; python_version >= '3.9' 20 | pluggy==1.6.0; python_version >= '3.9' 21 | prompt-toolkit==3.0.51; python_version >= '3.8' 22 | pygments==2.19.1; python_version >= '3.8' 23 | python-dotenv==1.1.0; python_version >= '3.9' 24 | python-git-info==0.8.3 25 | pytimeparse==1.1.8 26 | pytrakt==4.2.0 27 | pyyaml==6.0.2 28 | requests==2.32.3; python_version >= '3.8' 29 | requests-cache==1.2.1 30 | requests-oauthlib==2.0.0; python_version >= '3.4' 31 | rich==14.0.0; python_full_version >= '3.8.0' 32 | six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 33 | tqdm==4.67.1; python_version >= '3.7' 34 | types-decorator==5.2.0.20250324; python_version >= '3.9' 35 | typing-extensions==4.13.2; python_version >= '3.8' 36 | url-normalize==2.2.1; python_version >= '3.8' 37 | urllib3==2.4.0; python_version >= '3.9' 38 | wcwidth==0.2.13 39 | websocket-client==1.8.0 40 | wrapt==1.17.2; python_version >= '3.8' 41 | -------------------------------------------------------------------------------- /setup.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | color 04 3 | echo Killing any running versions of this script... 4 | taskkill /F /FI "WindowTitle eq Plex - Trakt Sync - Setup" /T 5 | taskkill /F /FI "WindowTitle eq Plex - Trakt Sync" /T 6 | 7 | title Plex - Trakt Sync - Setup 8 | 9 | SET mypath=%~dp0 10 | cd %mypath%\ 11 | 12 | cls 13 | 14 | echo ----------------------------------------------------------------------------------- 15 | echo Plex - Trakt Sync Setup 16 | echo ----------------------------------------------------------------------------------- 17 | 18 | echo This project stores PyTrakt API keys (no passwords) in plain text on your system. 19 | echo If you do not want to have a file containing these on your system, you can not use this project. 20 | echo. 21 | echo The entire project is open source. Just open the data folder, it's all there :) 22 | echo. 23 | SET /P APICONT="Would you like to continue? ([Y]/N)?" 24 | IF /I "%APICONT%" EQU "N" GOTO :ENDIT 25 | 26 | echo. 27 | echo Checking for Python... 28 | :: Check for Python Installation 29 | python --version 2>NUL 30 | if errorlevel 1 goto errorNoPython 31 | echo. 32 | 33 | IF NOT EXIST "%cd%\Plex Trakt Sync.lnk" goto :START 34 | 35 | echo WARNING: SETUP HAS ALREADY BEEN RUN! 36 | echo. 37 | echo What would you like to do? 38 | echo 1. Reset all settings and start again from scratch 39 | echo 2. Cancel 40 | echo. 41 | SET /P CONFIGCHOICE="Please enter your choice (1 -2): " 42 | IF /I "%CONFIGCHOICE%"=="1" GOTO :RESET 43 | IF /I "%CONFIGCHOICE%"=="3" GOTO :ADDSERVER 44 | GOTO :ENDIT 45 | 46 | :RESET 47 | schtasks /delete /tn "Plex Trakt Sync" /f >nul 2>&1 48 | del "%userprofile%\Start Menu\Programs\Startup\Plex Trakt Sync.lnk" /f >nul 2>&1 49 | del "%userprofile%\Desktop\Plex Trakt Sync.lnk" /f >nul 2>&1 50 | del ".env" /f >nul 2>&1 51 | del ".pytrakt.json" /f >nul 2>&1 52 | del "trakt_cache.sqlite" /f >nul 2>&1 53 | rmdir /q /s "plextraktsync\__pycache__" >nul 2>&1 54 | rmdir /q /s "plextraktsync\commands\__pycache__" >nul 2>&1 55 | rmdir /q /s "plextraktsync\decorators\__pycache__" >nul 2>&1 56 | echo Restoring default settings... Done! 57 | 58 | 59 | 60 | 61 | :START 62 | powershell "$s=(New-Object -COM WScript.Shell).CreateShortcut('%cd%\Plex Trakt Sync.lnk');$s.WorkingDirectory = '%cd%\';$s.TargetPath='%cd%\plextraktsync.bat';$s.IconLocation='%cd%\trakt.ico';$s.Save()" 63 | 64 | 65 | echo. 66 | SET /P SCHEDULED=Would you like to schedule sync to run daily? (Y/[N])? 67 | IF /I "%SCHEDULED%" NEQ "Y" GOTO :NOSCHEDULE 68 | SET /P SCHEDULED_TIME=What time would you like the sync to run (Use 24 hour time. eg: 01:00, 23:42)? 69 | SCHTASKS /CREATE /SC DAILY /TN "Plex Trakt Sync" /TR "%cd%\plextraktsync.bat" /ST "%SCHEDULED_TIME%" > nul 70 | 71 | :NOSCHEDULE 72 | SET /P STARTUP=Would you like to schedule sync to run on system start up? (Y/[N])? 73 | IF /I "%STARTUP%" NEQ "Y" GOTO :NOSTARTUP 74 | powershell "$s=(New-Object -COM WScript.Shell).CreateShortcut('%userprofile%\Start Menu\Programs\Startup\Plex Trakt Sync.lnk');$s.WorkingDirectory = '%cd%\';$s.TargetPath='%cd%\plextraktsync.bat';$s.IconLocation='%cd%\trakt.ico';$s.Save()" 75 | 76 | :NOSTARTUP 77 | SET /P DESKTOPQ=Would you like to create a desktop shortcut to run sync manually? (Y/[N])? 78 | IF /I "%DESKTOPQ%" NEQ "Y" GOTO :NODESKTOP 79 | powershell "$s=(New-Object -COM WScript.Shell).CreateShortcut('%userprofile%\Desktop\Plex Trakt Sync.lnk');$s.WorkingDirectory = '%cd%\';$s.TargetPath='%cd%\plextraktsync.bat';$s.IconLocation='%cd%\trakt.ico';$s.Save()" 80 | 81 | :NODESKTOP 82 | echo Once fully configured the sync will occur: 83 | IF /I "%SCHEDULED%" EQU "Y" echo. * Automatically at %SCHEDULED_TIME% 84 | IF /I "%STARTUP%" EQU "Y" echo. * Automatically at system startup 85 | IF /I "%DESKTOPQ%" EQU "Y" echo. * Manually using the shortcut on your desktop 86 | echo. * Manually using the shortcut created at %cd%\Plex Trakt Sync 87 | echo. 88 | 89 | echo Press any key to continue with setup... 90 | echo. 91 | pause > nul 92 | echo Installing Python requirements... 93 | pip install -r requirements.txt 94 | Pushd "%~dp0" 95 | call "plextraktsync.bat" 96 | goto :ENDIT 97 | 98 | :errorNoPython 99 | echo You will need to download and install Python to use this project: https://www.python.org/downloads 100 | echo Please download and install Python, then restart this project. 101 | echo Press any key to exit 102 | echo. 103 | pause > nul 104 | goto :ENDIT 105 | 106 | :NO_WRITE_ACCESS 107 | echo AN ERROR HAS OCCURRED! 108 | echo Please ensure that you have read / write access to the current directory, then restart setup. 109 | echo Press any key to exit 110 | echo. 111 | pause > nul 112 | 113 | :ENDIT 114 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [options] 2 | include_package_data = True 3 | 4 | [options.packages.find] 5 | exclude = 6 | tests 7 | 8 | [options.data_files] 9 | . = 10 | requirements.txt 11 | plextraktsync/config.default.yml 12 | 13 | [requirements-files] 14 | # setuptools does not support "file:", so use a extra package for this: 15 | # https://pypi.org/project/setuptools-declarative-requirements/ 16 | # https://github.com/pypa/setuptools/issues/1951#issuecomment-718094135 17 | install_requires = requirements.txt 18 | 19 | [flake8] 20 | ignore = E501 21 | -------------------------------------------------------------------------------- /trakt-api.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | : "${TRAKT_API_KEY=$(jq -r .CLIENT_ID < .pytrakt.json)}" 4 | : "${TRAKT_AUTHORIZATION=Bearer $(jq -r .OAUTH_TOKEN < .pytrakt.json)}" 5 | 6 | curl -sSf \ 7 | --header "Content-Type: application/json" \ 8 | --header "trakt-api-version: 2" \ 9 | --header "trakt-api-key: $TRAKT_API_KEY" \ 10 | --header "Authorization: $TRAKT_AUTHORIZATION" \ 11 | "$@" 12 | -------------------------------------------------------------------------------- /trakt.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Taxel/PlexTraktSync/01530733d10c370d584a81ee0972f4bf851148c9/trakt.ico --------------------------------------------------------------------------------