From 2d2d3cbcadb9d73ff9ab2915c3410229c8923273 Mon Sep 17 00:00:00 2001 From: yitzhaq <17812841+yitzhaq@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:23:36 +0200 Subject: [PATCH] Request extended=progress and paginate the watched/collected getters Trakt's 2026 API change (enforced after 2026-06-30) stopped returning the season/episode breakdown from /sync/watched/shows unless extended=progress is sent, and force-paginated all sync endpoints. The new REST client added a pagination helper (_get_all_pages) but getShowsWatched neither requested extended=progress nor paged, so episode watched-state cannot sync; and getShowsCollected/getMoviesWatched still used a single _get, silently truncating to the first 100 items. - getShowsWatched: request extended=progress (restores the episode breakdown) and page through results via _get_all_pages. - getShowsCollected, getMoviesWatched: route through _get_all_pages. - _get_all_pages: accept optional extra query params (merged per page). - tests: extended=progress + pagination on the getters, and the params-merge in _get_all_pages. Verified against a live account: /sync/watched/shows returns 0 episodes without extended=progress and the full season/episode breakdown with it; X-Pagination-Page-Count > 1 confirms pagination is required. Refs #713 Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/lib/traktapi.py | 24 ++++++++++---- tests/test_traktapi.py | 66 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/resources/lib/traktapi.py b/resources/lib/traktapi.py index f14dadff..d3937510 100644 --- a/resources/lib/traktapi.py +++ b/resources/lib/traktapi.py @@ -404,6 +404,7 @@ def _get_all_pages( authorized: bool = False, timeout: int = 90, limit: int = 100, + params: Optional[Dict] = None, ) -> List: if not self.client: return [] @@ -412,7 +413,10 @@ def _get_all_pages( page = 1 page_count = 1 while page <= page_count: - page_path = self.client.build_path(path, {"page": page, "limit": limit}) + query = {"page": page, "limit": limit} + if params: + query.update(params) + page_path = self.client.build_path(path, query) response = self._get( page_path, authorized=authorized, @@ -595,8 +599,8 @@ def scrobbleMovie(self, movie: Dict, percent: float, status: str) -> Optional[Di ) def getShowsCollected(self, shows: Dict) -> Dict: - for item in ( - self._get("/sync/collection/shows", authorized=True, timeout=90) or [] + for item in self._get_all_pages( + "/sync/collection/shows", authorized=True, timeout=90 ): self._merge_show(shows, item, ("collected_at",)) return shows @@ -609,15 +613,23 @@ def getMoviesCollected(self, movies: Dict) -> Dict: return movies def getShowsWatched(self, shows: Dict) -> Dict: - for item in self._get("/sync/watched/shows", authorized=True, timeout=90) or []: + # extended=progress is required for the season/episode breakdown; without + # it Trakt returns show-level plays only and episode watched-state can't + # be synced. All sync endpoints are also paginated, so page through them. + for item in self._get_all_pages( + "/sync/watched/shows", + authorized=True, + timeout=90, + params={"extended": "progress"}, + ): self._merge_show( shows, item, ("plays", "last_watched_at", "last_updated_at", "reset_at") ) return shows def getMoviesWatched(self, movies: Dict) -> Dict: - for item in ( - self._get("/sync/watched/movies", authorized=True, timeout=90) or [] + for item in self._get_all_pages( + "/sync/watched/movies", authorized=True, timeout=90 ): self._merge_object( movies, item, "movie", ("plays", "last_watched_at", "last_updated_at") diff --git a/tests/test_traktapi.py b/tests/test_traktapi.py index 3ca30445..9cd29bce 100644 --- a/tests/test_traktapi.py +++ b/tests/test_traktapi.py @@ -217,6 +217,23 @@ def test_get_all_pages_follows_pagination_headers(): ) +def test_get_all_pages_merges_extra_params_into_query(): + api = traktAPI.__new__(traktAPI) + api.client = TraktClient("id", "secret", "ua") + api._get = mock.Mock(return_value=([{"title": "One"}], {})) + + api._get_all_pages( + "/sync/watched/shows", authorized=True, params={"extended": "progress"} + ) + + api._get.assert_called_once_with( + "/sync/watched/shows?page=1&limit=100&extended=progress", + authorized=True, + timeout=90, + include_headers=True, + ) + + def test_get_all_ratings_fetches_current_rating_bucket_endpoints(): api = traktAPI.__new__(traktAPI) api._get_all_pages = mock.Mock(return_value=[]) @@ -269,6 +286,55 @@ def test_episode_playback_progress_uses_pagination(): assert episode["progress"] == 50 +def test_get_shows_watched_requests_progress_and_paginates(): + api = traktAPI.__new__(traktAPI) + api._get_all_pages = mock.Mock( + return_value=[ + { + "plays": 3, + "last_watched_at": "2026-01-01T00:00:00.000Z", + "show": {"title": "Show", "ids": {"trakt": 1}}, + "seasons": [{"number": 1, "episodes": [{"number": 1, "plays": 1}]}], + } + ] + ) + + result = api.getShowsWatched({}) + + # extended=progress is what restores the season/episode breakdown (Trakt 2026 + # API change); without it episode watched-state can't be synced. + api._get_all_pages.assert_called_once_with( + "/sync/watched/shows", + authorized=True, + timeout=90, + params={"extended": "progress"}, + ) + episode = result[1].to_dict()["seasons"][0]["episodes"][0] + assert episode["plays"] == 1 + + +def test_get_shows_collected_paginates(): + api = traktAPI.__new__(traktAPI) + api._get_all_pages = mock.Mock(return_value=[]) + + api.getShowsCollected({}) + + api._get_all_pages.assert_called_once_with( + "/sync/collection/shows", authorized=True, timeout=90 + ) + + +def test_get_movies_watched_paginates(): + api = traktAPI.__new__(traktAPI) + api._get_all_pages = mock.Mock(return_value=[]) + + api.getMoviesWatched({}) + + api._get_all_pages.assert_called_once_with( + "/sync/watched/movies", authorized=True, timeout=90 + ) + + def test_client_retries_once_after_rate_limit_retry_after(): client = TraktClient("id", "secret", "ua") rate_limit = urllib.error.HTTPError(