diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..e3dce7d9713 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# https://docs.docker.com/engine/reference/builder/#dockerignore-file +/* +!/*.py +!/Pipfile +!/Pipfile.lock +!/config.default.json +!/plex_trakt_sync/*.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..a09ed82185d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to PlexTraktSync + +We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +## We Develop with GitHub + +We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. + +## We Use [GitHub Flow], So All Code Changes Happen Through Pull Requests + +Pull requests are the best way to propose changes to the codebase (we use [GitHub Flow]). We actively welcome your pull requests: + +1. Fork the repo and create your branch from `master`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. Issue that pull request! + +[GitHub Flow]: https://guides.github.com/introduction/flow/index.html + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions will be understood +under the same [MIT License] that covers the project. + +Feel free to contact the maintainers if that's a concern. + +[MIT License]: http://choosealicense.com/licenses/mit/ + +## Report bugs using GitHub's [issues] + +We use GitHub issues to track public bugs. Report a bug by [opening a new issue]; it's that easy! + +[issues]: https://github.com/Taxel/PlexTraktSync/issues +[opening a new issue]: https://github.com/Taxel/PlexTraktSync/issues/new + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. + +## References + +This document was adapted from [@briandk gist] which itself was adapted from +the open-source contribution guidelines for [Facebook's Draft]. + +[@briandk gist]: https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62 +[Facebook's Draft]: https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..674e7b7ffc7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.9-alpine3.13 AS base + +WORKDIR /app +ENTRYPOINT ["/app/main.py"] + +# Install app depedencies +RUN pip install pipenv +COPY Pipfile* ./ +RUN pipenv install --system --deploy + +# Copy rest of the app +COPY . . diff --git a/Pipfile b/Pipfile index ae2da582fe2..493b851ebf8 100644 --- a/Pipfile +++ b/Pipfile @@ -6,10 +6,11 @@ verify_ssl = true [dev-packages] [packages] +click = "==7.1.2" +plexapi = "==4.5.0" python-dotenv = "==0.15.0" requests-cache = "==0.5.2" trakt = "==3.0.0" -plexapi = "==4.5.0" [requires] python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock index cb817e9dc32..dd360c80906 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e0ee6c43e322ee908327f8153228fbe5e617bdc5fdb938f00943f1e1d0b4f7b9" + "sha256": "b4bfc898459f0340931b972e0262bb08ae1fe0d9bc0d0b4811817cabce12897b" }, "pipfile-spec": 6, "requires": { @@ -31,6 +31,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.0.0" }, + "click": { + "hashes": [ + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "index": "pypi", + "version": "==7.1.2" + }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", diff --git a/README.md b/README.md index 695998c457c..a50c5b2515e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,22 @@ type `crontab -e` in the terminal. 0 */2 * * * cd ~/path/to/this/repo && ./plex_trakt_sync.sh ``` +## Sync options + +The sync subcommand supports `--sync=tv` and `--sync=movies` options, +so you can sync only specific library types. + +``` +➔ ./plex_trakt_sync.sh sync --help +Usage: main.py sync [OPTIONS] + + Perform sync between Plex and Trakt + +Options: + --sync [all|movies|tv] Specify what to sync [default: all] + --help Show this message and exit. +``` + ## Sync settings To disable parts of the functionality of this software, look no further than diff --git a/clear_trakt_collections.py b/clear_trakt_collections.py deleted file mode 100755 index 6a69b637ecf..00000000000 --- a/clear_trakt_collections.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -from plex_trakt_sync.clear_trakt_collections import clear_trakt_collections - -if __name__ == "__main__": - clear_trakt_collections() diff --git a/main.py b/main.py index 489d5f68fa6..81a107702b4 100755 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -from plex_trakt_sync.main import main +from plex_trakt_sync.cli import cli if __name__ == "__main__": - main() + cli() diff --git a/plex_trakt_sync.sh b/plex_trakt_sync.sh index 751b52a8846..177164b0290 100755 --- a/plex_trakt_sync.sh +++ b/plex_trakt_sync.sh @@ -1,3 +1,6 @@ #!/bin/sh -PATH=/usr/local/bin:/usr/local/sbin:~/bin:/usr/bin:/bin:/usr/sbin:/sbin -python3 ./main.py \ No newline at end of file +set -eu + +dir=$(dirname "$0") + +exec python3 "$dir/main.py" "$@" diff --git a/plex_trakt_sync/clear_trakt_collections.py b/plex_trakt_sync/clear_trakt_collections.py deleted file mode 100644 index 966af1220f9..00000000000 --- a/plex_trakt_sync/clear_trakt_collections.py +++ /dev/null @@ -1,15 +0,0 @@ -import trakt -from plex_trakt_sync.path import pytrakt_file -trakt.core.CONFIG_PATH = pytrakt_file -import trakt.users - -def clear_trakt_collections(): - trakt_user = trakt.users.User('me') - coll = trakt_user.movie_collection - for movie in coll: - print("Deleting", movie.title) - movie.remove_from_library() - coll = trakt_user.show_collection - for show in coll: - print("Deleting", show.title) - show.remove_from_library() diff --git a/plex_trakt_sync/cli.py b/plex_trakt_sync/cli.py new file mode 100644 index 00000000000..a2e58a5af56 --- /dev/null +++ b/plex_trakt_sync/cli.py @@ -0,0 +1,19 @@ +import click +from plex_trakt_sync.commands.clear_collections import clear_collections +from plex_trakt_sync.commands.sync import sync +from plex_trakt_sync.commands.watch import watch + + +@click.group(invoke_without_command=True) +@click.pass_context +def cli(ctx): + """ + Plex-Trakt-Sync is a two-way-sync between trakt.tv and Plex Media Server + """ + if not ctx.invoked_subcommand: + sync() + + +cli.add_command(sync) +cli.add_command(clear_collections) +cli.add_command(watch) diff --git a/plex_trakt_sync/commands/clear_collections.py b/plex_trakt_sync/commands/clear_collections.py new file mode 100644 index 00000000000..aef0ab412db --- /dev/null +++ b/plex_trakt_sync/commands/clear_collections.py @@ -0,0 +1,28 @@ +import click +from plex_trakt_sync.logging import logger +from plex_trakt_sync.trakt_api import TraktApi + + +@click.command() +@click.option('--confirm', is_flag=True, help='Confirm the dangerous action') +@click.option('--dry-run', is_flag=True, help='Do not perform delete actions') +def clear_collections(confirm, dry_run): + """ + Clear Movies and Shows collections in Trakt + """ + + if not confirm and not dry_run: + click.echo('You need to pass --confirm or --dry-run option to proceed') + return + + trakt = TraktApi() + + for movie in trakt.movie_collection: + logger.info(f"Deleting: {movie}") + if not dry_run: + trakt.remove_from_library(movie) + + for show in trakt.show_collection: + logger.info(f"Deleting: {show}") + if not dry_run: + trakt.remove_from_library(show) diff --git a/plex_trakt_sync/commands/sync.py b/plex_trakt_sync/commands/sync.py new file mode 100644 index 00000000000..399813d2a36 --- /dev/null +++ b/plex_trakt_sync/commands/sync.py @@ -0,0 +1,189 @@ +import click + +from plex_trakt_sync.requests_cache import requests_cache +from plex_trakt_sync.plex_server import get_plex_server +from plex_trakt_sync.config import CONFIG +from plex_trakt_sync.decorators import measure_time +from plex_trakt_sync.plex_api import PlexApi, PlexLibraryItem +from plex_trakt_sync.trakt_api import TraktApi +from plex_trakt_sync.trakt_list_util import TraktListUtil +from plex_trakt_sync.logging import logger + + +def sync_collection(pm, tm, trakt: TraktApi, trakt_movie_collection): + if not CONFIG['sync']['collection']: + return + + if tm.trakt in trakt_movie_collection: + return + + logger.info(f"Add to Trakt Collection: {pm}") + trakt.add_to_collection(tm) + + +def sync_show_collection(pm, tm, pe, te, trakt: TraktApi): + if not CONFIG['sync']['collection']: + return + + collected = trakt.collected(tm) + is_collected = collected.get_completed(pe.seasonNumber, pe.index) + if is_collected: + return + + logger.info(f"Add to Trakt Collection: {pm} S{pe.seasonNumber:02}E{pe.index:02}") + trakt.add_to_collection(te.instance) + + +def sync_ratings(pm, tm, plex: PlexApi, trakt: TraktApi): + if not CONFIG['sync']['ratings']: + return + + trakt_rating = trakt.rating(tm) + plex_rating = pm.rating + if plex_rating is trakt_rating: + return + + # Plex rating takes precedence over Trakt rating + if plex_rating is not None: + logger.info(f"Rating {pm} with {plex_rating} on Trakt") + trakt.rate(tm, plex_rating) + elif trakt_rating is not None: + logger.info(f"Rating {pm} with {trakt_rating} on Plex") + plex.rate(pm.item, trakt_rating) + + +def sync_watched(pm, tm, plex: PlexApi, trakt: TraktApi, trakt_watched_movies): + if not CONFIG['sync']['watched_status']: + return + + watched_on_plex = pm.item.isWatched + watched_on_trakt = tm.trakt in trakt_watched_movies + if watched_on_plex is watched_on_trakt: + return + + # if watch status is not synced + # send watched status from plex to trakt + if watched_on_plex: + logger.info(f"Marking as watched on Trakt: {pm}") + trakt.mark_watched(tm, pm.seen_date) + # set watched status if movie is watched on Trakt + elif watched_on_trakt: + logger.info(f"Marking as watched in Plex: {pm}") + plex.mark_watched(pm.item) + + +def sync_show_watched(pm, tm, pe, te, trakt_watched_shows, plex: PlexApi, trakt: TraktApi): + if not CONFIG['sync']['watched_status']: + return + + watched_on_plex = pe.isWatched + watched_on_trakt = trakt_watched_shows.get_completed(tm.trakt, pe.seasonNumber, pe.index) + + if watched_on_plex == watched_on_trakt: + return + + if watched_on_plex: + logger.info(f"Marking as watched in Trakt: {pm} S{pe.seasonNumber:02}E{pe.index:02}") + m = PlexLibraryItem(pe) + trakt.mark_watched(te.instance, m.seen_date) + elif watched_on_trakt: + logger.info(f"Marking as watched in Plex: {pm} S{pe.seasonNumber:02}E{pe.index:02}") + plex.mark_watched(pe) + + +def for_each_pair(sections, trakt: TraktApi): + for section in sections: + with measure_time(f"Processing section {section.title}"): + for pm in section.items(): + tm = trakt.find_movie(pm) + if tm is None: + logger.warning(f"[{pm})]: Not found on Trakt. Skipping") + continue + + yield pm, tm + + +def for_each_episode(sections, trakt: TraktApi): + for pm, tm in for_each_pair(sections, trakt): + lookup = trakt.lookup(tm) + + # loop over episodes in plex db + for pe in pm.item.episodes(): + try: + te = lookup[pe.seasonNumber][pe.index] + except KeyError: + try: + logger.warning(f"Show [{pm}: Key not found: S{pe.seasonNumber:02}E{pe.seasonNumber:02}") + except TypeError: + logger.error(f"Show [{pm}]: Invalid episode: {pe}") + continue + + yield pm, tm, pe, te + + +def sync_all(movies=True, tv=True): + with requests_cache.disabled(): + server = get_plex_server() + listutil = TraktListUtil() + plex = PlexApi(server) + trakt = TraktApi() + + with measure_time("Loaded Trakt lists"): + trakt_watched_movies = trakt.watched_movies + trakt_watched_shows = trakt.watched_shows + trakt_movie_collection = trakt.movie_collection_set + trakt_ratings = trakt.ratings + trakt_watchlist_movies = trakt.watchlist_movies + trakt_liked_lists = trakt.liked_lists + + if trakt_watchlist_movies: + listutil.addList(None, "Trakt Watchlist", traktid_list=trakt_watchlist_movies) + + for lst in trakt_liked_lists: + listutil.addList(lst['username'], lst['listname']) + + with requests_cache.disabled(): + logger.info("Server version {} updated at: {}".format(server.version, server.updatedAt)) + logger.info("Recently added: {}".format(server.library.recentlyAdded()[:5])) + + if movies: + for pm, tm in for_each_pair(plex.movie_sections, trakt): + sync_collection(pm, tm, trakt, trakt_movie_collection) + sync_ratings(pm, tm, plex, trakt) + sync_watched(pm, tm, plex, trakt, trakt_watched_movies) + + if tv: + for pm, tm, pe, te in for_each_episode(plex.show_sections, trakt): + sync_show_collection(pm, tm, pe, te, trakt) + sync_show_watched(pm, tm, pe, te, trakt_watched_shows, plex, trakt) + + # add to plex lists + listutil.addPlexItemToLists(te.instance.trakt, pe) + + with measure_time("Updated plex watchlist"): + listutil.updatePlexLists(server) + + +@click.command() +@click.option( + "--sync", "sync_option", + type=click.Choice(["all", "movies", "tv"], case_sensitive=False), + default="all", + show_default=True, help="Specify what to sync" +) +def sync(sync_option: str): + """ + Perform sync between Plex and Trakt + """ + + movies = sync_option in ["all", "movies"] + tv = sync_option in ["all", "tv"] + if not movies and not tv: + click.echo("Nothing to sync!") + return + + logger.info(f"Syncing with Plex {CONFIG['PLEX_USERNAME']} and Trakt {CONFIG['TRAKT_USERNAME']}") + logger.info(f"Syncing TV={tv}, Movies={movies}") + + with measure_time("Completed full sync"): + sync_all(movies=movies, tv=tv) diff --git a/plex_trakt_sync/commands/watch.py b/plex_trakt_sync/commands/watch.py new file mode 100644 index 00000000000..67c8faaeccb --- /dev/null +++ b/plex_trakt_sync/commands/watch.py @@ -0,0 +1,98 @@ +import click +from plexapi.server import PlexServer + +from plex_trakt_sync.listener import WebSocketListener, PLAYING +from plex_trakt_sync.config import CONFIG +from plex_trakt_sync.plex_api import PlexApi +from plex_trakt_sync.trakt_api import TraktApi + + +class ScrobblerCollection(dict): + def __init__(self, trakt: TraktApi): + super(dict, self).__init__() + self.trakt = trakt + + def __missing__(self, key): + self[key] = value = self.trakt.scrobbler(key) + return value + + +class WatchStateUpdater: + def __init__(self, plex: PlexApi, trakt: TraktApi): + self.plex = plex + self.trakt = trakt + self.scrobblers = ScrobblerCollection(trakt) + + def __call__(self, message): + for pm, tm, item in self.filter_media(message): + movie = pm.item + percent = pm.watch_progress(item["viewOffset"]) + + print("%r: %.6F%% Watched: %s, LastViewed: %s" % ( + movie, percent, movie.isWatched, movie.lastViewedAt + )) + + self.scrobble(tm, percent, item["state"]) + + def scrobble(self, tm, percent, state): + if state == "playing": + return self.scrobblers[tm].update(percent) + + if state == "paused": + return self.scrobblers[tm].pause() + + if state == "stopped": + self.scrobblers[tm].stop() + del self.scrobblers[tm] + + def filter_media(self, message): + for item in self.filter_playing(message): + pm = self.plex.fetch_item(int(item["ratingKey"])) + print(f"Found {pm}") + if not pm: + continue + + tm = self.trakt.find_movie(pm) + if not tm: + continue + + yield pm, tm, item + + def filter_playing(self, message): + """ + {'sessionKey': '23', 'guid': '', 'ratingKey': '9725', 'url': '', 'key': '/library/metadata/9725', 'viewOffset': 0, 'playQueueItemID': 17679, 'state': 'playing'} + {'sessionKey': '23', 'guid': '', 'ratingKey': '9725', 'url': '', 'key': '/library/metadata/9725', 'viewOffset': 10000, 'playQueueItemID': 17679, 'state': 'playing', 'transcodeSession': '18nyclub53k1ey37jjbg8ok3'} + {'sessionKey': '23', 'guid': '', 'ratingKey': '9725', 'url': '', 'key': '/library/metadata/9725', 'viewOffset': 20000, 'playQueueItemID': 17679, 'state': 'playing', 'transcodeSession': '18nyclub53k1ey37jjbg8ok3'} + {'sessionKey': '23', 'guid': '', 'ratingKey': '9725', 'url': '', 'key': '/library/metadata/9725', 'viewOffset': 30000, 'playQueueItemID': 17679, 'state': 'playing', 'transcodeSession': '18nyclub53k1ey37jjbg8ok3'} + {'sessionKey': '23', 'guid': '', 'ratingKey': '9725', 'url': '', 'key': '/library/metadata/9725', 'viewOffset': 30000, 'playQueueItemID': 17679, 'state': 'paused', 'transcodeSession': '18nyclub53k1ey37jjbg8ok3'} + {'sessionKey': '23', 'guid': '', 'ratingKey': '9725', 'url': '', 'key': '/library/metadata/9725', 'viewOffset': 30000, 'playQueueItemID': 17679, 'state': 'paused', 'transcodeSession': '18nyclub53k1ey37jjbg8ok3'} + """ + + if message["size"] != 1: + raise ValueError("Unexpected size: %r" % message) + + for item in message["PlaySessionStateNotification"]: + state = item["state"] + print(f"State: {state}") + if state not in ["playing", "stopped", "paused"]: + continue + + yield item + + +@click.command() +def watch(): + """ + Listen to events from Plex + """ + + url = CONFIG["PLEX_BASEURL"] + token = CONFIG["PLEX_TOKEN"] + server = PlexServer(url, token) + trakt = TraktApi() + plex = PlexApi(server) + + ws = WebSocketListener(server) + ws.on(PLAYING, WatchStateUpdater(plex, trakt)) + print("Listening for events!") + ws.listen() diff --git a/plex_trakt_sync/config.py b/plex_trakt_sync/config.py index 6f9d4433b47..431fc255980 100644 --- a/plex_trakt_sync/config.py +++ b/plex_trakt_sync/config.py @@ -4,6 +4,11 @@ from plex_trakt_sync.path import config_file, env_file, default_config_file from os.path import exists +""" +Platform name to identify our application +""" +PLEX_PLATFORM = "PlexTraktSync" + class Config(dict): env_keys = [ diff --git a/plex_trakt_sync/decorators/__init__.py b/plex_trakt_sync/decorators/__init__.py new file mode 100644 index 00000000000..980f9a7f723 --- /dev/null +++ b/plex_trakt_sync/decorators/__init__.py @@ -0,0 +1,4 @@ +from .measure_time import measure_time +from .memoize import memoize +from .nocache import nocache +from .rate_limit import rate_limit diff --git a/plex_trakt_sync/decorators/measure_time.py b/plex_trakt_sync/decorators/measure_time.py new file mode 100644 index 00000000000..aaf65a9e8bb --- /dev/null +++ b/plex_trakt_sync/decorators/measure_time.py @@ -0,0 +1,13 @@ +from plex_trakt_sync.logging import logging, logger +from contextlib import contextmanager +from time import time + + +@contextmanager +def measure_time(message, level=logging.INFO): + start = time() + yield + timedelta = time() - start + + m, s = divmod(timedelta, 60) + logger.log(level, message + " in " + (m > 0) * "{:.0f} min ".format(m) + (s > 0) * "{:.1f} seconds".format(s)) diff --git a/plex_trakt_sync/decorators/memoize.py b/plex_trakt_sync/decorators/memoize.py new file mode 100644 index 00000000000..da53c886dce --- /dev/null +++ b/plex_trakt_sync/decorators/memoize.py @@ -0,0 +1,10 @@ +try: + from functools import cache as memoize +except ImportError: + # For py<3.9 + # https://docs.python.org/3.9/library/functools.html + from functools import lru_cache + + + def memoize(user_function): + return lru_cache(maxsize=None)(user_function) diff --git a/plex_trakt_sync/decorators/nocache.py b/plex_trakt_sync/decorators/nocache.py new file mode 100644 index 00000000000..d85e504dd2f --- /dev/null +++ b/plex_trakt_sync/decorators/nocache.py @@ -0,0 +1,12 @@ +from functools import wraps + +import requests_cache + + +def nocache(method): + @wraps(method) + def inner(self, *args, **kwargs): + with requests_cache.disabled(): + return method(self, *args, **kwargs) + + return inner diff --git a/plex_trakt_sync/decorators/rate_limit.py b/plex_trakt_sync/decorators/rate_limit.py new file mode 100644 index 00000000000..59ed99322d1 --- /dev/null +++ b/plex_trakt_sync/decorators/rate_limit.py @@ -0,0 +1,55 @@ +from functools import wraps +from time import sleep, time +from trakt.errors import RateLimitException +from plex_trakt_sync.logging import logger + +last_time = None + + +# https://trakt.docs.apiary.io/#introduction/rate-limiting +def rate_limit(retries=5, delay=None): + """ + + :param retries: number of retries + :param delay: delay in sec between trakt requests to respect rate limit + :return: + """ + + def respect_trakt_rate(): + if delay is None: + return + + global last_time + if last_time is None: + last_time = time() + return + + diff_time = time() - last_time + if diff_time < delay: + wait = delay - diff_time + logger.debug(f"Sleeping for {wait:.3f} seconds") + sleep(wait) + last_time = time() + + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + retry = 0 + while True: + try: + respect_trakt_rate() + return fn(*args, **kwargs) + except RateLimitException as e: + if retry == retries: + raise e + + seconds = int(e.response.headers.get("Retry-After", 1)) + retry += 1 + logger.warning( + f'RateLimitException for {fn}, retrying after {seconds} seconds (try: {retry}/{retries})' + ) + sleep(seconds) + + return wrapper + + return decorator diff --git a/plex_trakt_sync/listener.py b/plex_trakt_sync/listener.py new file mode 100644 index 00000000000..9624de1f225 --- /dev/null +++ b/plex_trakt_sync/listener.py @@ -0,0 +1,34 @@ +from time import sleep +from plexapi.server import PlexServer + +from plex_trakt_sync.logging import logging + +PLAYING = "playing" + + +class WebSocketListener: + def __init__(self, plex: PlexServer, interval=1): + self.plex = plex + self.interval = interval + self.event_handlers = {} + self.logger = logging.getLogger("PlexTraktSync.WebSocketListener") + + def on(self, event_name, handler): + if event_name not in self.event_handlers: + self.event_handlers[event_name] = [] + + self.event_handlers[event_name].append(handler) + + def listen(self): + def handler(data): + self.logger.debug(data) + event_type = data['type'] + if event_type not in self.event_handlers: + return + + for handler in self.event_handlers[event_type]: + handler(data) + + notifier = self.plex.startAlertListener(callback=handler) + while notifier.is_alive(): + sleep(self.interval) diff --git a/plex_trakt_sync/logging.py b/plex_trakt_sync/logging.py index 9e250da14fc..e41dcc007e7 100644 --- a/plex_trakt_sync/logging.py +++ b/plex_trakt_sync/logging.py @@ -1,8 +1,31 @@ import logging +import sys from .config import CONFIG from .path import log_file -log_level = logging.DEBUG if CONFIG['log_debug_messages'] else logging.INFO -logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s', - handlers=[logging.FileHandler(log_file, 'w', 'utf-8')], - level=log_level) + +def initialize(): + # global log level for all messages + log_level = logging.DEBUG if CONFIG['log_debug_messages'] else logging.INFO + log_format = '%(asctime)s %(levelname)s:%(message)s' + + # messages with info and above are printed to stdout + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + console_handler.setLevel(logging.INFO) + + # file handler can log down to debug messages + file_handler = logging.FileHandler(log_file, 'w', 'utf-8') + file_handler.setFormatter(logging.Formatter("%(asctime)-15s %(levelname)s[%(name)s]:%(message)s")) + file_handler.setLevel(logging.DEBUG) + + handlers = [ + file_handler, + console_handler, + ] + logging.basicConfig(format=log_format, handlers=handlers, level=log_level) + + +initialize() + +logger = logging.getLogger('PlexTraktSync') diff --git a/plex_trakt_sync/main.py b/plex_trakt_sync/main.py deleted file mode 100644 index 5614e329c9b..00000000000 --- a/plex_trakt_sync/main.py +++ /dev/null @@ -1,469 +0,0 @@ -import plexapi.server -import requests_cache -import trakt -from plex_trakt_sync.path import pytrakt_file, env_file, trakt_cache -trakt.core.CONFIG_PATH = pytrakt_file -import trakt.errors -import trakt.movies -import trakt.tv -import trakt.sync -import trakt.users -import trakt.core -from time import time, sleep -import datetime -from json.decoder import JSONDecodeError - -from plex_trakt_sync import pytrakt_extensions -from plex_trakt_sync.trakt_list_util import TraktListUtil -from plex_trakt_sync.config import CONFIG -from plex_trakt_sync.logging import logging - -requests_cache.install_cache(trakt_cache) -trakt_post_wait = 1.2 # delay in sec between trakt post requests to respect rate limit - - -def process_movie_section(s, watched_set, ratings_dict, listutil, collection): - # args: a section of plex movies, a set comprised of the trakt ids of all watched movies and a dict with key=slug and value=rating (1-10) - - ############### - # Sync movies with trakt - ############### - with requests_cache.disabled(): - allMovies = s.all() - logging.info("Now working on movie section {} containing {} elements".format(s.title, len(allMovies))) - for movie in allMovies: - # find id to search movie - guid = movie.guid - if guid.startswith('plex://movie/'): - if len(movie.guids) > 0: - logging.debug("trying first alternative guid: " + str(movie.guids[0].id)) - guid = movie.guids[0].id - x = provider = None - if guid.startswith('local') or 'agents.none' in guid: - # ignore this guid, it's not matched - logging.warning("Movie [{} ({})]: GUID ({}) is local or none, ignoring".format( - movie.title, movie.year, guid)) - continue - elif 'imdb' in guid: - x = guid.split('//')[1] - x = x.split('?')[0] - provider = 'imdb' - elif 'themoviedb' in guid or 'tmdb' in guid: - x = guid.split('//')[1] - x = x.split('?')[0] - provider = 'tmdb' - elif 'xbmcnfo' in guid: - x = guid.split('//')[1] - x = x.split('?')[0] - provider = CONFIG['xbmc-providers']['movies'] - else: - logging.error('Movie [{} ({})]: Unrecognized GUID {}'.format( - movie.title, movie.year, movie.guid)) - continue - # search and sync movie - try: - search = trakt.sync.search_by_id(x, id_type=provider) - m = None - # look for the first movie in the results - for result in search: - if type(result) is trakt.movies.Movie: - m = result - break - if m is None: - logging.error('Movie [{} ({})]: Not found. Aborting'.format( - movie.title, movie.year)) - continue - last_time = time() - if CONFIG['sync']['collection']: - # add to collection if necessary - if m.trakt not in collection: - retry = 0 - while retry < 5: - try: - last_time = respect_trakt_rate(last_time) - m.add_to_library() - logging.info('Movie [{} ({})]: Added to trakt collection'.format( - movie.title, movie.year)) - break - except trakt.errors.RateLimitException as e: - delay = int(e.response.headers.get("Retry-After", 1)) - logging.warning( - "Movie [{} ({})]: Rate Limited on adding to collection. Sleeping {} sec from trakt (GUID: {})".format(movie.title, movie.year, delay, guid)) - sleep(delay) - retry += retry - if retry == 5: - logging.warning( - "Movie [{} ({})]: Rate Limited 5 times on watched update. Abort trakt request.".format(movie.title, movie.year)) - # compare ratings - if CONFIG['sync']['ratings']: - if m.slug in ratings_dict: - trakt_rating = int(ratings_dict[m.slug]) - else: - trakt_rating = None - plex_rating = int( - movie.userRating) if movie.userRating is not None else None - identical = plex_rating is trakt_rating - # plex rating takes precedence over trakt rating - if plex_rating is not None and not identical: - retry = 0 - while retry < 5: - try: - last_time = respect_trakt_rate(last_time) - with requests_cache.disabled(): - m.rate(plex_rating) - logging.info("Movie [{} ({})]: Rating with {} on trakt".format( - movie.title, movie.year, plex_rating)) - break - except trakt.errors.RateLimitException as e: - delay = int(e.response.headers.get("Retry-After", 1)) - logging.warning( - "Movie [{} ({})]: Rate Limited on rating update. Sleeping {} sec from trakt (GUID: {})".format(movie.title, movie.year, delay, guid)) - sleep(delay) - retry += retry - if retry == 5: - logging.warning( - "Movie [{} ({})]: Rate Limited 5 times on watched update. Abort trakt request.".format(movie.title, movie.year)) - elif trakt_rating is not None and not identical: - with requests_cache.disabled(): - movie.rate(trakt_rating) - logging.info("Movie [{} ({})]: Rating with {} on plex".format( - movie.title, movie.year, trakt_rating)) - - # sync watch status - if CONFIG['sync']['watched_status']: - watchedOnPlex = movie.isWatched - watchedOnTrakt = m.trakt in watched_set - if watchedOnPlex is not watchedOnTrakt: - # if watch status is not synced - # send watched status from plex to trakt - if watchedOnPlex: - retry = 0 - while retry < 5: - try: - last_time = respect_trakt_rate(last_time) - with requests_cache.disabled(): - seen_date = (movie.lastViewedAt if movie.lastViewedAt else datetime.now()) - m.mark_as_seen(seen_date.astimezone(datetime.timezone.utc)) - logging.info("Movie [{} ({})]: marking as watched on Trakt...".format( - movie.title, movie.year)) - break - except ValueError: # for py<3.6 - with requests_cache.disabled(): - m.mark_as_seen(seen_date) - except trakt.errors.RateLimitException as e: - delay = int(e.response.headers.get("Retry-After", 1)) - logging.warning( - "Movie [{} ({})]: Rate Limited on watched update. Sleeping {} sec from trakt (GUID: {})".format(movie.title, movie.year, delay, guid)) - sleep(delay) - retry += retry - if retry == 5: - logging.warning( - "Movie [{} ({})]: Rate Limited 5 times on watched update. Abort trakt request.".format(movie.title, movie.year)) - # set watched status if movie is watched on trakt - elif watchedOnTrakt: - logging.info("Movie [{} ({})]: marking as watched in Plex...".format( - movie.title, movie.year)) - with requests_cache.disabled(): - movie.markWatched() - # add to plex lists - listutil.addPlexItemToLists(m.trakt, movie) - - logging.info("Movie [{} ({})]: Finished sync".format( - movie.title, movie.year)) - except trakt.errors.NotFoundException: - logging.error( - "Movie [{} ({})]: GUID {} not found on trakt".format(movie.title, movie.year, guid)) - except trakt.errors.RateLimitException as e: - delay = int(e.response.headers.get("Retry-After", 1)) - logging.warning( - "Movie [{} ({})]: Rate Limited. Sleeping {} sec from trakt (GUID: {})".format(movie.title, movie.year, delay, guid)) - sleep(delay) - except Exception as e: - logging.warning( - "Movie [{} ({})]: {} (GUID: {})".format(movie.title, movie.year, e, guid)) - - -def process_show_section(s, watched_set, listutil): - with requests_cache.disabled(): - allShows = s.all() - logging.info("Now working on show section {} containing {} elements".format(s.title, len(allShows))) - for show in allShows: - guid = show.guid - if guid.startswith('local') or 'agents.none' in guid: - # ignore this guid, it's not matched - logging.warning("Show [{} ({})]: GUID is local, ignoring".format( - show.title, show.year)) - continue - elif 'thetvdb' in guid: - x = guid.split('//')[1] - x = x.split('?')[0] - provider = 'tvdb' - elif 'themoviedb' in guid: - x = guid.split('//')[1] - x = x.split('?')[0] - provider = 'tmdb' - elif 'xbmcnfotv' in guid: - x = guid.split('//')[1] - x = x.split('?')[0] - provider = CONFIG['xbmc-providers']['shows'] - else: - logging.error("Show [{} ({})]: Unrecognized GUID {}".format( - show.title, show.year, guid)) - continue - - try: - # find show - logging.debug("Show [{} ({})]: Started sync".format( - show.title, show.year)) - search = trakt.sync.search_by_id(x, id_type=provider) - trakt_show = None - # look for the first tv show in the results - for result in search: - if type(result) is trakt.tv.TVShow: - trakt_show = result - break - if trakt_show is None: - logging.error("Show [{} ({})]: Did not find on Trakt. Aborting. GUID: {}".format(show.title, show.year, guid)) - continue - with requests_cache.disabled(): - trakt_collected = pytrakt_extensions.collected(trakt_show.trakt) - start_time = last_time = time() - # this lookup-table is accessible via lookup[season][episode] - with requests_cache.disabled(): - lookup = pytrakt_extensions.lookup_table(trakt_show) - - logging.debug("Show [{} ({})]: Generated LUT in {} seconds".format( - show.title, show.year, (time() - start_time))) - - # loop over episodes in plex db - for episode in show.episodes(): - try: - eps = lookup[episode.seasonNumber][episode.index] - except KeyError: - try: - logging.warning("Show [{} ({})]: Key not found, did not record episode S{:02}E{:02}".format( - show.title, show.year, episode.seasonNumber, episode.index)) - except TypeError: - logging.error("Show [{} ({})]: Invalid episode {}".format(show.title, show.year, episode)) - continue - watched = watched_set.get_completed( - trakt_show.trakt, episode.seasonNumber, episode.index) - collected = trakt_collected.get_completed( - episode.seasonNumber, episode.index) - # sync collected - if CONFIG['sync']['collection']: - if not collected: - retry = 0 - while retry < 5: - try: - last_time = respect_trakt_rate(last_time) - with requests_cache.disabled(): - eps.instance.add_to_library() - logging.info("Show [{} ({})]: Collected episode S{:02}E{:02}".format( - show.title, show.year, episode.seasonNumber, episode.index)) - break - except JSONDecodeError as e: - logging.error( - "JSON decode error: {}".format(str(e))) - except trakt.errors.RateLimitException as e: - delay = int(e.response.headers.get("Retry-After", 1)) - logging.warning("Show [{} ({})]: Rate limit on collected episode S{:02}E{:02}. Sleeping {} sec from trakt".format( - show.title, show.year, episode.seasonNumber, episode.index, delay)) - sleep(delay) - retry += retry - if retry == 5: - logging.warning( - "Show [{} ({})]: Rate Limited 5 times on collected episode S{:02}E{:02}. Abort trakt request.".format(show.title, show.year, episode.seasonNumber, episode.index)) - # sync watched status - if CONFIG['sync']['watched_status']: - if episode.isWatched != watched: - if episode.isWatched: - retry = 0 - while retry < 5: - try: - last_time = respect_trakt_rate(last_time) - with requests_cache.disabled(): - seen_date = (episode.lastViewedAt if episode.lastViewedAt else datetime.now()) - eps.instance.mark_as_seen(seen_date.astimezone(datetime.timezone.utc)) - logging.info("Show [{} ({})]: Marked as watched on trakt: episode S{:02}E{:02}".format( - show.title, show.year, episode.seasonNumber, episode.index)) - break - except JSONDecodeError as e: - logging.error( - "JSON decode error: {}".format(str(e))) - except ValueError: # for py<3.6 - with requests_cache.disabled(): - eps.instance.mark_as_seen(seen_date) - except trakt.errors.RateLimitException as e: - delay = int(e.response.headers.get("Retry-After", 1)) - logging.warning("Show [{} ({})]: Rate limit on watched episode S{:02}E{:02}. Sleep {} sec from trakt".format( - show.title, show.year, episode.seasonNumber, episode.index, delay)) - retry += retry - sleep(delay) - if retry == 5: - logging.warning( - "Show [{} ({})]: Rate Limited 5 times on collected episode S{:02}E{:02}. Abort trakt request.".format(show.title, show.year, episode.seasonNumber, episode.index)) - elif watched: - with requests_cache.disabled(): - episode.markWatched() - logging.info("Show [{} ({})]: Marked as watched on plex: episode S{:02}E{:02}".format( - show.title, show.year, episode.seasonNumber, episode.index)) - else: - logging.warning("Episode.isWatched: {}, watched: {} isWatched != watched: {}".format( - episode.isWatched, watched, episode.isWatched != watched)) - logging.debug("Show [{} ({})]: Synced episode S{:02}E{:02}".format( - show.title, show.year, episode.seasonNumber, episode.index)) - # add to plex lists - listutil.addPlexItemToLists(eps.instance.trakt, episode) - logging.info("Show [{} ({})]: Finished sync".format( - show.title, show.year)) - except trakt.errors.NotFoundException: - logging.error("Show [{} ({})]: GUID {} not found on trakt".format( - show.title, show.year, guid)) - except trakt.errors.RateLimitException as e: - delay = int(e.response.headers.get("Retry-After", 1)) - logging.debug( - "Show [{} ({})]: Rate Limited. Sleeping {} sec from trakt".format(show.title, show.year, delay)) - sleep(delay) - except Exception as e: - logging.error("Show [{} ({})]: {} (GUID {})".format( - show.title, show.year, e, guid)) - - -def get_plex_server(): - plex_token = CONFIG["PLEX_TOKEN"] - plex_baseurl = CONFIG["PLEX_BASEURL"] - plex_fallbackurl = CONFIG["PLEX_FALLBACKURL"] - if plex_token == '-': - plex_token = "" - server = None - # if connection fails, it will try : - # 1. url expected by new ssl certificate - # 2. url without ssl - # 3. fallback url (localhost) - try: - server = plexapi.server.PlexServer( - token=plex_token, baseurl=plex_baseurl) - except plexapi.server.requests.exceptions.SSLError as e: - m = "Plex connection error: {}, fallback url {} didn't respond either.".format(str(e), plex_fallbackurl) - excep_msg = str(e.__context__) - if "doesn't match '*." in excep_msg: - hash_pos = excep_msg.find("*.") + 2 - new_hash = excep_msg[hash_pos:hash_pos + 32] - end_pos = plex_baseurl.find(".plex.direct") - new_plex_baseurl = plex_baseurl[:end_pos - 32] + new_hash + plex_baseurl[end_pos:] - try: # 1 - server = plexapi.server.PlexServer( - token=plex_token, baseurl=new_plex_baseurl) - # save new url to .env - with open(env_file, 'w') as txt: - txt.write("PLEX_USERNAME=" + CONFIG['PLEX_USERNAME'] + "\n") - txt.write("PLEX_TOKEN=" + plex_token + "\n") - txt.write("PLEX_BASEURL=" + new_plex_baseurl + "\n") - txt.write("PLEX_FALLBACKURL=" + plex_fallbackurl + "\n") - txt.write("TRAKT_USERNAME=" + CONFIG['TRAKT_USERNAME'] + "\n") - logging.info("Plex server url changed to {}".format(new_plex_baseurl)) - except Exception: - pass - if server is None and plex_baseurl[:5] == "https": - new_plex_baseurl = plex_baseurl.replace("https", "http") - try: # 2 - server = plexapi.server.PlexServer( - token=plex_token, baseurl=new_plex_baseurl) - logging.warning("Switched to Plex unsecure connection because of SSLError.") - except Exception: - pass - except Exception as e: - m = "Plex connection error: {}, fallback url {} didn't respond either.".format(str(e), plex_fallbackurl) - pass - if server is None: - try: # 3 - server = plexapi.server.PlexServer( - token=plex_token, baseurl=plex_fallbackurl) - logging.warning("No response from {}, fallback to {}".format(plex_baseurl, plex_fallbackurl)) - except Exception: - logging.error(m) - print(m) - exit(1) - return server - -def respect_trakt_rate(last_time): - diff_time = time() - last_time - if diff_time < trakt_post_wait: - sleep(trakt_post_wait - diff_time) - return time() - -def main(): - - start_time = time() - listutil = TraktListUtil() - # do not use the cache for account specific stuff as this is subject to change - start_msg = "Starting sync Plex {} and Trakt {}".format(CONFIG['PLEX_USERNAME'], CONFIG['TRAKT_USERNAME']) - print(start_msg) - logging.info(start_msg) - with requests_cache.disabled(): - try: - trakt_user = trakt.users.User('me') - except trakt.errors.OAuthException as e: - m = "Trakt authentication error: {}".format(str(e)) - logging.info(m) - print(m) - exit(1) - if CONFIG['sync']['liked_lists']: - liked_lists = pytrakt_extensions.get_liked_lists() - trakt_watched_movies = set( - map(lambda m: m.trakt, trakt_user.watched_movies)) - logging.debug("Watched movies from trakt: {}".format( - trakt_watched_movies)) - trakt_movie_collection = set( - map(lambda m: m.trakt, trakt_user.movie_collection)) - # logging.debug("Movie collection from trakt:", trakt_movie_collection) - trakt_watched_shows = pytrakt_extensions.allwatched() - if CONFIG['sync']['watchlist']: - listutil.addList(None, "Trakt Watchlist", traktid_list=list( - map(lambda m: m.trakt, trakt_user.watchlist_movies))) - # logging.debug("Movie watchlist from trakt:", trakt_movie_watchlist) - user_ratings = trakt_user.get_ratings(media_type='movies') - if CONFIG['sync']['liked_lists']: - for lst in liked_lists: - listutil.addList(lst['username'], lst['listname']) - ratings = {} - for r in user_ratings: - ratings[r['movie']['ids']['slug']] = r['rating'] - logging.debug("Movie ratings from trakt: {}".format(ratings)) - logging.info('Loaded Trakt lists.') - with requests_cache.disabled(): - plex = get_plex_server() - logging.info("Server version {} updated at: {}".format( - plex.version, plex.updatedAt)) - logging.info("Recently added: {}".format( - plex.library.recentlyAdded()[:5])) - with requests_cache.disabled(): - sections = plex.library.sections() - for section in sections: - if section.title in CONFIG['excluded-libraries']: - continue - # process movie sections - section_start_time = time() - if type(section) is plexapi.library.MovieSection: - # clean_collections_in_section(section) - print("Processing section", section.title) - process_movie_section( - section, trakt_watched_movies, ratings, listutil, trakt_movie_collection) - # process show sections - elif type(section) is plexapi.library.ShowSection: - print("Processing section", section.title) - process_show_section(section, trakt_watched_shows, listutil) - else: - continue - - timedelta = time() - section_start_time - m, s = divmod(timedelta, 60) - logging.warning("Completed section sync in " + (m>0) * "{:.0f} min ".format(m) + (s>0) * "{:.1f} seconds".format(s)) - - listutil.updatePlexLists(plex) - logging.info("Updated plex watchlist") - timedelta = time() - start_time - m, s = divmod(timedelta, 60) - logging.info("Completed full sync in " + (m>0) * "{:.0f} min ".format(m) + (s>0) * "{:.1f} seconds".format(s)) - print("Completed full sync in " + (m>0) * "{:.0f} min ".format(m) + (s>0) * "{:.1f} seconds".format(s)) diff --git a/plex_trakt_sync/plex_api.py b/plex_trakt_sync/plex_api.py new file mode 100644 index 00000000000..96e6b30d945 --- /dev/null +++ b/plex_trakt_sync/plex_api.py @@ -0,0 +1,194 @@ +import datetime +from typing import Union + +from plexapi.exceptions import NotFound +from plexapi.library import MovieSection, ShowSection, LibrarySection +from plexapi.video import Movie, Show + +from plex_trakt_sync.logging import logger +from plex_trakt_sync.decorators import memoize, nocache +from plex_trakt_sync.config import CONFIG + + +class PlexLibraryItem: + def __init__(self, item): + self.item = item + + @property + @memoize + def guid(self): + if self.item.guid.startswith('plex://'): + if len(self.item.guids) > 0: + return self.item.guids[0].id + return self.item.guid + + @property + @memoize + def guids(self): + return self.item.guids + + @property + @memoize + def type(self): + return f"{self.media_type}s" + + @property + @memoize + def media_type(self): + return self.item.type + + @property + @memoize + def provider(self): + if self.guid_is_imdb_legacy: + return "imdb" + x = self.guid.split("://")[0] + x = x.replace("com.plexapp.agents.", "") + x = x.replace("tv.plex.agents.", "") + x = x.replace("themoviedb", "tmdb") + x = x.replace("thetvdb", "tvdb") + if x == "xbmcnfo": + x = CONFIG["xbmc-providers"][self.type] + + return x + + @property + @memoize + def id(self): + if self.guid_is_imdb_legacy: + return self.item.guid + x = self.guid.split("://")[1] + x = x.split("?")[0] + return x + + @property + @memoize + def rating(self): + return int(self.item.userRating) if self.item.userRating is not None else None + + @property + @memoize + def seen_date(self): + media = self.item + if not media.lastViewedAt: + raise ValueError('lastViewedAt is not set') + + date = media.lastViewedAt + + try: + return date.astimezone(datetime.timezone.utc) + except ValueError: # for py<3.6 + return date + + def watch_progress(self, view_offset): + percent = view_offset / self.item.duration * 100 + return percent + + @property + @memoize + def guid_is_imdb_legacy(self): + guid = self.item.guid + + # old item, like imdb 'tt0112253' + return guid[0:2] == "tt" and guid[2:].isnumeric() + + def __repr__(self): + return "<%s:%s:%s>" % (self.provider, self.id, self.item) + + +class PlexLibrarySection: + def __init__(self, section: LibrarySection): + self.section = section + + def __len__(self): + return len(self.all()) + + @property + def title(self): + return self.section.title + + @memoize + @nocache + def all(self): + return self.section.all() + + @memoize + def items(self): + result = [] + for item in (PlexLibraryItem(x) for x in self.all()): + try: + provider = item.provider + except NotFound as e: + logger.error(f"{e}, skipping {item}") + continue + + if provider in ["local", "none", "agents.none"]: + continue + + if provider not in ["imdb", "tmdb", "tvdb"]: + logger.error(f"{item}: Unable to parse a valid provider from guid:'{item.guid}', guids:{item.guids}") + continue + + result.append(item) + + return result + + +class PlexApi: + """ + Plex API class abstracting common data access and dealing with requests cache. + """ + + def __init__(self, plex): + self.plex = plex + + @property + @memoize + def movie_sections(self): + result = [] + for section in self.library_sections: + if not type(section) is MovieSection: + continue + result.append(PlexLibrarySection(section)) + + return result + + @property + @memoize + def show_sections(self): + result = [] + for section in self.library_sections: + if not type(section) is ShowSection: + continue + result.append(PlexLibrarySection(section)) + + return result + + @memoize + def fetch_item(self, key: Union[int, str]): + media = self.plex.library.fetchItem(key) + return PlexLibraryItem(media) + + def reload_item(self, pm): + self.fetch_item.cache_clear() + return self.fetch_item(pm.item.ratingKey) + + @property + @memoize + @nocache + def library_sections(self): + result = [] + for section in self.plex.library.sections(): + if section.title in CONFIG["excluded-libraries"]: + continue + result.append(section) + + return result + + @nocache + def rate(self, m, rating): + m.rate(rating) + + @nocache + def mark_watched(self, m): + m.markWatched() diff --git a/plex_trakt_sync/plex_server.py b/plex_trakt_sync/plex_server.py new file mode 100644 index 00000000000..f23d38820ac --- /dev/null +++ b/plex_trakt_sync/plex_server.py @@ -0,0 +1,63 @@ +import plexapi.server +from plex_trakt_sync.config import CONFIG, PLEX_PLATFORM +from plex_trakt_sync.logging import logger + + +def get_plex_server(): + plex_token = CONFIG["PLEX_TOKEN"] + plex_baseurl = CONFIG["PLEX_BASEURL"] + plex_fallbackurl = CONFIG["PLEX_FALLBACKURL"] + if plex_token == '-': + plex_token = "" + server = None + + plexapi.X_PLEX_PLATFORM = PLEX_PLATFORM + plexapi.BASE_HEADERS['X-Plex-Platform'] = plexapi.X_PLEX_PLATFORM + + # if connection fails, it will try : + # 1. url expected by new ssl certificate + # 2. url without ssl + # 3. fallback url (localhost) + try: + server = plexapi.server.PlexServer( + token=plex_token, baseurl=plex_baseurl) + except plexapi.server.requests.exceptions.SSLError as e: + m = "Plex connection error: {}, fallback url {} didn't respond either.".format(str(e), plex_fallbackurl) + excep_msg = str(e.__context__) + if "doesn't match '*." in excep_msg: + hash_pos = excep_msg.find("*.") + 2 + new_hash = excep_msg[hash_pos:hash_pos + 32] + end_pos = plex_baseurl.find(".plex.direct") + new_plex_baseurl = plex_baseurl[:end_pos - 32] + new_hash + plex_baseurl[end_pos:] + try: # 1 + server = plexapi.server.PlexServer( + token=plex_token, baseurl=new_plex_baseurl) + # save new url to .env + CONFIG["PLEX_TOKEN"] = plex_token + CONFIG["PLEX_BASEURL"] = new_plex_baseurl + CONFIG["PLEX_FALLBACKURL"] = plex_fallbackurl + CONFIG.save() + logger.info("Plex server url changed to {}".format(new_plex_baseurl)) + except Exception: + pass + if server is None and plex_baseurl[:5] == "https": + new_plex_baseurl = plex_baseurl.replace("https", "http") + try: # 2 + server = plexapi.server.PlexServer( + token=plex_token, baseurl=new_plex_baseurl) + logger.warning("Switched to Plex unsecure connection because of SSLError.") + except Exception: + pass + except Exception as e: + m = "Plex connection error: {}, fallback url {} didn't respond either.".format(str(e), plex_fallbackurl) + pass + if server is None: + try: # 3 + server = plexapi.server.PlexServer( + token=plex_token, baseurl=plex_fallbackurl) + logger.warning("No response from {}, fallback to {}".format(plex_baseurl, plex_fallbackurl)) + except Exception: + logger.error(m) + print(m) + exit(1) + return server diff --git a/plex_trakt_sync/requests_cache.py b/plex_trakt_sync/requests_cache.py new file mode 100644 index 00000000000..122c42bbf4e --- /dev/null +++ b/plex_trakt_sync/requests_cache.py @@ -0,0 +1,4 @@ +import requests_cache +from .path import trakt_cache + +requests_cache.install_cache(trakt_cache) diff --git a/plex_trakt_sync/trakt_api.py b/plex_trakt_sync/trakt_api.py new file mode 100644 index 00000000000..6f7fd589b7f --- /dev/null +++ b/plex_trakt_sync/trakt_api.py @@ -0,0 +1,201 @@ +from json import JSONDecodeError +from typing import Union +import trakt + +from plex_trakt_sync import pytrakt_extensions +from plex_trakt_sync.path import pytrakt_file +from plex_trakt_sync.plex_api import PlexLibraryItem + +trakt.core.CONFIG_PATH = pytrakt_file +import trakt.users +import trakt.sync +import trakt.movies +from trakt.movies import Movie +from trakt.tv import TVShow, TVSeason, TVEpisode +from trakt.errors import OAuthException, ForbiddenException +from trakt.sync import Scrobbler + +from plex_trakt_sync.logging import logger +from plex_trakt_sync.decorators import memoize, nocache, rate_limit +from plex_trakt_sync.config import CONFIG + +TRAKT_POST_DELAY = 1.1 + + +class ScrobblerProxy: + """ + Proxy to Scrobbler that handles requsts cache and rate limiting + """ + + def __init__(self, scrobbler: Scrobbler): + self.scrobbler = scrobbler + + @nocache + @rate_limit(delay=TRAKT_POST_DELAY) + def update(self, progress: float): + self.scrobbler.update(progress) + + @nocache + @rate_limit(delay=TRAKT_POST_DELAY) + def pause(self): + self.scrobbler.pause() + + @nocache + @rate_limit(delay=TRAKT_POST_DELAY) + def stop(self): + self.scrobbler.stop() + + +class TraktApi: + """ + Trakt API class abstracting common data access and dealing with requests cache. + """ + + @property + @memoize + @nocache + @rate_limit() + def me(self): + try: + return trakt.users.User('me') + except (OAuthException, ForbiddenException) as e: + logger.fatal("Trakt authentication error: {}".format(str(e))) + raise e + + @property + @memoize + @nocache + @rate_limit() + def liked_lists(self): + if not CONFIG['sync']['liked_lists']: + return [] + return pytrakt_extensions.get_liked_lists() + + @property + @memoize + @nocache + @rate_limit() + def watched_movies(self): + return set( + map(lambda m: m.trakt, self.me.watched_movies) + ) + + @property + @memoize + @nocache + @rate_limit() + def movie_collection(self): + return self.me.movie_collection + + @property + @memoize + @nocache + @rate_limit() + def show_collection(self): + return self.me.show_collection + + @nocache + @rate_limit(delay=TRAKT_POST_DELAY) + def remove_from_library(self, media: Union[Movie, TVShow, TVSeason, TVEpisode]): + if not isinstance(media, (Movie, TVShow, TVSeason, TVEpisode)): + raise ValueError("Must be valid media type") + media.remove_from_library() + + @property + @memoize + def movie_collection_set(self): + return set( + map(lambda m: m.trakt, self.movie_collection) + ) + + @property + @memoize + @nocache + @rate_limit() + def watched_shows(self): + return pytrakt_extensions.allwatched() + + @property + @memoize + @nocache + @rate_limit() + def watchlist_movies(self): + if not CONFIG['sync']['watchlist']: + return [] + + return list( + map(lambda m: m.trakt, self.me.watchlist_movies) + ) + + @property + @memoize + @nocache + @rate_limit() + def movie_ratings(self): + return self.me.get_ratings(media_type='movies') + + @property + @memoize + def ratings(self): + ratings = {} + for r in self.movie_ratings: + ratings[r['movie']['ids']['slug']] = r['rating'] + + return ratings + + def rating(self, m): + if m.slug in self.ratings: + return int(self.ratings[m.slug]) + + return None + + @nocache + @rate_limit(delay=TRAKT_POST_DELAY) + def rate(self, m, rating): + m.rate(rating) + + def scrobbler(self, media: Union[Movie, TVEpisode]) -> ScrobblerProxy: + scrobbler = media.scrobble(0, None, None) + return ScrobblerProxy(scrobbler) + + @nocache + @rate_limit(delay=TRAKT_POST_DELAY) + def mark_watched(self, m, time): + m.mark_as_seen(time) + + @nocache + @rate_limit(delay=TRAKT_POST_DELAY) + def add_to_collection(self, m): + m.add_to_library() + + @memoize + @nocache + @rate_limit() + def collected(self, tm: TVShow): + return pytrakt_extensions.collected(tm.trakt) + + @memoize + @nocache + @rate_limit() + def lookup(self, tm: TVShow): + """ + This lookup-table is accessible via lookup[season][episode] + """ + return pytrakt_extensions.lookup_table(tm) + + @memoize + @rate_limit() + def find_movie(self, media: PlexLibraryItem): + try: + search = trakt.sync.search_by_id(media.id, id_type=media.provider, media_type=media.media_type) + except JSONDecodeError as e: + raise ValueError(f"Unable parse search result for {media.provider}/{media.id}: {e.doc!r}") from e + except ValueError as e: + # Search_type must be one of ('trakt', ..., 'imdb', 'tmdb', 'tvdb') + raise ValueError(f"Invalid id_type: '{media.provider}', guid: '{media.guid}', guids: '{media.item.guids}': {e}") from e + # look for the first wanted type in the results + for m in search: + if m.media_type == media.type: + return m + + return None diff --git a/plex_trakt_sync/trakt_list_util.py b/plex_trakt_sync/trakt_list_util.py index 37ce5629125..0264cc3b5b2 100644 --- a/plex_trakt_sync/trakt_list_util.py +++ b/plex_trakt_sync/trakt_list_util.py @@ -3,8 +3,8 @@ from trakt.movies import Movie from trakt.tv import TVEpisode from plexapi.video import Episode -import requests_cache -import logging +from plex_trakt_sync.requests_cache import requests_cache +from plex_trakt_sync.logging import logger from plexapi.exceptions import BadRequest, NotFound from itertools import count @@ -27,16 +27,16 @@ def addPlexItem(self, traktid, plex_item): if rank is not None: self.plex_items.append((rank, plex_item)) if isinstance(plex_item, Episode): - logging.info('Show [{} ({})]: {} added to list {}'.format(plex_item.show().title, plex_item.show().year, plex_item.seasonEpisode, self.name)) + logger.info('Show [{} ({})]: {} added to list {}'.format(plex_item.show().title, plex_item.show().year, plex_item.seasonEpisode, self.name)) else: - logging.info('Movie [{} ({})]: added to list {}'.format(plex_item.title, plex_item.year, self.name)) + logger.info('Movie [{} ({})]: added to list {}'.format(plex_item.title, plex_item.year, self.name)) def updatePlexList(self, plex): with requests_cache.disabled(): try: plex.playlist(self.name).delete() except (NotFound, BadRequest): - logging.error("Playlist %s not found, so it could not be deleted. Actual playlists: %s" % (self.name, plex.playlists())) + logger.error("Playlist %s not found, so it could not be deleted. Actual playlists: %s" % (self.name, plex.playlists())) pass if len(self.plex_items) > 0: _, plex_items_sorted = zip(*sorted(dict(reversed(self.plex_items)).items())) @@ -50,13 +50,13 @@ def __init__(self): def addList(self, username, listname, traktid_list=None): if traktid_list is not None: self.lists.append(TraktList.from_traktid_list(listname, traktid_list)) - logging.info("Downloaded List {}".format(listname)) + logger.info("Downloaded List {}".format(listname)) return try: self.lists.append(TraktList(username, listname)) - logging.info("Downloaded List {}".format(listname)) + logger.info("Downloaded List {}".format(listname)) except (NotFoundException, OAuthException): - logging.warning("Failed to get list {} by user {}".format(listname, username)) + logger.warning("Failed to get list {} by user {}".format(listname, username)) def addPlexItemToLists(self, traktid, plex_item): for l in self.lists: diff --git a/requirements.txt b/requirements.txt index 088a5362928..9254137e57d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +click==7.1.2 plexapi==4.5.0 python-dotenv==0.15.0 requests-cache==0.5.2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000000..684a12b8d27 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +import sys +from os.path import dirname, abspath + +# Add our module to system path +sys.path.insert(0, dirname(dirname(abspath(__file__)))) diff --git a/tests/test_new_agent.py b/tests/test_new_agent.py new file mode 100755 index 00000000000..3a722a71de8 --- /dev/null +++ b/tests/test_new_agent.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 -m pytest +from plex_trakt_sync.plex_api import PlexLibraryItem +from plex_trakt_sync.trakt_api import TraktApi + + +def make(cls=None, **kwargs): + cls = cls if cls is not None else "object" + # https://stackoverflow.com/a/2827726/2314626 + return type(cls, (object,), kwargs) + + +trakt = TraktApi() + + +def test_tv_lookup(): + m = PlexLibraryItem(make( + cls='plexapi.video.Show', + guid='plex://show/5d9c085ae98e47001eb0d74f', + guids=[ + make(id='imdb://tt2661044'), + make(id='tmdb://48866'), + make(id='tvdb://268592'), + ], + type='show', + )) + + assert m.provider == 'imdb' + assert m.id == 'tt2661044' + assert m.media_type == 'show' + + +def test_tv_lookup_none(): + m = PlexLibraryItem(make( + cls='plexapi.video.Show', + guid='tv.plex.agents.none://68178', + guids=[ + ], + type='show', + )) + + assert m.provider == 'none' + assert m.id == '68178' + assert m.media_type == 'show'