From 4216ed02b5e59c915cb2b46f2b6e1813d79e91c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 6 Jan 2024 15:06:02 +0200 Subject: [PATCH 01/12] Add title_link to PlexPlaylist --- plextraktsync/plex/PlexPlaylist.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plextraktsync/plex/PlexPlaylist.py b/plextraktsync/plex/PlexPlaylist.py index 263d29df32..ddb961bfad 100644 --- a/plextraktsync/plex/PlexPlaylist.py +++ b/plextraktsync/plex/PlexPlaylist.py @@ -3,6 +3,8 @@ from functools import cached_property from typing import TYPE_CHECKING +from rich.markup import escape + from plextraktsync.factory import logging if TYPE_CHECKING: @@ -63,6 +65,15 @@ def update(self, items: list[PlexMedia], description=None) -> bool: return True + @property + def title_link(self): + if self.playlist is not None: + link = self.playlist._getWebURL() + + return f"[link={link}][green]{escape(self.name)}[/][/]" + + return f"[green]{escape(self.name)}[/]" + @staticmethod def same_list(list_a: list[PlexMedia], list_b: list[PlexMedia]) -> bool: """ From 4e1c8b6222245aa212efa2a39729e6c5a4f557b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Tue, 2 May 2023 08:13:03 +0300 Subject: [PATCH 02/12] Add TraktLikedList type --- plextraktsync/trakt/types.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plextraktsync/trakt/types.py b/plextraktsync/trakt/types.py index 591e99794d..cdbd93c3e8 100644 --- a/plextraktsync/trakt/types.py +++ b/plextraktsync/trakt/types.py @@ -1,6 +1,11 @@ -from typing import Union +from typing import TypedDict, Union from trakt.movies import Movie from trakt.tv import TVEpisode, TVSeason, TVShow TraktMedia = Union[Movie, TVShow, TVSeason, TVEpisode] + + +class TraktLikedList(TypedDict): + listid: int + listname: str From 35c35a465edc67074dbd1bbc1632b091f166c0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Tue, 2 May 2023 08:17:57 +0300 Subject: [PATCH 03/12] Typing: Use TraktLikedList return type --- plextraktsync/trakt/TraktApi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plextraktsync/trakt/TraktApi.py b/plextraktsync/trakt/TraktApi.py index 51e698d2e4..07c1fdb935 100644 --- a/plextraktsync/trakt/TraktApi.py +++ b/plextraktsync/trakt/TraktApi.py @@ -20,7 +20,6 @@ from plextraktsync.trakt.PartialTraktMedia import PartialTraktMedia from plextraktsync.trakt.TraktLookup import TraktLookup from plextraktsync.trakt.TraktRatingCollection import TraktRatingCollection -from plextraktsync.trakt.types import TraktMedia if TYPE_CHECKING: from trakt.movies import Movie @@ -28,6 +27,7 @@ from plextraktsync.plex.PlexGuid import PlexGuid from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem + from plextraktsync.trakt.types import TraktLikedList, TraktMedia class TraktApi: @@ -58,7 +58,7 @@ def me(self): @rate_limit() @retry() @flatten_list - def liked_lists(self): + def liked_lists(self) -> list[TraktLikedList]: for item in self.me.get_liked_lists("lists", limit=1000): yield { 'listname': item['list']['name'], From 3f998c9f54003d78b0fb0e6d666403d85f6c44e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Fri, 5 Jan 2024 23:47:20 +0200 Subject: [PATCH 04/12] Add TraktUserListCollection class --- .../trakt/TraktUserListCollection.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 plextraktsync/trakt/TraktUserListCollection.py diff --git a/plextraktsync/trakt/TraktUserListCollection.py b/plextraktsync/trakt/TraktUserListCollection.py new file mode 100644 index 0000000000..3dd5d4b48e --- /dev/null +++ b/plextraktsync/trakt/TraktUserListCollection.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections import UserList +from typing import TYPE_CHECKING + +from plextraktsync.factory import logging +from plextraktsync.trakt.TraktUserList import TraktUserList + +if TYPE_CHECKING: + from trakt.movies import Movie + from trakt.tv import TVEpisode + + from plextraktsync.media import Media + from plextraktsync.trakt.types import TraktLikedList + + +class TraktUserListCollection(UserList): + def __init__(self): + super().__init__() + self.logger = logging.getLogger("PlexTraktSync.TraktUserListCollection") + + def add_to_lists(self, m: Media): + for tl in self: + tl.add(m) + + def load_lists(self, liked_lists: list[TraktLikedList]): + for liked_list in liked_lists: + self.add_list(liked_list["listid"], liked_list["listname"]) + + def add_watchlist(self, items: list[Movie, TVEpisode]): + tl = TraktUserList.from_watchlist(items) + self.append(tl) + return tl + + def add_list(self, list_id: int, list_name: str): + tl = TraktUserList(list_id, list_name) + self.append(tl) + return tl + + def sync(self): + for tl in self: + pl = tl.plex_list + updated = pl.update(tl.plex_items_sorted) + self.logger.info(f"Plex list '{tl.name}' ({len(tl.plex_items)} items) {'updated' if updated else 'nothing to update'}") From 7ad4370b69ece40d74b3e7507152b7b0ca35018b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Fri, 5 Jan 2024 23:46:54 +0200 Subject: [PATCH 05/12] Add PlexPlaylistCollection class --- plextraktsync/plex/PlexPlaylistCollection.py | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 plextraktsync/plex/PlexPlaylistCollection.py diff --git a/plextraktsync/plex/PlexPlaylistCollection.py b/plextraktsync/plex/PlexPlaylistCollection.py new file mode 100644 index 0000000000..d46264442f --- /dev/null +++ b/plextraktsync/plex/PlexPlaylistCollection.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from collections import UserDict +from typing import TYPE_CHECKING + +from plextraktsync.plex.PlexPlaylist import PlexPlaylist + +if TYPE_CHECKING: + from plexapi.server import PlexServer + + +class PlexPlaylistCollection(UserDict): + def __init__(self, server: PlexServer): + super().__init__() + self.server = server + + def __missing__(self, name: str): + playlist = PlexPlaylist(self.server, name) + self[name] = playlist + + return playlist From 4d62ac78b753240c16a59a8b80a6ea6098541154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Tue, 2 May 2023 19:50:37 +0300 Subject: [PATCH 06/12] Add plex_lists factory --- plextraktsync/util/Factory.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plextraktsync/util/Factory.py b/plextraktsync/util/Factory.py index 912eef0347..c248c35b61 100644 --- a/plextraktsync/util/Factory.py +++ b/plextraktsync/util/Factory.py @@ -80,6 +80,13 @@ def plex_server(self): token=server.token, ) + @cached_property + def plex_lists(self): + from plextraktsync.plex.PlexPlaylistCollection import \ + PlexPlaylistCollection + + return PlexPlaylistCollection(self.plex_server) + @cached_property def has_plex_token(self): try: From de6c86b70436cb494e63596c4f8e58ced875f455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Fri, 5 Jan 2024 23:46:21 +0200 Subject: [PATCH 07/12] Add TraktUserList class --- plextraktsync/trakt/TraktUserList.py | 121 +++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 plextraktsync/trakt/TraktUserList.py diff --git a/plextraktsync/trakt/TraktUserList.py b/plextraktsync/trakt/TraktUserList.py new file mode 100644 index 0000000000..7ab37ca5ff --- /dev/null +++ b/plextraktsync/trakt/TraktUserList.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from functools import cached_property +from itertools import count +from typing import TYPE_CHECKING + +from plextraktsync.factory import factory, logging + +if TYPE_CHECKING: + from trakt.movies import Movie + from trakt.tv import TVEpisode + + from plextraktsync.media import Media + from plextraktsync.plex.PlexLibraryItem import PlexLibraryItem + + +class TraktUserList: + plex_items: list[tuple[int, PlexLibraryItem]] + + def __init__(self, + trakt_id: int = None, + name: str = None, + items=None, + ): + if items is None: + items = [] + self.trakt_id = trakt_id + self.name = name + self._items = items + self.description = None + self.plex_items = [] + self.logger = logging.getLogger("PlexTraktSync.TraktUserList") + + def __iter__(self): + return iter(self.items) + + def __len__(self): + return len(self.items) + + def __contains__(self, m: Media): + rank = self.items.get((m.media_type, m.trakt_id)) + + return rank is not None + + @property + def items(self): + if not self._items: + self.description, self._items = self.load_items() + return self._items + + def load_items(self): + from plextraktsync.trakt_list_util import LazyUserList + + userlist = LazyUserList._get(self.name, self.trakt_id) + list_items = userlist._items + prelist = [ + (elem[0], elem[1]) + for elem in list_items + if elem[0] in ["movies", "episodes"] + ] + self.logger.info(f"Downloaded Trakt list '{self.name}' https://trakt.tv/lists/{self.trakt_id}") + + return userlist.description, dict(zip(prelist, count(1))) + + @classmethod + def from_trakt_list(cls, name: str, items: list[Movie, TVEpisode]): + items = zip([(item.media_type, item.trakt) for item in items], count(1)) + + return cls(name=name, items=dict(items)) + + @classmethod + def from_watchlist(cls, items: list[Movie, TVEpisode]): + trakt_items = dict( + zip([(elem.media_type, elem.trakt) for elem in items], count(1)) + ) + return cls(name="Trakt Watchlist", items=trakt_items) + + @cached_property + def plex_lists(self): + return factory.plex_lists + + @cached_property + def plex_list(self): + if not self.name: + raise RuntimeError("Name is required") + + return self.plex_lists[self.name] + + def add(self, m: Media): + rank = self.items.get((m.media_type, m.trakt_id)) + if rank is None: + # Item is not in this trakt list + return + + # TODO: add with rank + self.plex_items.append((rank, m.plex)) + + if m in self.plex_list: + # Already in the list + return + + self.logger.info(f'Adding {m.title_link} ({m.plex_key}) to Plex list {self.title_link}', extra={"markup": True}) + + @property + def title_link(self): + return self.plex_list.title_link + + @property + def plex_items_sorted(self): + """ + Returns items sorted by trakt rank + + https://github.com/Taxel/PlexTraktSync/pull/58 + """ + if len(self.plex_items) == 0: + return [] + + plex_items = [(r, p.item) for (r, p) in self.plex_items] + _, items = zip(*sorted(dict(reversed(plex_items)).items())) + + return items From 9e75b55ee5939ceb8bfffbe5a90567a33fe99bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 6 Jan 2024 16:53:02 +0200 Subject: [PATCH 08/12] Add plex_key property to Media --- plextraktsync/media.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plextraktsync/media.py b/plextraktsync/media.py index 8cc9da902d..c2e40034bf 100644 --- a/plextraktsync/media.py +++ b/plextraktsync/media.py @@ -81,6 +81,10 @@ def episode_number(self): def trakt_id(self): return self.trakt.trakt + @cached_property + def plex_key(self): + return self.plex.key + @property def trakt_url(self): path = self.trakt.slug if self.trakt.slug else self.trakt_id From 037849d9a7d19e2927e3b6c92b4e4068bfe62b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 29 Apr 2023 12:06:56 +0300 Subject: [PATCH 09/12] Add iter,len,contains dunders to PlexPlaylist --- plextraktsync/plex/PlexPlaylist.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plextraktsync/plex/PlexPlaylist.py b/plextraktsync/plex/PlexPlaylist.py index ddb961bfad..62789f8b86 100644 --- a/plextraktsync/plex/PlexPlaylist.py +++ b/plextraktsync/plex/PlexPlaylist.py @@ -5,7 +5,9 @@ from rich.markup import escape +from plextraktsync.decorators.flatten import flatten_dict from plextraktsync.factory import logging +from plextraktsync.media import Media if TYPE_CHECKING: from plexapi.playlist import Playlist @@ -20,6 +22,15 @@ def __init__(self, server: PlexServer, name: str): self.name = name self.logger = logging.getLogger("PlexTraktSync.PlexPlaylist") + def __iter__(self): + return iter(self.items) + + def __len__(self): + return len(self.items.keys()) + + def __contains__(self, m: Media): + return m.plex_key in self.items + @cached_property def playlist(self) -> Playlist | None: try: @@ -34,6 +45,14 @@ def playlist(self) -> Playlist | None: self.logger.debug(f'Unable to find Plex playlist with title "{self.name}".') return None + @cached_property + @flatten_dict + def items(self) -> dict[int, PlexMedia]: + if self.playlist is None: + return + for m in self.playlist.items(): + yield m.ratingKey, m + def update(self, items: list[PlexMedia], description=None) -> bool: """ Updates playlist (creates if name missing) replacing contents with items[] @@ -42,6 +61,7 @@ def update(self, items: list[PlexMedia], description=None) -> bool: if playlist is None and len(items) > 0: # Force reload del self.__dict__["playlist"] + del self.__dict__["items"] playlist = self.server.createPlaylist(self.name, items=items) self.logger.info(f"Created plex playlist '{self.name}' with {len(items)} items") From 30be6f84511a8811483cda88da25d4d2f0635abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Tue, 2 May 2023 19:19:09 +0300 Subject: [PATCH 10/12] sync: Use TraktUserListCollection class --- plextraktsync/sync.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/plextraktsync/sync.py b/plextraktsync/sync.py index a64ddbad82..0c1d502e67 100644 --- a/plextraktsync/sync.py +++ b/plextraktsync/sync.py @@ -5,6 +5,7 @@ from plextraktsync.decorators.measure_time import measure_time from plextraktsync.factory import logger +from plextraktsync.trakt.TraktUserListCollection import TraktUserListCollection from plextraktsync.trakt.types import TraktMedia from plextraktsync.trakt_list_util import TraktListUtil @@ -41,8 +42,8 @@ def trakt_wl(self): return TraktWatchList(self.trakt.watchlist_movies + self.trakt.watchlist_shows) def sync(self, walker: Walker, dry_run=False): - listutil = TraktListUtil() - is_partial = walker.is_partial + trakt_lists = TraktUserListCollection() + is_partial = walker.is_partial and not dry_run if is_partial and self.config.clear_collected: logger.warning("Running partial library sync. Clear collected will be disabled.") @@ -51,14 +52,13 @@ def sync(self, walker: Walker, dry_run=False): if is_partial: logger.warning("Running partial library sync. Watchlist as playlist won't update because it needs full library sync.") else: - listutil.addList(None, "Trakt Watchlist", trakt_list=self.trakt.watchlist_movies) + trakt_lists.add_watchlist(self.trakt.watchlist_movies) if self.config.sync_liked_lists: if is_partial: logger.warning("Partial walk, disabling liked lists updating. Liked lists won't update because it needs full library sync.") else: - for lst in self.trakt.liked_lists: - listutil.addList(lst["listid"], lst["listname"]) + trakt_lists.load_lists(self.trakt.liked_lists) if self.config.need_library_walk: movie_trakt_ids = set() @@ -67,7 +67,7 @@ def sync(self, walker: Walker, dry_run=False): self.sync_ratings(movie, dry_run=dry_run) self.sync_watched(movie, dry_run=dry_run) if not is_partial: - listutil.addPlexItemToLists(movie) + trakt_lists.add_to_lists(movie) if self.config.clear_collected: movie_trakt_ids.add(movie.trakt_id) @@ -81,7 +81,7 @@ def sync(self, walker: Walker, dry_run=False): self.sync_ratings(episode, dry_run=dry_run) self.sync_watched(episode, dry_run=dry_run) if not is_partial: - listutil.addPlexItemToLists(episode) + trakt_lists.add_to_lists(episode) if self.config.clear_collected: episode_trakt_ids.add(episode.trakt_id) @@ -99,8 +99,9 @@ def sync(self, walker: Walker, dry_run=False): if is_partial: logger.warning("Running partial library sync. Liked lists won't update because it needs full library sync.") else: - with measure_time("Updated liked list"): - self.update_playlists(listutil, dry_run=dry_run) + if not dry_run: + with measure_time("Updated liked list"): + trakt_lists.sync() if walker.config.walk_watchlist and self.sync_wl: with measure_time("Updated watchlist"): From f302dbe586d96ae8b34903b64b148af7ee4e19a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 13 Jan 2024 15:54:02 +0200 Subject: [PATCH 11/12] Print only updated lists --- plextraktsync/trakt/TraktUserListCollection.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plextraktsync/trakt/TraktUserListCollection.py b/plextraktsync/trakt/TraktUserListCollection.py index 3dd5d4b48e..23e5482019 100644 --- a/plextraktsync/trakt/TraktUserListCollection.py +++ b/plextraktsync/trakt/TraktUserListCollection.py @@ -39,6 +39,7 @@ def add_list(self, list_id: int, list_name: str): def sync(self): for tl in self: - pl = tl.plex_list - updated = pl.update(tl.plex_items_sorted) - self.logger.info(f"Plex list '{tl.name}' ({len(tl.plex_items)} items) {'updated' if updated else 'nothing to update'}") + updated = tl.plex_list.update(tl.plex_items_sorted) + if not updated: + continue + self.logger.info(f"Plex list {tl.title_link} ({len(tl.plex_items)} items) updated", extra={"markup": True}) From c4868ca7237d8fc5b27e6f490c9df250607561db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 13 Jan 2024 16:03:32 +0200 Subject: [PATCH 12/12] Add TraktLikedList typing --- plextraktsync/trakt/TraktApi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plextraktsync/trakt/TraktApi.py b/plextraktsync/trakt/TraktApi.py index 07c1fdb935..7948f38ed0 100644 --- a/plextraktsync/trakt/TraktApi.py +++ b/plextraktsync/trakt/TraktApi.py @@ -60,10 +60,11 @@ def me(self): @flatten_list def liked_lists(self) -> list[TraktLikedList]: for item in self.me.get_liked_lists("lists", limit=1000): - yield { + tll: TraktLikedList = { 'listname': item['list']['name'], 'listid': item['list']['ids']['trakt'], } + yield tll @cached_property @rate_limit()