diff --git a/Pipfile b/Pipfile index eed5521df7..c8d01dc7cc 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ verify_ssl = true click = "==7.1.2" plexapi = "==4.5.0" python-dotenv = "==0.15.0" +python-git-info = "==0.6.1" requests-cache = "==0.5.2" trakt = "==3.1.0" diff --git a/Pipfile.lock b/Pipfile.lock index c0affa8dc4..78ba8844cb 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "31aa883b7bc5d936b309b6d69fc7d6836c096bcb74e302945255f4f6c69f4e5b" + "sha256": "b782af96bccb52b0fa8e8384d91d93bef4f2dee0df97b960fa8c4c9cd8b98e4d" }, "pipfile-spec": 6, "requires": { @@ -71,6 +71,13 @@ "index": "pypi", "version": "==0.15.0" }, + "python-git-info": { + "hashes": [ + "sha256:75ba1016873b014e17263ba512b9bd0304de2a55f1724858bd48cce932f49702" + ], + "index": "pypi", + "version": "==0.6.1" + }, "requests": { "hashes": [ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", diff --git a/plex_trakt_sync/commands/inspect.py b/plex_trakt_sync/commands/inspect.py index 059397add6..22fb9bca87 100644 --- a/plex_trakt_sync/commands/inspect.py +++ b/plex_trakt_sync/commands/inspect.py @@ -3,6 +3,7 @@ from plex_trakt_sync.config import CONFIG from plex_trakt_sync.plex_api import PlexApi from plex_trakt_sync.trakt_api import TraktApi +from plex_trakt_sync.version import git_version_info @click.command() @@ -12,6 +13,9 @@ def inspect(input): Inspect details of an object """ + git_version = git_version_info() or 'Unknown version' + print(f"PlexTraktSync inspect [{git_version}]") + url = CONFIG["PLEX_BASEURL"] token = CONFIG["PLEX_TOKEN"] server = PlexServer(url, token) diff --git a/plex_trakt_sync/commands/plex_login.py b/plex_trakt_sync/commands/plex_login.py index f4803998d2..cba7cffc8f 100644 --- a/plex_trakt_sync/commands/plex_login.py +++ b/plex_trakt_sync/commands/plex_login.py @@ -1,13 +1,15 @@ +from datetime import datetime, timedelta +from functools import partial from typing import List import click from click import Choice from plexapi.exceptions import Unauthorized, NotFound -from plexapi.myplex import MyPlexAccount, MyPlexResource +from plexapi.myplex import MyPlexAccount, MyPlexResource, ResourceConnection from plexapi.server import PlexServer from plex_trakt_sync.config import CONFIG -from plex_trakt_sync.style import prompt, error, success, title, comment +from plex_trakt_sync.style import prompt, error, success, title, comment, disabled, highlight PROMPT_PLEX_PASSWORD = prompt("Please enter your Plex password") PROMPT_PLEX_USERNAME = prompt("Please enter your Plex username") @@ -55,23 +57,35 @@ def choose_managed_user(account: MyPlexAccount): def prompt_server(servers: List[MyPlexResource]): + old_age = datetime.now() - timedelta(weeks=1) + def fmt_server(s): - details = comment(f"{s.product}/{s.productVersion} on {s.device}: {s.platform}/{s.platformVersion}") - return f"- {s.name}: [Last seen: {comment(str(s.lastSeenAt))}, Server: {details}]" + if s.lastSeenAt < old_age: + decorator = disabled + else: + decorator = comment + + product = decorator(f"{s.product}/{s.productVersion}") + platform = decorator(f"{s.device}: {s.platform}/{s.platformVersion}") + click.echo(f"- {highlight(s.name)}: [Last seen: {decorator(str(s.lastSeenAt))}, Server: {product} on {platform}]") + c: ResourceConnection + for c in s.connections: + click.echo(f" {c.uri}") owned_servers = [s for s in servers if s.owned] unowned_servers = [s for s in servers if not s.owned] + sorter = partial(sorted, key=lambda s: s.lastSeenAt) server_names = [] if owned_servers: click.echo(success(f"{len(owned_servers)} owned servers found:")) - for s in owned_servers: - click.echo(fmt_server(s)) + for s in sorter(owned_servers): + fmt_server(s) server_names.append(s.name) if unowned_servers: click.echo(success(f"{len(owned_servers)} unowned servers found:")) - for s in unowned_servers: - click.echo(fmt_server(s)) + for s in sorter(unowned_servers): + fmt_server(s) server_names.append(s.name) return click.prompt( @@ -105,6 +119,9 @@ def choose_server(account: MyPlexAccount): server = pick_server(account) # Connect to obtain baseUrl click.echo(title(f"Attempting to connect to {server.name}. This may take time and print some errors.")) + click.echo(title(f"Server connections:")) + for c in server.connections: + click.echo(f" {c.uri}") plex = server.connect() # Validate connection again, the way we connect plex = PlexServer(token=server.accessToken, baseurl=plex._baseurl) diff --git a/plex_trakt_sync/commands/sync.py b/plex_trakt_sync/commands/sync.py index ba0eb8bcf8..35137dff15 100644 --- a/plex_trakt_sync/commands/sync.py +++ b/plex_trakt_sync/commands/sync.py @@ -9,6 +9,7 @@ from plex_trakt_sync.trakt_api import TraktApi from plex_trakt_sync.trakt_list_util import TraktListUtil from plex_trakt_sync.logging import logger +from plex_trakt_sync.version import git_version_info def sync_collection(pm, tm, trakt: TraktApi, trakt_movie_collection): @@ -18,7 +19,7 @@ def sync_collection(pm, tm, trakt: TraktApi, trakt_movie_collection): if tm.trakt in trakt_movie_collection: return - logger.info(f"Add to Trakt Collection: {pm}") + logger.info(f"To be added to collection: {pm}") trakt.add_to_collection(tm, pm) @@ -136,6 +137,7 @@ def find_show_episodes(show, plex: PlexApi, trakt: TraktApi): def for_each_show_episode(pm, tm, trakt: TraktApi): + lookup = trakt.lookup(tm) for pe in pm.episodes(): try: provider = pe.provider @@ -147,19 +149,19 @@ def for_each_show_episode(pm, tm, trakt: TraktApi): logger.error(f"Skipping {pe}: Provider {provider} not supported") continue - te = trakt.find_episode(tm, pe) + te = trakt.find_episode(tm, pe, lookup) if te is None: logger.warning(f"Skipping {pe}: Not found on Trakt") continue yield tm, pe, te -def sync_all(movies=True, tv=True, show=None): +def sync_all(library=None, movies=True, tv=True, show=None, batch_size=None): with requests_cache.disabled(): server = get_plex_server() listutil = TraktListUtil() plex = PlexApi(server) - trakt = TraktApi() + trakt = TraktApi(batch_size=batch_size) with measure_time("Loaded Trakt lists"): trakt_watched_movies = trakt.watched_movies @@ -180,7 +182,7 @@ def sync_all(movies=True, tv=True, show=None): logger.info("Recently added: {}".format(server.library.recentlyAdded()[:5])) if movies: - for pm, tm in for_each_pair(plex.movie_sections, trakt): + for pm, tm in for_each_pair(plex.movie_sections(library=library), trakt): sync_collection(pm, tm, trakt, trakt_movie_collection) sync_ratings(pm, tm, plex, trakt) sync_watched(pm, tm, plex, trakt, trakt_watched_movies) @@ -189,7 +191,7 @@ def sync_all(movies=True, tv=True, show=None): if show: it = find_show_episodes(show, plex, trakt) else: - it = for_each_episode(plex.show_sections, trakt) + it = for_each_episode(plex.show_sections(library=library), trakt) for tm, pe, te in it: sync_show_collection(tm, pe, te, trakt) @@ -205,6 +207,10 @@ def sync_all(movies=True, tv=True, show=None): @click.command() +@click.option( + "--library", + help="Specify Library to use" +) @click.option( "--show", "show", type=str, @@ -216,11 +222,20 @@ def sync_all(movies=True, tv=True, show=None): default="all", show_default=True, help="Specify what to sync" ) -def sync(sync_option: str, show: str): +@click.option( + "--batch-size", "batch_size", + type=int, + default=1, show_default=True, + help="Batch size for collection submit queue" +) +def sync(sync_option: str, library: str, show: str, batch_size: int): """ Perform sync between Plex and Trakt """ + git_version = git_version_info() + if git_version: + logger.info(f"PlexTraktSync [{git_version}]") logger.info(f"Syncing with Plex {CONFIG['PLEX_USERNAME']} and Trakt {CONFIG['TRAKT_USERNAME']}") movies = sync_option in ["all", "movies"] @@ -237,4 +252,4 @@ def sync(sync_option: str, show: str): logger.info(f"Syncing TV={tv}, Movies={movies}") with measure_time("Completed full sync"): - sync_all(movies=movies, tv=tv, show=show) + sync_all(movies=movies, library=library, tv=tv, show=show, batch_size=batch_size) diff --git a/plex_trakt_sync/decorators/rate_limit.py b/plex_trakt_sync/decorators/rate_limit.py index 134ca79b73..68c638910f 100644 --- a/plex_trakt_sync/decorators/rate_limit.py +++ b/plex_trakt_sync/decorators/rate_limit.py @@ -2,7 +2,7 @@ from time import sleep, time from requests.exceptions import ConnectionError -from trakt.errors import RateLimitException +from trakt.errors import RateLimitException, TraktInternalException from plex_trakt_sync.logging import logger last_time = None @@ -41,7 +41,7 @@ def wrapper(*args, **kwargs): try: respect_trakt_rate() return fn(*args, **kwargs) - except (RateLimitException, ConnectionError) as e: + except (RateLimitException, ConnectionError, TraktInternalException) as e: if retry == retries: raise e diff --git a/plex_trakt_sync/plex_api.py b/plex_trakt_sync/plex_api.py index a67218ace0..42cd513534 100644 --- a/plex_trakt_sync/plex_api.py +++ b/plex_trakt_sync/plex_api.py @@ -171,24 +171,24 @@ class PlexApi: def __init__(self, plex): self.plex = plex - @property - @memoize - def movie_sections(self): + def movie_sections(self, library=None): result = [] for section in self.library_sections: if not type(section) is MovieSection: continue + if library and section.title != library: + continue result.append(PlexLibrarySection(section)) return result - @property - @memoize - def show_sections(self): + def show_sections(self, library=None): result = [] for section in self.library_sections: if not type(section) is ShowSection: continue + if library and section.title != library: + continue result.append(PlexLibrarySection(section)) return result diff --git a/plex_trakt_sync/style.py b/plex_trakt_sync/style.py index 99fe7f5ba7..1074ffb050 100644 --- a/plex_trakt_sync/style.py +++ b/plex_trakt_sync/style.py @@ -6,4 +6,6 @@ prompt = partial(click.style, fg="yellow") success = partial(click.style, fg="green") error = partial(click.style, fg="red") -comment = partial(click.style, fg="cyan") +comment = partial(click.style, fg="bright_cyan") +disabled = partial(click.style, fg="blue") +highlight = partial(click.style, fg="bright_white") diff --git a/plex_trakt_sync/trakt_api.py b/plex_trakt_sync/trakt_api.py index 1a8d061c85..939c2e3871 100644 --- a/plex_trakt_sync/trakt_api.py +++ b/plex_trakt_sync/trakt_api.py @@ -50,8 +50,8 @@ class TraktApi: Trakt API class abstracting common data access and dealing with requests cache. """ - def __init__(self): - self.batch = TraktBatch(self) + def __init__(self, batch_size=None): + self.batch = TraktBatch(self, batch_size=batch_size) @property @memoize @@ -216,14 +216,18 @@ def search_by_id(self, media_id: str, id_type: str, media_type: str): return None - def find_episode(self, tm: TVShow, pe: PlexLibraryItem): + def find_episode(self, tm: TVShow, pe: PlexLibraryItem, lookup=None): """ Find Trakt Episode from Plex Episode """ - lookup = self.lookup(tm) + lookup = lookup if lookup else self.lookup(tm) try: return lookup[pe.season_number][pe.episode_number].instance except KeyError: + # Retry using search for specific Plex Episode + logger.warning("Retry using search for specific Plex Episode") + if not pe.is_episode: + return self.find_by_media(pe) return None def flush(self): @@ -234,24 +238,42 @@ def flush(self): class TraktBatch: - def __init__(self, trakt: TraktApi): + def __init__(self, trakt: TraktApi, batch_size=None): self.trakt = trakt + self.batch_size = batch_size self.collection = {} @nocache @rate_limit(delay=TRAKT_POST_DELAY) def submit_collection(self): - if not len(self.collection): - return None + if self.queue_size() == 0: + return try: - result = trakt.sync.add_to_collection(self.collection) - result = self.remove_empty_values(result) + result = self.trakt_sync_collection(self.collection) + result = self.remove_empty_values(result.copy()) if result: logger.info(f"Updated Trakt collection: {result}") finally: self.collection.clear() + def queue_size(self): + size = 0 + for media_type in self.collection: + size += len(self.collection[media_type]) + + return size + + def flush(self): + """ + Flush the queue if it's bigger than batch_size + """ + if not self.batch_size: + return + + if self.queue_size() >= self.batch_size: + self.submit_collection() + def add_to_collection(self, media_type: str, item): """ Add item of media_type to collection @@ -260,6 +282,10 @@ def add_to_collection(self, media_type: str, item): self.collection[media_type] = [] self.collection[media_type].append(item) + self.flush() + + def trakt_sync_collection(self, media_object): + return trakt.sync.add_to_collection(media_object) def remove_empty_values(self, result): """ diff --git a/plex_trakt_sync/version.py b/plex_trakt_sync/version.py new file mode 100644 index 0000000000..5b3bbd6f18 --- /dev/null +++ b/plex_trakt_sync/version.py @@ -0,0 +1,11 @@ +def git_version_info(): + try: + from gitinfo import get_git_info + except ImportError: + return + + commit = get_git_info() + if not commit: + return None + + return f"{commit['commit'][0:8]}: {commit['message']} @{commit['author_date']}" diff --git a/requirements.txt b/requirements.txt index 03d1be18d3..aea014dca7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ click==7.1.2 plexapi==4.5.0 python-dotenv==0.15.0 +python-git-info==0.6.1 requests-cache==0.5.2 trakt==3.1.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..000670114f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import json +from os.path import dirname, join as join_path + +MOCK_DATA_DIR = join_path(dirname(__file__), "mock_data") + + +def load_mock(name: str): + filename = join_path(MOCK_DATA_DIR, name) + with open(filename, encoding='utf-8') as f: + return json.load(f) diff --git a/tests/mock_data/trakt_sync_collection_request.json b/tests/mock_data/trakt_sync_collection_request.json new file mode 100644 index 0000000000..e6062bba9d --- /dev/null +++ b/tests/mock_data/trakt_sync_collection_request.json @@ -0,0 +1,101 @@ +{ + "movies": [ + { + "collected_at": "2014-09-01T09:10:11.000Z", + "title": "Batman Begins", + "year": 2005, + "ids": { + "trakt": 1, + "slug": "batman-begins-2005", + "imdb": "tt0372784", + "tmdb": 272 + }, + "media_type": "digital", + "resolution": "uhd_4k", + "hdr": "dolby_vision", + "audio": "dts_ma", + "audio_channels": "5.1" + }, + { + "ids": { + "imdb": "tt0000111" + } + } + ], + "shows": [ + { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + }, + { + "title": "The Walking Dead", + "year": 2010, + "ids": { + "trakt": 2, + "slug": "the-walking-dead", + "tvdb": 153021, + "imdb": "tt1520211", + "tmdb": 1402 + }, + "seasons": [ + { + "number": 3 + } + ] + }, + { + "title": "Mad Men", + "year": 2007, + "ids": { + "trakt": 4, + "slug": "mad-men", + "tvdb": 80337, + "imdb": "tt0804503", + "tmdb": 1104 + }, + "seasons": [ + { + "number": 1, + "episodes": [ + { + "number": 1, + "media_type": "bluray", + "resolution": "hd_1080p", + "audio": "dolby_digital_plus", + "audio_channels": "5.1" + }, + { + "number": 2 + } + ] + } + ] + } + ], + "seasons": [ + { + "ids": { + "trakt": 140912, + "tvdb": 703353, + "tmdb": 81266 + } + } + ], + "episodes": [ + { + "ids": { + "trakt": 1061, + "tvdb": 1555111, + "imdb": "tt007404", + "tmdb": 422183 + } + } + ] +} diff --git a/tests/mock_data/trakt_sync_collection_response.json b/tests/mock_data/trakt_sync_collection_response.json new file mode 100644 index 0000000000..77eaebd621 --- /dev/null +++ b/tests/mock_data/trakt_sync_collection_response.json @@ -0,0 +1,20 @@ +{ + "added": { + "movies": 0, + "episodes": 0 + }, + "updated": { + "movies": 0, + "episodes": 0 + }, + "existing": { + "movies": 0, + "episodes": 0 + }, + "not_found": { + "movies": [], + "shows": [], + "seasons": [], + "episodes": [] + } +} diff --git a/tests/test_batch.py b/tests/test_batch.py new file mode 100755 index 0000000000..f117936969 --- /dev/null +++ b/tests/test_batch.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 -m pytest +from plex_trakt_sync.trakt_api import TraktApi, TraktBatch +from tests.conftest import load_mock +from unittest.mock import Mock + +trakt = TraktApi() + + +def test_batch_size_none(): + response = load_mock("trakt_sync_collection_response.json") + b = TraktBatch(trakt) + b.trakt_sync_collection = Mock(return_value=response) + + assert b.queue_size() == 0 + + request = load_mock("trakt_sync_collection_request.json") + for media_type, items in request.items(): + for item in items: + b.add_to_collection(media_type, item) + assert b.queue_size() == 7 + + b.submit_collection() + assert b.queue_size() == 0 + assert b.trakt_sync_collection.call_count == 1 + + +def test_batch_size_1(): + response = load_mock("trakt_sync_collection_response.json") + b = TraktBatch(trakt, batch_size=1) + b.trakt_sync_collection = Mock(return_value=response) + + assert b.queue_size() == 0 + + request = load_mock("trakt_sync_collection_request.json") + for media_type, items in request.items(): + for item in items: + b.add_to_collection(media_type, item) + assert b.queue_size() == 0 + assert b.trakt_sync_collection.call_count == 7 + + b.submit_collection() + assert b.queue_size() == 0 + assert b.trakt_sync_collection.call_count == 7 diff --git a/tests/test_tv_lookup.py b/tests/test_tv_lookup.py index 77823cac9f..e2159963d6 100755 --- a/tests/test_tv_lookup.py +++ b/tests/test_tv_lookup.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 -m pytest +from typing import Union + +from trakt.tv import TVShow from plex_trakt_sync.plex_api import PlexLibraryItem from plex_trakt_sync.trakt_api import TraktApi -def make(cls=None, **kwargs): +def make(cls=None, **kwargs) -> Union[TVShow]: cls = cls if cls is not None else "object" # https://stackoverflow.com/a/2827726/2314626 return type(cls, (object,), kwargs) @@ -34,3 +37,24 @@ def test_tv_lookup_by_episode_id(): te = trakt.find_by_media(pe) assert te.imdb == "tt0505457" assert te.tmdb == 511997 + + +def test_find_episode(): + tm = make( + cls='TVShow', + # trakt=4965066, + trakt=176447, + ) + + pe = PlexLibraryItem(make( + cls='Episode', + guid='imdb://tt11909222', + type='episode', + seasonNumber=1, + index=1, + )) + + te = trakt.find_episode(tm, pe) + assert te.season == 1 + assert te.episode == 1 + assert te.imdb == "tt11909222" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100755 index 0000000000..85eb047a9c --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 -m pytest +from plex_trakt_sync.version import git_version_info + + +def test_version(): + v = git_version_info() + + assert type(v) == str