diff --git a/tests/conftest.py b/tests/conftest.py index b8fe5fa..d8c9764 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from copy import deepcopy import trakt +import trakt._pagination as _pagination_store TESTS_DIR = os.path.dirname(__file__) MOCK_DATA_DIR = os.path.join(TESTS_DIR, "mock_data") @@ -42,6 +43,9 @@ def request(self, method, uri, data=None): response = method_responses.get(method.upper()) if response is None: print(f"No mock for {uri}") + # Mock responses carry no HTTP headers, so clear any stale pagination + # state to ensure iter_pages stops after the first populated page. + _pagination_store.set(None) return deepcopy(response) diff --git a/tests/mock_data/lists.json b/tests/mock_data/lists.json index 97e66c6..4481a52 100644 --- a/tests/mock_data/lists.json +++ b/tests/mock_data/lists.json @@ -31,7 +31,106 @@ } } }, - "lists/1248149/items": { + "lists/1248149/items?page=1&limit=100": { + "GET": [ + { + "rank": 1, + "id": 37745122, + "listed_at": "2015-07-16T15:01:36.000Z", + "notes": null, + "type": "movie", + "movie": { + "title": "Captain America: The First Avenger", + "year": 2011, + "ids": { + "trakt": 1170, + "slug": "captain-america-the-first-avenger-2011", + "imdb": "tt0458339", + "tmdb": 1771 + } + } + }, + { + "rank": 2, + "id": 92178558, + "listed_at": "2016-04-24T15:44:25.000Z", + "notes": null, + "type": "season", + "season": { + "number": 1, + "ids": { + "trakt": 83598, + "tvdb": 585033, + "tmdb": 63213, + "tvrage": null + } + }, + "show": { + "title": "Marvel's Agent Carter", + "year": 2015, + "ids": { + "trakt": 77677, + "slug": "marvel-s-agent-carter", + "tvdb": 281485, + "imdb": "tt3475734", + "tmdb": 61550, + "tvrage": null + } + } + }, + { + "rank": 3, + "id": 964880087, + "listed_at": "2024-01-19T20:35:45.000Z", + "notes": null, + "type": "episode", + "episode": { + "season": 1, + "number": 1, + "title": "Stark Expo 1974", + "ids": { + "trakt": 3448545, + "tvdb": 7106488, + "imdb": null, + "tmdb": null, + "tvrage": null + } + }, + "show": { + "title": "Stark Expo Shorts", + "year": 2010, + "ids": { + "trakt": 145816, + "slug": "stark-expo-shorts", + "tvdb": 361608, + "imdb": null, + "tmdb": null, + "tvrage": null + } + } + }, + { + "rank": 4, + "id": 564744722, + "listed_at": "2021-01-16T12:25:07.000Z", + "notes": null, + "type": "show", + "show": { + "title": "Moon Knight", + "year": 2022, + "ids": { + "trakt": 151840, + "slug": "moon-knight", + "tvdb": 368611, + "imdb": "tt10234724", + "tmdb": 92749, + "tvrage": null + } + } + } + ] + }, + "lists/1248149/items?page=1&limit=250": { "GET": [ { "rank": 1, diff --git a/tests/mock_data/sync.json b/tests/mock_data/sync.json index b39ac5f..2335aee 100644 --- a/tests/mock_data/sync.json +++ b/tests/mock_data/sync.json @@ -11,7 +11,7 @@ "sync/playback/13": { "DELETE": "" }, - "sync/collection/movies": { + "sync/collection/movies?page=1&limit=100": { "GET": [ { "collected_at":"2014-09-01T09:10:11.000Z", @@ -23,7 +23,7 @@ } ] }, - "sync/collection/movies?extended=metadata": { + "sync/collection/movies?page=1&limit=100&extended=metadata": { "GET": [ { "collected_at":"2014-09-01T09:10:11.000Z", @@ -37,7 +37,7 @@ } ] }, - "sync/collection/shows": { + "sync/collection/shows?page=1&limit=100": { "GET": [ { "last_collected_at":"2014-09-01T09:10:11.000Z", @@ -81,7 +81,7 @@ } ] }, - "sync/collection/shows?extended=metadata": { + "sync/collection/shows?page=1&limit=100&extended=metadata": { "GET": [ { "last_collected_at":"2014-09-01T09:10:11.000Z", @@ -149,29 +149,21 @@ } } }, - "sync/watched/movies": { + "sync/watched/movies?page=1&limit=100": { "GET": [ { "plays":56, "last_watched_at":"2014-10-11T17:00:54.000Z", - "show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}}, - "seasons":[ - {"number":1,"episodes":[{"number":1,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"},{"number":2,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"}]}, - {"number":2,"episodes":[{"number":1,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"},{"number":2,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"}]} - ] + "movie":{"title":"TRON: Legacy","year":2010,"ids":{"trakt":1,"slug":"tron-legacy-2010","imdb":"tt1104001","tmdb":20526}} }, { "plays":23, "last_watched_at":"2014-10-12T17:00:54.000Z", - "show":{"title":"Parks and Recreation","year":2009,"ids":{"trakt":4,"slug":"parks-and-recreation","tvdb":84912,"imdb":"tt1266020","tmdb":8592,"tvrage":21686}}, - "seasons":[ - {"number":1,"episodes":[{"number":1,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"},{"number":2,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"}]}, - {"number":2,"episodes":[{"number":1,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"},{"number":2,"plays":1,"last_watched_at":"2014-10-11T17:00:54.000Z"}]} - ] + "movie":{"title":"The Dark Knight","year":2008,"ids":{"trakt":4,"slug":"the-dark-knight-2008","imdb":"tt0468569","tmdb":155}} } ] }, - "sync/watched/shows": { + "sync/watched/shows?page=1&limit=100&extended=progress": { "GET": [ { "plays":56, @@ -193,7 +185,7 @@ } ] }, - "sync/watched/shows?extended=noseasons": { + "sync/watched/shows?page=1&limit=100": { "GET": [ { "plays":56, @@ -397,7 +389,7 @@ } } }, - "sync/watchlist/movies": { + "sync/watchlist/movies?page=1&limit=100": { "GET": [ { "listed_at":"2014-09-01T09:10:11.000Z", @@ -411,7 +403,10 @@ } ] }, - "sync/watchlist/shows": { + "sync/watchlist/movies?page=2&limit=100": { + "GET": [] + }, + "sync/watchlist/shows?page=1&limit=100": { "GET": [ { "listed_at":"2014-09-01T09:10:11.000Z", @@ -425,7 +420,7 @@ } ] }, - "sync/watchlist/seasons": { + "sync/watchlist/seasons?page=1&limit=100": { "GET": [ { "listed_at":"2014-09-01T09:10:11.000Z", @@ -441,7 +436,7 @@ } ] }, - "sync/watchlist/episodes": { + "sync/watchlist/episodes?page=1&limit=100": { "GET": [ { "listed_at":"2014-09-01T09:10:11.000Z", diff --git a/tests/mock_data/users.json b/tests/mock_data/users.json index ad43800..b7b3767 100644 --- a/tests/mock_data/users.json +++ b/tests/mock_data/users.json @@ -82,6 +82,21 @@ } ] }, + "users/sean/collection/movies?page=1&limit=250": { + "GET": [ + { + "collected_at":"2014-09-01T09:10:11.000Z", + "movie":{"title":"TRON: Legacy","year":2010,"ids":{"trakt":1,"slug":"tron-legacy-2010","imdb":"tt1104001","tmdb":20526}} + }, + { + "collected_at":"2014-09-01T09:10:11.000Z", + "movie":{"title":"The Dark Knight","year":2008,"ids":{"trakt":6,"slug":"the-dark-knight-2008","imdb":"tt0468569","tmdb":155}} + } + ] + }, + "users/sean/collection/movies?page=2&limit=250": { + "GET": [] + }, "users/sean/collection/movies?extended=metadata": { "GET": [ { @@ -96,6 +111,23 @@ } ] }, + "users/sean/collection/movies?page=1&limit=250&extended=metadata": { + "GET": [ + { + "collected_at":"2014-09-01T09:10:11.000Z", + "movie":{"title":"TRON: Legacy","year":2010,"ids":{"trakt":1,"slug":"tron-legacy-2010","imdb":"tt1104001","tmdb":20526}}, + "metadata":{"media_type":"bluray","resolution":"hd_1080p","audio":"dts","audio_channels":"6.1","3d":false} + }, + { + "collected_at":"2014-09-01T09:10:11.000Z", + "movie":{"title":"The Dark Knight","year":2008,"ids":{"trakt":6,"slug":"the-dark-knight-2008","imdb":"tt0468569","tmdb":155}}, + "metadata":{"media_type":"bluray","resolution":"hd_1080p","audio":"dts","audio_channels":"6.1","3d":false} + } + ] + }, + "users/sean/collection/movies?page=2&limit=250&extended=metadata": { + "GET": [] + }, "users/sean/collection/shows": { "GET": [ { @@ -140,6 +172,53 @@ } ] }, + "users/sean/collection/shows?page=1&limit=250": { + "GET": [ + { + "last_collected_at":"2014-09-01T09:10:11.000Z", + "show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}}, + "seasons":[ + { + "number":1, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z"}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z"} + ] + }, + { + "number":2, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z"}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z"} + ] + } + ] + }, + { + "last_collected_at":"2014-09-01T09:10:11.000Z", + "show":{"title":"The Walking Dead","year":2010,"ids":{"trakt":1393,"slug":"the-walking-dead","tvdb":153021,"imdb":"tt1520211","tmdb":1402,"tvrage":null}}, + "seasons":[ + { + "number":1, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z"}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z"} + ] + }, + { + "number":2, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z"}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z"} + ] + } + ] + } + ] + }, + "users/sean/collection/shows?page=2&limit=250": { + "GET": [] + }, "users/sean/collection/shows?extended=metadata": { "GET": [ { @@ -184,6 +263,53 @@ } ] }, + "users/sean/collection/shows?page=1&limit=250&extended=metadata": { + "GET": [ + { + "last_collected_at":"2014-09-01T09:10:11.000Z", + "show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}}, + "seasons":[ + { + "number":1, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}} + ] + }, + { + "number":2, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}} + ] + } + ] + }, + { + "last_collected_at":"2014-09-01T09:10:11.000Z", + "show":{"title":"The Walking Dead","year":2010,"ids":{"trakt":1393,"slug":"the-walking-dead","tvdb":153021,"imdb":"tt1520211","tmdb":1402,"tvrage":null}}, + "seasons":[ + { + "number":1, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}} + ] + }, + { + "number":2, + "episodes":[ + {"number":1,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}}, + {"number":2,"collected_at":"2014-09-01T09:10:11.000Z","metadata":{"media_type":"digital","resolution":"hd_720p","audio":"aac","audio_channels":"5.1","3d":false}} + ] + } + ] + } + ] + }, + "users/sean/collection/shows?page=2&limit=250&extended=metadata": { + "GET": [] + }, "users/sean/comments": { "GET": [ {"type":"movie","movie":{"title":"Batman Begins","year":2005,"ids":{"trakt":1,"slug":"batman-begins-2005","imdb":"tt0372784","tmdb":272}},"comment":{"id":267,"comment":"Great kickoff to a new Batman trilogy!","spoiler":false,"review":false,"parent_id":0,"created_at":"2015-04-25T00:14:57.000Z","replies":0,"likes":0,"user_rating":10,"user":{"username":"justin","private":false,"name":"Justin N.","vip":true,"vip_ep":false}}}, @@ -307,7 +433,7 @@ "POST": "", "DELETE": "" }, - "users/sean/lists/star-wars-in-machete-order/items": { + "users/sean/lists/star-wars-in-machete-order/items?page=1&limit=100": { "GET": [ {"rank":"1","listed_at":"2014-06-16T06:07:12.000Z","type":"movie","movie":{"title":"Star Wars: Episode IV - A New Hope","year":1977,"ids":{"trakt":12,"slug":"star-wars-episode-iv-a-new-hope-1977","imdb":"tt0076759","tmdb":11}}}, {"rank":"2","listed_at":"2014-06-16T06:07:12.000Z","type":"show","show":{"title":"The Walking Dead","year":2010,"ids":{"trakt":1393,"slug":"the-walking-dead","tvdb":153021,"imdb":"tt1520211","tmdb":1402,"tvrage":null}}}, @@ -327,6 +453,15 @@ } } }, + "users/sean/lists/star-wars-in-machete-order/items?page=1&limit=250": { + "GET": [ + {"rank":"1","listed_at":"2014-06-16T06:07:12.000Z","type":"movie","movie":{"title":"Star Wars: Episode IV - A New Hope","year":1977,"ids":{"trakt":12,"slug":"star-wars-episode-iv-a-new-hope-1977","imdb":"tt0076759","tmdb":11}}}, + {"rank":"2","listed_at":"2014-06-16T06:07:12.000Z","type":"show","show":{"title":"The Walking Dead","year":2010,"ids":{"trakt":1393,"slug":"the-walking-dead","tvdb":153021,"imdb":"tt1520211","tmdb":1402,"tvrage":null}}}, + {"rank":"3","listed_at":"2014-06-16T06:07:12.000Z","type":"season","season":{"number":1,"ids":{"tvdb":30272,"tmdb":3572,"tvrage":null}},"show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}}}, + {"rank":"4","listed_at":"2014-06-17T06:52:03.000Z","type":"episode","episode":{"season":0,"number":2,"title":"Wedding Day","ids":{"trakt":2,"tvdb":3859791,"imdb":null,"tmdb":62133,"tvrage":null}},"show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}}}, + {"rank":"5","listed_at":"2014-06-17T06:52:03.000Z","type":"person","person":{"name":"Garrett Hedlund","ids":{"trakt":1,"slug":"garrett-hedlund","imdb":"nm1330560","tmdb":9828,"tvrage":null}}} + ] + }, "users/sean/lists/star-wars-in-machete-order/items/remove": { "POST": { "deleted":{"movies":1,"shows":1,"seasons":1,"episodes":2,"people":1}, @@ -529,7 +664,7 @@ } ] }, - "users/sean/watchlist/movies": { + "users/sean/watchlist/movies?page=1&limit=100": { "GET": [ { "listed_at":"2014-09-01T09:10:11.000Z", @@ -543,7 +678,38 @@ } ] }, - "users/sean/watchlist/shows": { + "users/sean/watchlist/movies?page=1&limit=250": { + "GET": [ + { + "listed_at":"2014-09-01T09:10:11.000Z", + "type":"movie", + "movie":{"title":"TRON: Legacy","year":2010,"ids":{"trakt":1,"slug":"tron-legacy-2010","imdb":"tt1104001","tmdb":20526}} + }, + { + "listed_at":"2014-09-01T09:10:11.000Z", + "type":"movie", + "movie":{"title":"The Dark Knight","year":2008,"ids":{"trakt":6,"slug":"the-dark-knight-2008","imdb":"tt0468569","tmdb":155}} + } + ] + }, + "users/sean/watchlist/movies?page=2&limit=250": { + "GET": [] + }, + "users/sean/watchlist/shows?page=1&limit=100": { + "GET": [ + { + "listed_at":"2014-09-01T09:10:11.000Z", + "type":"show", + "show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}} + }, + { + "listed_at":"2014-09-01T09:10:11.000Z", + "type":"show", + "show":{"title":"The Walking Dead","year":2010,"ids":{"trakt":1393,"slug":"the-walking-dead","tvdb":153021,"imdb":"tt1520211","tmdb":1402,"tvrage":null}} + } + ] + }, + "users/sean/watchlist/shows?page=1&limit=250": { "GET": [ { "listed_at":"2014-09-01T09:10:11.000Z", @@ -557,6 +723,9 @@ } ] }, + "users/sean/watchlist/shows?page=2&limit=250": { + "GET": [] + }, "users/sean/watchlist/seasons": { "GET": [ { @@ -598,7 +767,21 @@ "users/sean-nothing/watching": { "GET": "" }, - "users/sean/watched/movies": { + "users/sean/watched/movies?page=1&limit=100": { + "GET": [ + { + "plays":4, + "last_watched_at":"2014-10-11T17:00:54.000Z", + "movie":{"title":"Batman Begins","year":2005,"ids":{"trakt":6,"slug":"batman-begins-2005","imdb":"tt0372784","tmdb":272}} + }, + { + "plays":2, + "last_watched_at":"2014-10-12T17:00:54.000Z", + "movie":{"title":"The Dark Knight","year":2008,"ids":{"trakt":4,"slug":"the-dark-knight-2008","imdb":"tt0468569","tmdb":155}} + } + ] + }, + "users/sean/watched/movies?page=1&limit=250": { "GET": [ { "plays":4, @@ -612,7 +795,10 @@ } ] }, - "users/sean/watched/shows": { + "users/sean/watched/movies?page=2&limit=250": { + "GET": [] + }, + "users/sean/watched/shows?page=1&limit=100&extended=progress": { "GET": [ { "plays":56, @@ -634,7 +820,21 @@ } ] }, - "users/sean/watched/shows?extended=noseasons": { + "users/sean/watched/shows?page=1&limit=100": { + "GET": [ + { + "plays":56, + "last_watched_at":"2014-10-11T17:00:54.000Z", + "show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"slug":"breaking-bad","tvdb":81189,"imdb":"tt0903747","tmdb":1396,"tvrage":18164}} + }, + { + "plays":23, + "last_watched_at":"2014-10-12T17:00:54.000Z", + "show":{"title":"Parks and Recreation","year":2009,"ids":{"trakt":4,"slug":"parks-and-recreation","tvdb":84912,"imdb":"tt1266020","tmdb":8592,"tvrage":21686}} + } + ] + }, + "users/sean/watched/shows?page=1&limit=250": { "GET": [ { "plays":56, @@ -648,6 +848,9 @@ } ] }, + "users/sean/watched/shows?page=2&limit=250": { + "GET": [] + }, "users/sean/stats": { "GET": {"movies":{"plays":155,"watched":114,"minutes":15650,"collected":933,"ratings":256,"comments":28},"shows":{"watched":16,"collected":7,"ratings":63,"comments":20},"seasons":{"ratings":6,"comments":1},"episodes":{"plays":552,"watched":534,"minutes":17330,"collected":117,"ratings":64,"comments":14},"network":{"friends":1,"followers":4,"following":11},"ratings":{"total":389,"distribution":{"1":18,"2":1,"3":4,"4":1,"5":10,"6":9,"7":37,"8":37,"9":57,"10":215}}} } diff --git a/tests/test_lists.py b/tests/test_lists.py index f77d8da..2070542 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- + from trakt.movies import Movie from trakt.tv import TVEpisode, TVSeason, TVShow from trakt.users import PublicList @@ -6,19 +7,19 @@ def test_public_list(): trakt_id = 1248149 - l = PublicList.load(trakt_id) + public_list = PublicList.load(trakt_id) - assert isinstance(l, PublicList) - assert l.name == "MARVEL Cinematic Universe" - assert l.privacy == "public" - assert l.share_link == "https://trakt.tv/lists/1248149" + assert isinstance(public_list, PublicList) + assert public_list.name == "MARVEL Cinematic Universe" + assert public_list.privacy == "public" + assert public_list.share_link == "https://trakt.tv/lists/1248149" # Test iter - assert len(l) == 4 + assert len(public_list) == 4 # enumerate list items instancetypes = (Movie, TVShow, TVSeason, TVEpisode) - assert all([isinstance(k.item, instancetypes) for k in l]) + assert all([isinstance(k.item, instancetypes) for k in public_list]) # trakt id is a number - assert all([isinstance(k.trakt, int) for k in l]) + assert all([isinstance(k.trakt, int) for k in public_list]) diff --git a/tests/test_sync.py b/tests/test_sync.py index 818f430..fa2ac63 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -5,8 +5,9 @@ import pytest from trakt.sync import (add_to_collection, add_to_history, add_to_watchlist, - comment, rate, remove_from_collection, - remove_from_history, remove_from_watchlist) + comment, get_collection, get_watched, get_watchlist, + rate, remove_from_collection, remove_from_history, + remove_from_watchlist) class FakeMedia: @@ -69,3 +70,64 @@ def test_oneliners(fn, get_key): media = FakeMedia() response = fn(media) assert response.get(get_key) + + +def test_get_watchlist_movies(): + from trakt.movies import Movie + results = get_watchlist('movies') + assert len(results) == 2 + assert all(isinstance(r, Movie) for r in results) + assert results[0].title == "TRON: Legacy" + assert results[1].title == "The Dark Knight" + + +def test_get_watchlist_shows(): + from trakt.tv import TVShow + results = get_watchlist('shows') + assert len(results) == 2 + assert all(isinstance(r, TVShow) for r in results) + assert results[0].title == "Breaking Bad" + + +def test_get_watchlist_iter_pages(): + """iter_pages stops after page 1 (page 2 mock returns []).""" + from trakt.movies import Movie + from trakt.utils import iter_pages + all_results = [] + for page_data in iter_pages(get_watchlist, list_type='movies'): + all_results.extend(page_data) + assert len(all_results) == 2 + assert all(isinstance(r, Movie) for r in all_results) + + +def test_get_collection_movies(): + from trakt.movies import Movie + results = get_collection('movies') + assert len(results) == 2 + assert all(isinstance(r, Movie) for r in results) + assert results[0].title == "TRON: Legacy" + assert results[1].title == "The Dark Knight" + + +def test_get_collection_shows(): + from trakt.tv import TVShow + results = get_collection('shows') + assert len(results) == 2 + assert all(isinstance(r, TVShow) for r in results) + assert results[0].title == "Breaking Bad" + + +def test_get_watched_movies(): + from trakt.movies import Movie + results = get_watched('movies') + assert len(results) == 2 + assert all(isinstance(r, Movie) for r in results) + assert results[0].title == "TRON: Legacy" + + +def test_get_watched_shows(): + from trakt.tv import TVShow + results = get_watched('shows') + assert len(results) == 2 + assert all(isinstance(r, TVShow) for r in results) + assert results[0].title == "Breaking Bad" diff --git a/tests/test_users.py b/tests/test_users.py index 2f1014e..c51c7ce 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -36,47 +36,59 @@ def test_user_collections(): assert all([isinstance(m, Movie) for m in sean.movie_collection]) assert all([isinstance(s, TVShow) for s in sean.show_collection]) + movie_page = sean.collection('movies', page=1, limit=250) + show_page = sean.collection('shows', page=1, limit=250) + assert movie_page + assert show_page + assert all(['movie' in item for item in movie_page]) + assert all(['show' in item for item in show_page]) + assert sean.collection('movies', page=2, limit=250) == [] + assert sean.collection('shows', page=2, limit=250) == [] + def test_user_list(): sean = User('sean') - assert all([isinstance(l, UserList) for l in sean.lists]) + assert all([isinstance(user_list, UserList) for user_list in sean.lists]) data = dict(name='Star Wars in machete order', description='Some descriptive text', privacy='public', display_numbers=True) # create list - l = UserList.create(creator=sean.username, **data) + user_list = UserList.create(creator=sean.username, **data) for k, v in data.items(): - assert getattr(l, k) == v + assert getattr(user_list, k) == v # get list - l = UserList.get(data['name'], sean.username) - l = sean.get_list(data['name']) + user_list = UserList.get(data['name'], sean.username) + assert len(list(user_list)) == 5 + + user_list = sean.get_list(data['name']) for k, v in data.items(): - assert getattr(l, k) == v + assert getattr(user_list, k) == v + assert len(list(user_list)) == 5 # enumerate list items instancetypes = (Movie, TVShow, TVSeason, TVEpisode, Person) - assert all([isinstance(k, instancetypes) for k in l]) + assert all([isinstance(k, instancetypes) for k in user_list]) # PUT to add and remove items from list - l.add_items() + user_list.add_items() for k, v in data.items(): - assert getattr(l, k) == v - l.remove_items() + assert getattr(user_list, k) == v + user_list.remove_items() for k, v in data.items(): - assert getattr(l, k) == v + assert getattr(user_list, k) == v # like and unlike a list - l.like() - l.unlike() + user_list.like() + user_list.unlike() # just test to ensure that iterating over list items works - l.__iter__() + user_list.__iter__() # delete entire list - l.delete_list() + user_list.delete_list() def test_follow_user(): @@ -107,6 +119,15 @@ def test_user_watchlists(): assert all([isinstance(m, Movie) for m in sean.watchlist_movies]) assert all([isinstance(s, TVShow) for s in sean.watchlist_shows]) + movie_page = sean.watchlist('movies', page=1, limit=250) + show_page = sean.watchlist('shows', page=1, limit=250) + assert movie_page + assert show_page + assert all([item['type'] == 'movie' for item in movie_page]) + assert all([item['type'] == 'show' for item in show_page]) + assert sean.watchlist('movies', page=2, limit=250) == [] + assert sean.watchlist('shows', page=2, limit=250) == [] + def test_watching(): sean = User('sean') @@ -124,6 +145,13 @@ def test_watched(): assert all([isinstance(m, Movie) for m in sean.watched_movies]) assert all([isinstance(s, TVShow) for s in sean.watched_shows]) + movie_page = sean.watched('movies', page=1, limit=250) + show_page = sean.watched('shows', page=1, limit=250) + assert all(['movie' in item for item in movie_page]) + assert all(['show' in item for item in show_page]) + assert sean.watched('movies', page=2, limit=250) == [] + assert sean.watched('shows', page=2, limit=250) == [] + def test_stats(): sean = User('sean') diff --git a/trakt/_pagination.py b/trakt/_pagination.py new file mode 100644 index 0000000..a1ca4ab --- /dev/null +++ b/trakt/_pagination.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +"""Internal thread-local store for pagination state. + +This module is intentionally *not* part of the public API. +""" + +import threading +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from trakt.utils import Pagination + +_state = threading.local() + + +def get() -> Optional["Pagination"]: + return getattr(_state, "current", None) + + +def set(pagination: Optional["Pagination"]) -> None: # noqa: A001 + _state.current = pagination diff --git a/trakt/api.py b/trakt/api.py index 6ce7960..2414d3e 100644 --- a/trakt/api.py +++ b/trakt/api.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone from functools import lru_cache from json import JSONDecodeError +from typing import Optional from requests import Session from requests.auth import AuthBase @@ -12,6 +13,8 @@ from trakt.core import TIMEOUT from trakt.errors import (BadRequestException, BadResponseException, OAuthException) +from trakt.utils import Pagination +import trakt._pagination as _pagination_store __author__ = 'Elan Ruusamäe' @@ -130,10 +133,11 @@ def request(self, method, url, data=None): else: response = self.session.request(method, url, headers=self.headers, auth=self.auth, timeout=self.timeout, data=json.dumps(data)) self.logger.debug('RESPONSE [%s] (%s): %s', method, url, str(response)) + _pagination_store.set(None) if response.status_code == 204: # HTTP no content return None self.raise_if_needed(response) - + _pagination_store.set(self._parse_pagination_headers(response.headers)) return self.decode_response(response) @staticmethod @@ -143,6 +147,22 @@ def decode_response(response): except JSONDecodeError as e: raise BadResponseException(f"Unable to parse JSON: {e}") + @staticmethod + def _parse_pagination_headers(headers) -> Optional[Pagination]: + """Extract x-pagination-* headers into a :class:`Pagination` object. + + Returns ``None`` when any of the four required headers are absent. + """ + try: + return Pagination( + item_count=int(headers['x-pagination-item-count']), + limit=int(headers['x-pagination-limit']), + page=int(headers['x-pagination-page']), + page_count=int(headers['x-pagination-page-count']), + ) + except (KeyError, ValueError, TypeError): + return None + def raise_if_needed(self, response): if response.status_code in self.error_map: raise self.error_map[response.status_code](response) diff --git a/trakt/sync.py b/trakt/sync.py index 30e9f9d..06bc309 100644 --- a/trakt/sync.py +++ b/trakt/sync.py @@ -6,11 +6,10 @@ from datetime import datetime, timezone from typing import Any -from deprecated import deprecated from trakt.core import delete, get, post from trakt.mixins import IdsMixin -from trakt.utils import slugify, timestamp +from trakt.utils import slugify, timestamp, validate_limit __author__ = 'Jon Nappi' __all__ = ['Scrobbler', 'comment', 'rate', 'add_to_history', 'get_collection', @@ -21,6 +20,22 @@ 'delete_checkin'] +WATCHLIST_TYPES = ("all", "movies", "shows", "seasons", "episodes") +WATCHLIST_SORT_BY = ( + "rank", + "added", + "title", + "released", + "runtime", + "popularity", + "random", + "percentage", + "my_rating", + "watched", + "collected", +) + + @dataclass(frozen=True) class PlaybackEntry(IdsMixin): progress: float @@ -401,7 +416,7 @@ def get_playback(list_type=None): @get -def get_watchlist(list_type=None, sort=None): +def get_watchlist(list_type=None, page=1, limit=100, sort_by=None, sort_how=None): """ Returns all items in a user's watchlist filtered by type. optionally with a filter for a specific item type. @@ -411,27 +426,34 @@ def get_watchlist(list_type=None, sort=None): :param list_type: Optional Filter by a specific type. Possible values: movies, shows, seasons or episodes. - :param sort: Optional sort. Only if the type is also sent. - Possible values: rank, added, released or title. - + :param page: Optional page number for pagination. + :param limit: Optional number of items per page. + :param sort_by: Optional sort. Only if the type is also sent. + Possible values: rank, added, title, released, runtime, popularity, + random, percentage, my_rating, watched, collected. + :param sort_how: Optional sort direction, 'asc' or 'desc'. https://trakt.docs.apiary.io/#reference/sync/get-watchlist/get-watchlist """ - valid_type = ('movies', 'shows', 'seasons', 'episodes') - valid_sort = ('rank', 'added', 'released', 'title') - - if list_type and list_type not in valid_type: - raise ValueError('list_type must be one of {}'.format(valid_type)) - - if sort and sort not in valid_sort: - raise ValueError('sort must be one of {}'.format(valid_sort)) + validate_limit(limit) uri = 'sync/watchlist' - if list_type: + if list_type is not None: + if list_type not in WATCHLIST_TYPES: + raise ValueError('list_type must be one of {}'.format(WATCHLIST_TYPES)) uri += '/{}'.format(list_type) - if list_type and sort: - uri += '/{}'.format(sort) + if sort_by is not None: + if sort_by not in WATCHLIST_SORT_BY: + raise ValueError('sort_by must be one of {}'.format(WATCHLIST_SORT_BY)) + uri += '/{}'.format(sort_by) + if sort_how is not None: + if sort_how not in ("asc", "desc"): + raise ValueError("Invalid sort_how value. Must be 'asc' or 'desc'") + uri += '/{}'.format(sort_how) + + params: dict[str, int | str] = {"page": page, "limit": limit} + uri += "?" + "&".join(f"{k}={v}" for k, v in params.items()) data = yield uri results = [] for d in data: @@ -454,14 +476,14 @@ def get_watchlist(list_type=None, sort=None): yield results -@deprecated("This method returns watchlist, not watched list. " - "This will be fixed in PyTrakt 4.x to return watched list") @get -def get_watched(list_type=None, extended=None): +def get_watched(list_type=None, page=1, limit=100, extended=None): """Return all movies or shows a user has watched sorted by most plays. :param list_type: Optional Filter by a specific type. Possible values: movies, shows, seasons or episodes. + :param page: Optional page number for pagination. + :param limit: Optional number of items per page. :param extended: Optional value for requesting extended information. """ valid_type = ('movies', 'shows', 'seasons', 'episodes') @@ -469,12 +491,15 @@ def get_watched(list_type=None, extended=None): if list_type and list_type not in valid_type: raise ValueError('list_type must be one of {}'.format(valid_type)) + validate_limit(limit) uri = 'sync/watched' if list_type: uri += '/{}'.format(list_type) + params: dict[str, int | str] = {"page": page, "limit": limit} if list_type == 'shows' and extended: - uri += '?extended={extended}'.format(extended=extended) + params['extended'] = extended + uri += "?" + "&".join(f"{k}={v}" for k, v in params.items()) data = yield uri results = [] @@ -492,7 +517,7 @@ def get_watched(list_type=None, extended=None): @get -def get_collection(list_type=None, extended=None): +def get_collection(list_type=None, page=1, limit=100, extended=None): """ Get all collected items in a user's collection. @@ -501,6 +526,8 @@ def get_collection(list_type=None, extended=None): :param list_type: Optional Filter by a specific type. Possible values: movies or shows. + :param page: Optional page number for pagination. + :param limit: Optional number of items per page. :param extended: Optional value for requesting extended information. """ valid_type = ('movies', 'shows') @@ -508,12 +535,17 @@ def get_collection(list_type=None, extended=None): if list_type and list_type not in valid_type: raise ValueError('list_type must be one of {}'.format(valid_type)) + validate_limit(limit) uri = 'sync/collection' if list_type: uri += '/{}'.format(list_type) + params: dict[str, int | str] = {"page": page, "limit": limit} + if extended: - uri += '?extended={extended}'.format(extended=extended) + params['extended'] = extended + + uri += "?" + "&".join(f"{k}={v}" for k, v in params.items()) data = yield uri results = [] diff --git a/trakt/tv.py b/trakt/tv.py index e86ee88..f711ce1 100644 --- a/trakt/tv.py +++ b/trakt/tv.py @@ -13,7 +13,7 @@ add_to_watchlist, checkin_media, comment, delete_checkin, rate, remove_from_collection, remove_from_history, remove_from_watchlist, search) -from trakt.utils import airs_date, slugify +from trakt.utils import airs_date, slugify, validate_limit __author__ = 'Jon Nappi' __all__ = ['dismiss_recommendation', 'get_recommended_shows', 'genres', @@ -44,6 +44,7 @@ def get_recommended_shows(page=1, limit=10): history and your friends. Results are returned with the top recommendation first. """ + validate_limit(limit) data = yield 'recommendations/shows?page={page}&limit={limit}'.format( page=page, limit=limit ) @@ -59,6 +60,7 @@ def genres(): @get def popular_shows(page=1, limit=10, extended=None): + validate_limit(limit) uri = 'shows/popular?page={page}&limit={limit}'.format( page=page, limit=limit ) @@ -72,6 +74,7 @@ def popular_shows(page=1, limit=10, extended=None): @get def trending_shows(page=1, limit=10, extended=None): """All :class:`TVShow`'s being watched right now""" + validate_limit(limit) uri = 'shows/trending?page={page}&limit={limit}'.format( page=page, limit=limit ) @@ -89,6 +92,7 @@ def updated_shows(timestamp=None, page=1, limit=10, extended=None): store the timestamp so you can be efficient in using this method. """ y_day = datetime.now() - timedelta(1) + validate_limit(limit) ts = timestamp or int(y_day.strftime('%s')) * 1000 uri = 'shows/updates/{start_date}?page={page}&limit={limit}'.format( start_date=ts, page=page, limit=limit @@ -110,6 +114,7 @@ def recommended_shows(time_period='weekly', page=1, limit=10, extended=None): raise ValueError('time_period must be one of {}'.format( valid_time_period )) + validate_limit(limit) uri = 'shows/recommended/{time_period}?page={page}&limit={limit}'.format( time_period=time_period, page=page, limit=limit @@ -133,6 +138,7 @@ def played_shows(time_period='weekly', page=1, limit=10, extended=None): raise ValueError('time_period must be one of {}'.format( valid_time_period )) + validate_limit(limit) uri = 'shows/played/{time_period}?page={page}&limit={limit}'.format( time_period=time_period, page=page, limit=limit @@ -155,6 +161,7 @@ def watched_shows(time_period='weekly', page=1, limit=10, extended=None): raise ValueError('time_period must be one of {}'.format( valid_time_period )) + validate_limit(limit) uri = 'shows/watched/{time_period}?page={page}&limit={limit}'.format( time_period=time_period, page=page, limit=limit @@ -177,6 +184,7 @@ def collected_shows(time_period='weekly', page=1, limit=10, extended=None): raise ValueError('time_period must be one of {}'.format( valid_time_period )) + validate_limit(limit) uri = 'shows/collected/{time_period}?page={page}&limit={limit}'.format( time_period=time_period, page=page, limit=limit @@ -194,6 +202,7 @@ def anticipated_shows(page=1, limit=10, extended=None): Return most anticipated shows based on the number of lists a show appears on. """ + validate_limit(limit) uri = 'shows/anticipated?page={page}&limit={limit}'.format( page=page, limit=limit ) diff --git a/trakt/users.py b/trakt/users.py index fd5a42b..7d4e643 100644 --- a/trakt/users.py +++ b/trakt/users.py @@ -4,16 +4,43 @@ from dataclasses import dataclass from typing import Any, NamedTuple, Optional, Union +import trakt._pagination as _pagination_store from trakt.core import delete, get, post from trakt.mixins import DataClassMixin, IdsMixin from trakt.movies import Movie from trakt.people import Person from trakt.tv import TVEpisode, TVSeason, TVShow -from trakt.utils import slugify - -__author__ = 'Jon Nappi' -__all__ = ['User', 'UserList', 'PublicList', 'Request', 'follow', 'get_all_requests', - 'get_user_settings', 'unfollow'] +from trakt.utils import iter_pages, slugify, validate_limit + +__author__ = "Jon Nappi" +__all__ = [ + "User", + "UserList", + "PublicList", + "Request", + "follow", + "get_all_requests", + "get_user_settings", + "unfollow", +] + + +WATCHLIST_TYPES = ("all", "movies", "shows", "seasons", "episodes") +COLLECTION_TYPES = ("movies", "shows") +WATCHED_TYPES = ("movies", "shows") +WATCHLIST_SORT_BY = ( + "rank", + "added", + "title", + "released", + "runtime", + "popularity", + "random", + "percentage", + "my_rating", + "watched", + "collected", +) class Request(NamedTuple): @@ -23,11 +50,11 @@ class Request(NamedTuple): @post def approve(self): - yield 'users/requests/{id}'.format(id=self.id) + yield "users/requests/{id}".format(id=self.id) @delete def deny(self): - yield 'users/requests/{id}'.format(id=self.id) + yield "users/requests/{id}".format(id=self.id) @post @@ -36,7 +63,7 @@ def follow(user_name): follow request will be in a pending state. If they have a public profile, they will be followed immediately. """ - yield 'users/{username}/follow'.format(username=slugify(user_name)) + yield "users/{username}/follow".format(username=slugify(user_name)) @get @@ -44,10 +71,10 @@ def get_all_requests(): """Get a list of all follower requests including the timestamp when the request was made. Use the approve and deny methods to manage each request. """ - data = yield 'users/requests' + data = yield "users/requests" request_list = [] for request in data: - request['user'] = User(**request.pop('user')) + request["user"] = User(**request.pop("user")) request_list.append(Request(**request)) yield request_list @@ -55,15 +82,14 @@ def get_all_requests(): @get def get_user_settings(): """The currently authenticated user's settings""" - data = yield 'users/settings' + data = yield "users/settings" yield data @delete def unfollow(user_name): - """Unfollow a user you're currently following with a username of *user_name* - """ - yield 'users/{username}/follow'.format(username=slugify(user_name)) + """Unfollow a user you're currently following with a username of *user_name*""" + yield "users/{username}/follow".format(username=slugify(user_name)) @dataclass(frozen=True) @@ -143,7 +169,7 @@ class ListDescription: class PublicList(DataClassMixin(ListDescription), IdsMixin): - """A record for public lists """ + """A record for public lists""" def __init__(self, ids=None, **kwargs): super().__init__(**kwargs) @@ -168,17 +194,22 @@ def load(cls, id: int) -> Union[ListEntry, "PublicList"]: @property def items(self): if self._items is None: - self._load_items() + self._items = [] + for data in iter_pages(self._load_items, limit=250): + self._items.extend(data) return self._items @get - def _load_items(self): + def _load_items(self, page=1, limit=100): """ https://trakt.docs.apiary.io/#reference/lists/list-items """ - data = yield f"lists/{self.trakt}/items" - self._items = list(self._process_items(data)) - yield self._items + validate_limit(limit) + data = yield f"lists/{self.trakt}/items?page={page}&limit={limit}" + if not data: + yield [] + return + yield list(self._process_items(data)) @staticmethod def _process_items(items): @@ -205,8 +236,15 @@ def __iter__(self): @classmethod @post - def create(cls, name, creator, description=None, privacy='private', - display_numbers=False, allow_comments=True): + def create( + cls, + name, + creator, + description=None, + privacy="private", + display_numbers=False, + allow_comments=True, + ): """Create a new custom class:`UserList`. *name* is the only required field, but the other info is recommended. @@ -216,12 +254,15 @@ def create(cls, name, creator, description=None, privacy='private', :param display_numbers: Bool, should each item be numbered? :param allow_comments: Bool, are comments allowed? """ - args = {'name': name, 'privacy': privacy, - 'display_numbers': display_numbers, - 'allow_comments': allow_comments} + args = { + "name": name, + "privacy": privacy, + "display_numbers": display_numbers, + "allow_comments": allow_comments, + } if description is not None: - args['description'] = description - data = yield 'users/{user}/lists'.format(user=slugify(creator)), args + args["description"] = description + data = yield "users/{user}/lists".format(user=slugify(creator)), args yield cls(creator=creator, user=creator, **data) @classmethod @@ -231,51 +272,66 @@ def _get(cls, title, creator): :param title: Name of the list. """ - data = yield 'users/{user}/lists/{id}'.format(user=slugify(creator), - id=slugify(title)) + data = yield "users/{user}/lists/{id}".format( + user=slugify(creator), id=slugify(title) + ) ulist = cls(creator=creator, **data) - ulist.get_items() - + for page_items in iter_pages(ulist.get_items, limit=250): + ulist._items.extend(page_items) yield ulist @get - def get_items(self): + def get_items(self, page=1, limit=100): """A list of the list items using class instances instance types: movie, show, season, episode, person + :param page: Page number (default 1) + :param limit: Number of results per page (default 100, max 250) """ - - data = yield 'users/{user}/lists/{id}/items'.format( - user=slugify(self.creator), id=self.slug) - + validate_limit(limit) + data = yield "users/{user}/lists/{id}/items?page={page}&limit={limit}".format( + user=slugify(self.creator), id=self.slug, page=page, limit=limit + ) + if not data: + yield [] + return + + saved_pagination = _pagination_store.get() + items = [] for item in data: # match list item type - if 'type' not in item: + if "type" not in item: continue - item_type = item['type'] + item_type = item["type"] item_data = item.pop(item_type) - if item_type == 'movie': - self._items.append(Movie(item_data['title'], item_data['year'], - item_data['ids']['slug'])) - elif item_type == 'show': - self._items.append(TVShow(item_data['title'], - item_data['ids']['slug'])) - elif item_type == 'season': - show_data = item.pop('show') - season = TVSeason(show_data['title'], item_data['number'], - show_data['ids']['slug']) - self._items.append(season) - elif item_type == 'episode': - show_data = item.pop('show') - episode = TVEpisode(show_data['title'], item_data['season'], - item_data['number'], - show_id=show_data['ids']['trakt']) - self._items.append(episode) - elif item_type == 'person': - self._items.append(Person(item_data['name'], - item_data['ids']['slug'])) - - yield self._items + if item_type == "movie": + items.append( + Movie( + item_data["title"], item_data["year"], item_data["ids"]["slug"] + ) + ) + elif item_type == "show": + items.append(TVShow(item_data["title"], item_data["ids"]["slug"])) + elif item_type == "season": + show_data = item.pop("show") + season = TVSeason( + show_data["title"], item_data["number"], show_data["ids"]["slug"] + ) + items.append(season) + elif item_type == "episode": + show_data = item.pop("show") + episode = TVEpisode( + show_data["title"], + item_data["season"], + item_data["number"], + show_id=show_data["ids"]["trakt"], + ) + items.append(episode) + elif item_type == "person": + items.append(Person(item_data["name"], item_data["ids"]["slug"])) + + _pagination_store.set(saved_pagination) + yield items @post def add_items(self, *items): @@ -284,44 +340,48 @@ def add_items(self, *items): shows = [s.ids for s in items if isinstance(s, TVShow)] people = [p.ids for p in items if isinstance(p, Person)] self._items = items - args = {'movies': movies, 'shows': shows, 'people': people} - uri = 'users/{user}/lists/{id}/items'.format( - user=slugify(self.creator), id=self.trakt) + args = {"movies": movies, "shows": shows, "people": people} + uri = "users/{user}/lists/{id}/items".format( + user=slugify(self.creator), id=self.trakt + ) yield uri, args @delete def delete_list(self): """Delete this :class:`UserList`""" - yield 'users/{user}/lists/{id}'.format(user=slugify(self.creator), - id=self.trakt) + yield "users/{user}/lists/{id}".format( + user=slugify(self.creator), id=self.trakt + ) @post def like(self): """Like this :class:`UserList`. Likes help determine popular lists. Only one like is allowed per list per user. """ - uri = 'users/{user}/lists/{id}/like' + uri = "users/{user}/lists/{id}/like" yield uri.format(user=slugify(self.creator), id=self.trakt), None @post def remove_items(self, *items): - """Remove *items* to this :class:`UserList`, where items is an iterable - """ + """Remove *items* to this :class:`UserList`, where items is an iterable""" movies = [m.ids for m in items if isinstance(m, Movie)] shows = [s.ids for s in items if isinstance(s, TVShow)] people = [p.ids for p in items if isinstance(p, Person)] self._items = items - args = {'movies': movies, 'shows': shows, 'people': people} - uri = 'users/{user}/lists/{id}/items/remove'.format( - user=slugify(self.creator), id=self.trakt) + args = {"movies": movies, "shows": shows, "people": people} + uri = "users/{user}/lists/{id}/items/remove".format( + user=slugify(self.creator), id=self.trakt + ) yield uri, args @delete def unlike(self): """Remove a like on this :class:`UserList`.""" - uri = 'users/{username}/lists/{id}/like' + uri = "users/{username}/lists/{id}/like" yield uri.format(username=slugify(self.creator), id=self.trakt) + get = _get + class User: """A Trakt.tv User""" @@ -333,10 +393,10 @@ def __init__(self, username, **kwargs): self._movies = self._movie_collection = self._movies_watched = None self._shows = self._show_collection = self._shows_watched = None self._lists = self._followers = self._following = self._friends = None - self._collected = self._watched_shows = self._episode_ratings = None - self._show_ratings = self._movie_ratings = self._watched_movies = None - self._episode_watchlist = self._show_watchlist = None - self._movie_watchlist = None + self._collected = self._episode_ratings = None + self._show_ratings = self._movie_ratings = None + self._episode_watchlist = self._movie_watchlist = None + self._show_watchlist = None self._settings = None @@ -348,7 +408,7 @@ def __init__(self, username, **kwargs): @get def _get(self): """Get this :class:`User` from the trakt.tv API""" - data = yield 'users/{username}'.format(username=slugify(self.username)) + data = yield "users/{username}".format(username=slugify(self.username)) self._build(data) def _build(self, data): @@ -356,6 +416,87 @@ def _build(self, data): for key, val in data.items(): setattr(self, key, val) + @staticmethod + def _validate_watchlist_sort(sort_by=None, sort_how=None): + if sort_by is None: + # no sort at all, so sort_how is irrelevant + return + elif sort_by not in WATCHLIST_SORT_BY: + raise ValueError( + f"Invalid sort_by value. Must be one of {WATCHLIST_SORT_BY}" + ) + + if sort_how is not None and sort_how not in ("asc", "desc"): + raise ValueError("Invalid sort_how value. Must be 'asc' or 'desc'") + + @staticmethod + def _validate_page(page): + if page < 1: + raise ValueError("Invalid page value. Must be greater than 0") + + @staticmethod + def _parse_watchlist_movies(data): + result = [] + for movie in data: + mov = movie.pop("movie") + mov.update(movie) + result.append(Movie(**mov)) + return result + + @staticmethod + def _parse_watchlist_shows(data): + result = [] + for show in data: + show_data = show.pop("show") + show_data.update(show) + result.append(TVShow(**show_data)) + return result + + @staticmethod + def _parse_collection_movies(data): + result = [] + for movie in data: + movie_data = movie.pop("movie") + result.append(Movie(**movie_data)) + return result + + @staticmethod + def _parse_collection_shows(data): + result = [] + for show_data in data: + show_item = show_data.pop("show") + seasons = show_data.pop("seasons") + full_show = TVShow(**show_item) + for season in seasons: + ts = next( + s for s in full_show.seasons if s.season == season.get("number") + ) + for ep in season.get("episodes"): + te = next(e for e in ts.episodes if e.number == ep.get("number")) + ep["title"] = te.title + ep.update(te.ids) + del te, ts, full_show + result.append(TVShow(**show_item, seasons=seasons)) + return result + + @staticmethod + def _parse_watched_movies(data): + result = [] + for movie in data: + movie_data = movie.pop("movie") + movie_data.update(movie) + result.append(Movie(**movie_data)) + return result + + @staticmethod + def _parse_watched_shows(data): + result = [] + for show in data: + show_data = show.pop("show") + show_data.update(show) + result.append(TVShow(**show_data)) + return result + @property @get def followers(self): @@ -365,12 +506,11 @@ def followers(self): display data either. """ if self._followers is None: - data = yield 'users/{user}/followers'.format( - user=slugify(self.username)) + data = yield "users/{user}/followers".format(user=slugify(self.username)) self._followers = [] for user in data: - user_data = user.pop('user') - date = user.pop('followed_at') + user_data = user.pop("user") + date = user.pop("followed_at") self._followers.append(User(followed_at=date, **user_data)) yield self._followers @@ -383,12 +523,11 @@ def following(self): that are protected won't display data either. """ if self._following is None: - data = yield 'users/{user}/following'.format( - user=slugify(self.username)) + data = yield "users/{user}/following".format(user=slugify(self.username)) self._following = [] for user in data: - user_data = user.pop('user') - date = user.pop('followed_at') + user_data = user.pop("user") + date = user.pop("followed_at") self._following.append(User(followed_at=date, **user_data)) yield self._following @@ -403,11 +542,10 @@ def friends(self): """ if self._friends is None: self._friends = [] - data = yield 'users/{user}/friends'.format( - user=slugify(self.username)) + data = yield "users/{user}/friends".format(user=slugify(self.username)) for user in data: - user_data = user.pop('user') - date = user.pop('friends_at') + user_data = user.pop("user") + date = user.pop("friends_at") self._friends.append(User(followed_at=date, **user_data)) yield self._friends @@ -419,48 +557,119 @@ def lists(self): lists, you will need to authenticate as yourself. """ if self._lists is None: - data = yield 'users/{username}/lists'.format( - username=slugify(self.username)) + data = yield "users/{username}/lists".format( + username=slugify(self.username) + ) for ul in data: if "user" in ul: # user will be replaced with the self User object del ul["user"] - self._lists = [UserList(creator=slugify(self.username), user=self, - **ul) for ul in data] + self._lists = [ + UserList(creator=slugify(self.username), user=self, **ul) for ul in data + ] yield self._lists + @get + def watchlist(self, media_type="all", page=1, limit=100, sort_by=None, sort_how=None): + """Returns a page of watchlist items for :class:`User`. + + :param media_type: One of 'all', 'movies', 'shows', 'seasons', + or 'episodes'. + :param page: Page number (default 1) + :param limit: Number of results per page (default 100, max 250) + :param sort_by: Sort by field. One of 'rank', 'added', 'title', + 'released', 'runtime', 'popularity', 'random', 'percentage', + 'my_rating', 'watched', 'collected'. + :param sort_how: Sort direction, 'asc' or 'desc'. + """ + if media_type not in WATCHLIST_TYPES: + raise ValueError( + f"Invalid media_type value. Must be one of {WATCHLIST_TYPES}" + ) + + self._validate_page(page) + validate_limit(limit) + self._validate_watchlist_sort(sort_by=sort_by, sort_how=sort_how) + + uri = "users/{username}/watchlist/{media_type}".format( + username=slugify(self.username), media_type=media_type + ) + if sort_by is not None: + uri += '/{}'.format(sort_by) + if sort_how is not None: + uri += '/{}'.format(sort_how) + params: dict[str, int | str] = {"page": page, "limit": limit} + uri += "?" + "&".join(f"{k}={v}" for k, v in params.items()) + data = yield uri + yield data + + @get + def collection(self, media_type="movies", page=1, limit=100, extended=None): + """Returns a page of collection items for :class:`User`. + + :param media_type: One of 'movies' or 'shows'. + :param page: Page number (default 1) + :param limit: Number of results per page (default 100, max 250) + :param extended: Optional extended info to request. + """ + if media_type not in COLLECTION_TYPES: + raise ValueError( + f"Invalid media_type value. Must be one of {COLLECTION_TYPES}" + ) + + self._validate_page(page) + validate_limit(limit) + + uri = "users/{username}/collection/{media_type}".format( + username=slugify(self.username), media_type=media_type + ) + params: dict[str, int | str] = {"page": page, "limit": limit} + if extended is not None: + params["extended"] = extended + uri += "?" + "&".join(f"{k}={v}" for k, v in params.items()) + data = yield uri + yield data + + @get + def watched(self, media_type="movies", page=1, limit=100): + """Returns a page of watched items for :class:`User`. + + :param media_type: One of 'movies' or 'shows'. + :param page: Page number (default 1) + :param limit: Number of results per page (default 100, max 250) + """ + if media_type not in WATCHED_TYPES: + raise ValueError( + f"Invalid media_type value. Must be one of {WATCHED_TYPES}" + ) + + self._validate_page(page) + validate_limit(limit) + + uri = "users/{user}/watched/{media_type}?page={page}&limit={limit}".format( + user=slugify(self.username), media_type=media_type, page=page, limit=limit + ) + data = yield uri + yield data + @property @get def watchlist_shows(self): - """Returns all watchlist shows of :class:`User`. - """ + """Returns all watchlist shows of :class:`User`.""" if self._show_watchlist is None: - data = yield 'users/{username}/watchlist/shows'.format( - username=slugify(self.username), - ) self._show_watchlist = [] - for show in data: - show_data = show.pop('show') - show_data.update(show) - self._show_watchlist.append(TVShow(**show_data)) - yield self._show_watchlist + for data in iter_pages(self.watchlist, media_type="shows", limit=250): + self._show_watchlist.extend(self._parse_watchlist_shows(data)) yield self._show_watchlist @property @get def watchlist_movies(self): - """Returns all watchlist movies of :class:`User`. - """ + """Returns all watchlist movies of :class:`User`.""" if self._movie_watchlist is None: - data = yield 'users/{username}/watchlist/movies'.format( - username=slugify(self.username), - ) self._movie_watchlist = [] - for movie in data: - mov = movie.pop('movie') - mov.update(movie) - self._movie_watchlist.append(Movie(**mov)) - yield self._movie_watchlist + for data in iter_pages(self.watchlist, media_type="movies", limit=250): + self._movie_watchlist.extend(self._parse_watchlist_movies(data)) yield self._movie_watchlist @property @@ -471,12 +680,9 @@ def movie_collection(self): Protected users won't return any data unless you are friends. """ if self._movie_collection is None: - ext = 'users/{username}/collection/movies?extended=metadata' - data = yield ext.format(username=slugify(self.username)) self._movie_collection = [] - for movie in data: - mov = movie.pop('movie') - self._movie_collection.append(Movie(**mov)) + for data in iter_pages(self.collection, media_type="movies", limit=250): + self._movie_collection.extend(self._parse_collection_movies(data)) yield self._movie_collection @property @@ -487,22 +693,9 @@ def show_collection(self): Protected users won't return any data unless you are friends. """ if self._show_collection is None: - ext = 'users/{username}/collection/shows?extended=metadata' - data = yield ext.format(username=slugify(self.username)) self._show_collection = [] - for show_data in data: - show_item = show_data.pop('show') - seasons = show_data.pop('seasons') - full_show = TVShow(**show_item) - for season in seasons: - ts = next(s for s in full_show.seasons if s.season == season.get('number')) - for ep in season.get('episodes'): - te = next(e for e in ts.episodes if e.number == ep.get('number')) - ep['title'] = te.title - ep.update(te.ids) - del te, ts, full_show - show = TVShow(**show_item, seasons=seasons) - self._show_collection.append(show) + for data in iter_pages(self.collection, media_type="shows", limit=250): + self._show_collection.extend(self._parse_collection_shows(data)) yield self._show_collection @property @@ -511,16 +704,11 @@ def watched_movies(self): """Watched progress for all :class:`Movie`'s in this :class:`User`'s collection. """ - if self._watched_movies is None: - data = yield 'users/{user}/watched/movies'.format( - user=slugify(self.username) - ) - self._watched_movies = [] - for movie in data: - movie_data = movie.pop('movie') - movie_data.update(movie) - self._watched_movies.append(Movie(**movie_data)) - yield self._watched_movies + if self._movies_watched is None: + self._movies_watched = [] + for data in iter_pages(self.watched, media_type="movies", limit=250): + self._movies_watched.extend(self._parse_watched_movies(data)) + yield self._movies_watched @property @get @@ -528,16 +716,11 @@ def watched_shows(self): """Watched progress for all :class:`TVShow`'s in this :class:`User`'s collection. """ - if self._watched_shows is None: - data = yield 'users/{user}/watched/shows'.format( - user=slugify(self.username) - ) - self._watched_shows = [] - for show in data: - show_data = show.pop('show') - show_data.update(show) - self._watched_shows.append(TVShow(**show_data)) - yield self._watched_shows + if self._shows_watched is None: + self._shows_watched = [] + for data in iter_pages(self.watched, media_type="shows", limit=250): + self._shows_watched.extend(self._parse_watched_shows(data)) + yield self._shows_watched @property @get @@ -547,23 +730,25 @@ def watching(self): will be returned. Protected users won't return any data unless you are friends. """ - data = yield 'users/{user}/watching'.format( - user=slugify(self.username)) + data = yield "users/{user}/watching".format(user=slugify(self.username)) # if a user isn't watching anything, trakt returns a 204 - if data is None or data == '': + if data is None or data == "": yield None - media_type = data.pop('type') - if media_type == 'movie': - movie_data = data.pop('movie') + media_type = data.pop("type") + if media_type == "movie": + movie_data = data.pop("movie") movie_data.update(data) yield Movie(**movie_data) else: # media_type == 'episode' - ep_data = data.pop('episode') - sh_data = data.pop('show') - ep_data.update(data, show=sh_data.get('title'), - show_id=sh_data.get('trakt')) + ep_data = data.pop("episode") + sh_data = data.pop("show") + ep_data.update( + data, + show=sh_data.get("title"), + show_id=sh_data.get("ids", {}).get("trakt"), + ) yield TVEpisode(**ep_data) @staticmethod @@ -581,7 +766,7 @@ def get_list(self, title): return UserList.get(title, self.username) @get - def get_ratings(self, media_type='movies', rating=None): + def get_ratings(self, media_type="movies", rating=None): """Get a user's ratings filtered by type. You can optionally filter for a specific rating between 1 and 10. @@ -589,10 +774,11 @@ def get_ratings(self, media_type='movies', rating=None): 'movies', 'shows', 'seasons', 'episodes' :param rating: Optional rating between 1 and 10 """ - uri = 'users/{user}/ratings/{type}'.format(user=slugify(self.username), - type=media_type) + uri = "users/{user}/ratings/{type}".format( + user=slugify(self.username), type=media_type + ) if rating is not None: - uri += '/{rating}'.format(rating=rating) + uri += "/{rating}".format(rating=rating) data = yield uri # TODO (moogar0880) - return as objects yield data @@ -602,7 +788,7 @@ def get_stats(self): """Returns stats about the movies, shows, and episodes a user has watched and collected """ - data = yield 'users/{user}/stats'.format(user=slugify(self.username)) + data = yield "users/{user}/stats".format(user=slugify(self.username)) yield data @get @@ -616,12 +802,13 @@ def get_liked_lists(self, list_type=None, limit=None): https://trakt.docs.apiary.io/#reference/users/likes/get-likes """ - uri = 'users/likes' + uri = "users/likes" if list_type is not None: - uri += f'/{list_type}' + uri += f"/{list_type}" if limit is not None: - uri += f'?limit={limit}' + validate_limit(limit) + uri += f"?limit={limit}" data = yield uri yield data @@ -636,10 +823,8 @@ def unfollow(self): def __str__(self): """String representation of a :class:`User`""" - return ': {}'.format(self.username) + return ": {}".format(self.username) __repr__ = __str__ -# get decorator issue workaround - "It's a little hacky" -UserList.get = UserList._get diff --git a/trakt/utils.py b/trakt/utils.py index 487b60e..1067587 100644 --- a/trakt/utils.py +++ b/trakt/utils.py @@ -2,9 +2,71 @@ import re import unicodedata from datetime import datetime, timezone +from typing import Iterator, Callable, Optional, NamedTuple + +import trakt._pagination as _pagination_store __author__ = 'Jon Nappi' -__all__ = ['slugify', 'airs_date', 'now', 'timestamp', 'extract_ids'] +__all__ = ['slugify', 'airs_date', 'now', 'timestamp', 'extract_ids', + 'validate_limit', 'Pagination', 'iter_pages'] + +_MAX_LIMIT = 250 + + +class Pagination(NamedTuple): + """Pagination metadata from a Trakt.tv API response.""" + item_count: int + limit: int + page: int + page_count: int + + +def _get_pagination() -> Optional[Pagination]: + """Return the pagination info from the most recent paginated API response. + + Returns a :class:`Pagination` namedtuple with *item_count*, *limit*, + *page*, and *page_count* fields, or ``None`` when the last response did + not include ``x-pagination-*`` headers (e.g. non-paginated endpoints). + """ + return _pagination_store.get() + + +def iter_pages(fn: Callable, *args, **kwargs) -> Iterator: + """Iterate over every page returned by a paginated endpoint. + + Calls *fn* with ``page=1, 2, …`` until either the response contains an + ``x-pagination-page-count`` header and the last page has been fetched, or + the response is empty (fallback when no pagination headers are present). + + :param fn: A callable that accepts a *page* keyword argument and returns + the page's data. + :param args: Positional arguments forwarded to *fn*. + :param kwargs: Keyword arguments forwarded to *fn* (``page`` is + overridden on each call). + """ + page = 1 + while True: + kwargs['page'] = page + data = fn(*args, **kwargs) + if not data: + break + yield data + pagination = _get_pagination() + if pagination is None: + break + if page >= pagination.page_count: + break + page += 1 + + +def validate_limit(limit): + """Raise :class:`ValueError` if *limit* exceeds the maximum allowed value + of 250 items per page imposed by the Trakt.tv API. + """ + if limit is not None and limit > _MAX_LIMIT: + raise ValueError( + f"limit must not exceed {_MAX_LIMIT} items per page, got {limit}" + ) def slugify(value):