From 10efa4029359a3293aa2f8c83cc9236fe4d00ec4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 25 Jan 2025 13:00:32 +0100 Subject: [PATCH] Add methods for episodes (#488) --- src/spotifyaio/models.py | 40 +++ src/spotifyaio/spotify.py | 59 ++++- tests/__snapshots__/test_spotify.ambr | 139 +++++++++++ tests/fixtures/episode_saved.json | 4 + tests/fixtures/episodes.json | 274 +++++++++++++++++++++ tests/fixtures/saved_episodes.json | 334 ++++++++++++++++++++++++++ tests/test_spotify.py | 192 +++++++++++++++ 7 files changed, 1037 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/episode_saved.json create mode 100644 tests/fixtures/episodes.json create mode 100644 tests/fixtures/saved_episodes.json diff --git a/src/spotifyaio/models.py b/src/spotifyaio/models.py index c41136f..d40c8ab 100644 --- a/src/spotifyaio/models.py +++ b/src/spotifyaio/models.py @@ -557,6 +557,33 @@ class ShowEpisodesResponse(DataClassORJSONMixin): items: list[SimplifiedEpisode] +@dataclass +class SavedEpisode(DataClassORJSONMixin): + """Saved episode model.""" + + added_at: datetime + episode: Episode + + +@dataclass +class SavedEpisodeResponse(DataClassORJSONMixin): + """Saved episodes response model.""" + + items: list[SavedEpisode] + + @classmethod + def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]: + """Pre deserialize hook.""" + return { + **d, + "items": [ + item + for item in d["items"] + if item.get("episode", {}).get("id") is not None + ], + } + + @dataclass class Episode(SimplifiedEpisode, Item): """Episode model.""" @@ -565,6 +592,19 @@ class Episode(SimplifiedEpisode, Item): show: SimplifiedShow +@dataclass +class EpisodesResponse(DataClassORJSONMixin): + """Episodes response model.""" + + episodes: list[Episode] + + @classmethod + def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]: + """Pre deserialize hook.""" + items = [item for item in d["episodes"] if item is not None] + return {"episodes": items} + + @dataclass class Show(SimplifiedShow): """Show model.""" diff --git a/src/spotifyaio/spotify.py b/src/spotifyaio/spotify.py index 42ec664..2180e27 100644 --- a/src/spotifyaio/spotify.py +++ b/src/spotifyaio/spotify.py @@ -35,6 +35,7 @@ Device, Devices, Episode, + EpisodesResponse, FeaturedPlaylistResponse, FollowedArtistResponse, NewReleasesResponse, @@ -48,6 +49,8 @@ SavedAlbum, SavedAlbumResponse, SavedAudiobookResponse, + SavedEpisode, + SavedEpisodeResponse, SavedShow, SavedShowResponse, SavedTrack, @@ -383,15 +386,61 @@ async def get_episode(self, episode_id: str) -> Episode: response = await self._get(f"v1/episodes/{identifier}") return Episode.from_json(response) - # Get several episodes + async def get_episodes(self, episode_ids: list[str]) -> list[Episode]: + """Get episodes.""" + if not episode_ids: + return [] + if len(episode_ids) > 50: + msg = "Maximum of 50 episodes can be requested at once" + raise ValueError(msg) + params: dict[str, Any] = { + "ids": ",".join([get_identifier(i) for i in episode_ids]) + } + response = await self._get("v1/episodes", params=params) + return EpisodesResponse.from_json(response).episodes - # Get saved episodes + async def get_saved_episodes(self) -> list[SavedEpisode]: + """Get saved episodes.""" + params: dict[str, Any] = {"limit": 48} + response = await self._get("v1/me/episodes", params=params) + return SavedEpisodeResponse.from_json(response).items - # Save an episode + async def save_episodes(self, episode_ids: list[str]) -> None: + """Save episodes.""" + if not episode_ids: + return + if len(episode_ids) > 50: + msg = "Maximum of 50 episodes can be saved at once" + raise ValueError(msg) + params: dict[str, Any] = { + "ids": ",".join([get_identifier(i) for i in episode_ids]) + } + await self._put("v1/me/episodes", params=params) - # Remove an episode + async def remove_saved_episodes(self, episode_ids: list[str]) -> None: + """Remove saved episodes.""" + if not episode_ids: + return + if len(episode_ids) > 50: + msg = "Maximum of 50 episodes can be removed at once" + raise ValueError(msg) + params: dict[str, Any] = { + "ids": ",".join([get_identifier(i) for i in episode_ids]) + } + await self._delete("v1/me/episodes", params=params) - # Check if one or more episodes is already saved + async def are_episodes_saved(self, episode_ids: list[str]) -> dict[str, bool]: + """Check if episodes are saved.""" + if not episode_ids: + return {} + if len(episode_ids) > 50: + msg = "Maximum of 50 episodes can be checked at once" + raise ValueError(msg) + identifiers = [get_identifier(i) for i in episode_ids] + params: dict[str, Any] = {"ids": ",".join(identifiers)} + response = await self._get("v1/me/episodes/contains", params=params) + body: list[bool] = orjson.loads(response) # pylint: disable=no-member + return dict(zip(identifiers, body)) # Get genre seeds diff --git a/tests/__snapshots__/test_spotify.ambr b/tests/__snapshots__/test_spotify.ambr index 79d68c0..28c4799 100644 --- a/tests/__snapshots__/test_spotify.ambr +++ b/tests/__snapshots__/test_spotify.ambr @@ -6,6 +6,12 @@ '7iHfbu1YPACw6oZPAFJtqe': False, }) # --- +# name: test_check_saved_episodes + dict({ + '3o0RYoo5iOMKSmEbunsbvW': True, + '3o0RYoo5iOMKSmEbunsbvX': False, + }) +# --- # name: test_checking_saved_albums dict({ '1A2GTWGtFfWp7KSQTwWOyo': False, @@ -10426,6 +10432,71 @@ 'uri': 'spotify:episode:3o0RYoo5iOMKSmEbunsbvW', }) # --- +# name: test_get_episodes + list([ + dict({ + 'description': 'Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy', + 'duration_ms': 3690161, + 'episode_id': '3o0RYoo5iOMKSmEbunsbvW', + 'explicit': False, + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/episode/3o0RYoo5iOMKSmEbunsbvW', + }), + 'href': 'https://api.spotify.com/v1/episodes/3o0RYoo5iOMKSmEbunsbvW', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a', + 'width': 64, + }), + ]), + 'name': 'My Squirrel Has Brain Damage - Safety Third 119', + 'release_date': '2024-07-26', + 'release_date_precision': , + 'show': dict({ + 'description': 'Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube "Scientists". Sometimes we have guests, sometimes it\'s just us, but always: safety is our number three priority.', + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/show/1Y9ExMgMxoBVrgrfU7u0nD', + }), + 'href': 'https://api.spotify.com/v1/shows/1Y9ExMgMxoBVrgrfU7u0nD', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a', + 'width': 64, + }), + ]), + 'name': 'Safety Third', + 'publisher': 'Safety Third ', + 'show_id': '1Y9ExMgMxoBVrgrfU7u0nD', + 'total_episodes': 122, + 'uri': 'spotify:show:1Y9ExMgMxoBVrgrfU7u0nD', + }), + 'type': , + 'uri': 'spotify:episode:3o0RYoo5iOMKSmEbunsbvW', + }), + ]) +# --- # name: test_get_featured_playlists list([ dict({ @@ -19783,6 +19854,74 @@ }), ]) # --- +# name: test_get_saved_episodes + list([ + dict({ + 'added_at': datetime.datetime(2021, 4, 1, 23, 21, 46, tzinfo=datetime.timezone.utc), + 'episode': dict({ + 'description': "This week on the podcast Ed is joined by actor, writer and Series 10 contestant, Katherine Parkinson. As well as discussing the new line up and this week's tasks they go back to Series 10 and talk about Katherine's time on the show - expect chat about clay masks, marble runs, spiders and THAT fart noise. You can watch Series 11 of Taskmaster each Thursday on Channel 4 at 9pm.Watch Taskmaster Bleeped on All 4Get in touch with Ed and future guests:taskmasterpodcast@gmail.com\xa0Visit the Taskmaster Youtube channelwww.youtube.com/taskmaster\xa0For all your Taskmaster goodies visit\xa0www.taskmasterstore.com\xa0\xa0Taskmaster the podcast is produced by Daisy Knight for Avalon Television Ltd", + 'duration_ms': 3724303, + 'episode_id': '0x25dVaCtjWMmcjDJyuMM5', + 'explicit': True, + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/episode/0x25dVaCtjWMmcjDJyuMM5', + }), + 'href': 'https://api.spotify.com/v1/episodes/0x25dVaCtjWMmcjDJyuMM5', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8aee2ef0ad038698401b200131', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1fee2ef0ad038698401b200131', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68dee2ef0ad038698401b200131', + 'width': 64, + }), + ]), + 'name': 'Ep 26. Katherine Parkinson - S11 Ep.3', + 'release_date': '2021-04-01', + 'release_date_precision': , + 'show': dict({ + 'description': 'This is the official Taskmaster podcast, hosted by former champion and chickpea lover, Ed Gamble. Each week, released straight after the show is broadcast on Channel 4, Ed will be joined by a special guest to dissect and discuss the latest episode. Past contestants, little Alex Horne, and even the Taskmaster himself will feature in this brand-new podcast from the producers of the BAFTA-winning comedy show.', + 'external_urls': dict({ + 'spotify': 'https://open.spotify.com/show/4BZc9sOdNilJJ8irsuOzdg', + }), + 'href': 'https://api.spotify.com/v1/shows/4BZc9sOdNilJJ8irsuOzdg', + 'images': list([ + dict({ + 'height': 640, + 'url': 'https://i.scdn.co/image/ab6765630000ba8a940dfd502920d407436baca2', + 'width': 640, + }), + dict({ + 'height': 300, + 'url': 'https://i.scdn.co/image/ab67656300005f1f940dfd502920d407436baca2', + 'width': 300, + }), + dict({ + 'height': 64, + 'url': 'https://i.scdn.co/image/ab6765630000f68d940dfd502920d407436baca2', + 'width': 64, + }), + ]), + 'name': 'Taskmaster The Podcast', + 'publisher': 'Avalon ', + 'show_id': '4BZc9sOdNilJJ8irsuOzdg', + 'total_episodes': 200, + 'uri': 'spotify:show:4BZc9sOdNilJJ8irsuOzdg', + }), + 'type': , + 'uri': 'spotify:episode:0x25dVaCtjWMmcjDJyuMM5', + }), + }), + ]) +# --- # name: test_get_saved_shows list([ dict({ diff --git a/tests/fixtures/episode_saved.json b/tests/fixtures/episode_saved.json new file mode 100644 index 0000000..870284e --- /dev/null +++ b/tests/fixtures/episode_saved.json @@ -0,0 +1,4 @@ +[ + true, + false +] diff --git a/tests/fixtures/episodes.json b/tests/fixtures/episodes.json new file mode 100644 index 0000000..1667051 --- /dev/null +++ b/tests/fixtures/episodes.json @@ -0,0 +1,274 @@ +{ + "episodes": [ + null, + { + "audio_preview_url": "https://podz-content.spotifycdn.com/audio/clips/06lRxUmh8UNVTByuyxLYqh/clip_132296_192296.mp3", + "description": "Patreon: https://www.patreon.com/safetythirdMerch: https://safetythird.shopYouTube: https://www.youtube.com/@safetythird/Advertising Inquiries: https://redcircle.com/brandsPrivacy & Opt-Out: https://redcircle.com/privacy", + "duration_ms": 3690161, + "explicit": false, + "external_urls": { + "spotify": "https://open.spotify.com/episode/3o0RYoo5iOMKSmEbunsbvW" + }, + "href": "https://api.spotify.com/v1/episodes/3o0RYoo5iOMKSmEbunsbvW", + "html_description": "

Patreon: https://www.patreon.com/safetythird

Merch: https://safetythird.shop

YouTube: https://www.youtube.com/@safetythird/



Advertising Inquiries: https://redcircle.com/brands

Privacy & Opt-Out: https://redcircle.com/privacy", + "id": "3o0RYoo5iOMKSmEbunsbvW", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a", + "width": 64 + } + ], + "is_externally_hosted": false, + "is_playable": true, + "language": "en-US", + "languages": [ + "en-US" + ], + "name": "My Squirrel Has Brain Damage - Safety Third 119", + "release_date": "2024-07-26", + "release_date_precision": "day", + "resume_point": { + "fully_played": false, + "resume_position_ms": 90000 + }, + "show": { + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "PR", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "copyrights": [], + "description": "Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube \"Scientists\". Sometimes we have guests, sometimes it's just us, but always: safety is our number three priority.", + "explicit": true, + "external_urls": { + "spotify": "https://open.spotify.com/show/1Y9ExMgMxoBVrgrfU7u0nD" + }, + "href": "https://api.spotify.com/v1/shows/1Y9ExMgMxoBVrgrfU7u0nD", + "html_description": "

Safety Third is a weekly show hosted by William Osman, NileRed, The Backyard Scientist, Allen Pan, and a couple other YouTube "Scientists". Sometimes we have guests, sometimes it's just us, but always: safety is our number three priority.

", + "id": "1Y9ExMgMxoBVrgrfU7u0nD", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8ac7bedd27a4413b1abf926d8a", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1fc7bedd27a4413b1abf926d8a", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68dc7bedd27a4413b1abf926d8a", + "width": 64 + } + ], + "is_externally_hosted": false, + "languages": [ + "en-US" + ], + "media_type": "audio", + "name": "Safety Third", + "publisher": "Safety Third ", + "total_episodes": 122, + "type": "show", + "uri": "spotify:show:1Y9ExMgMxoBVrgrfU7u0nD" + }, + "type": "episode", + "uri": "spotify:episode:3o0RYoo5iOMKSmEbunsbvW" + } + ] +} diff --git a/tests/fixtures/saved_episodes.json b/tests/fixtures/saved_episodes.json new file mode 100644 index 0000000..1d7bb3c --- /dev/null +++ b/tests/fixtures/saved_episodes.json @@ -0,0 +1,334 @@ +{ + "href": "https://api.spotify.com/v1/me/episodes?offset=0&limit=20&locale=en-US,en;q%3D0.5", + "limit": 20, + "next": null, + "offset": 0, + "previous": null, + "total": 2, + "items": [ + { + "added_at": "2024-06-07T15:08:30Z", + "episode": { + "audio_preview_url": null, + "description": null, + "duration_ms": null, + "explicit": false, + "external_urls": { + "spotify": "https://open.spotify.com/episode/null" + }, + "href": "https://api.spotify.com/v1/episodes/null", + "html_description": null, + "id": null, + "images": [], + "is_externally_hosted": null, + "language": null, + "languages": [ + null + ], + "name": null, + "release_date": "0000", + "release_date_precision": "year", + "resume_point": { + "fully_played": false, + "resume_position_ms": null + }, + "show": { + "copyrights": null, + "description": null, + "explicit": false, + "external_urls": { + "spotify": "https://open.spotify.com/show/null" + }, + "href": "https://api.spotify.com/v1/shows/null", + "html_description": null, + "id": null, + "images": [], + "is_externally_hosted": null, + "languages": [ + null + ], + "media_type": null, + "name": null, + "publisher": null, + "total_episodes": null, + "type": "show", + "uri": null + }, + "type": "episode", + "uri": null + } + }, + { + "added_at": "2021-04-01T23:21:46Z", + "episode": { + "audio_preview_url": "https://podz-content.spotifycdn.com/audio/clips/3MPB6hmgIONz91Em6JfkAK/clip_0_60000.mp3", + "description": "This week on the podcast Ed is joined by actor, writer and Series 10 contestant, Katherine Parkinson. As well as discussing the new line up and this week's tasks they go back to Series 10 and talk about Katherine's time on the show - expect chat about clay masks, marble runs, spiders and THAT fart noise. You can watch Series 11 of Taskmaster each Thursday on Channel 4 at 9pm.Watch Taskmaster Bleeped on All 4Get in touch with Ed and future guests:taskmasterpodcast@gmail.com Visit the Taskmaster Youtube channelwww.youtube.com/taskmaster For all your Taskmaster goodies visit www.taskmasterstore.com  Taskmaster the podcast is produced by Daisy Knight for Avalon Television Ltd", + "duration_ms": 3724303, + "explicit": true, + "external_urls": { + "spotify": "https://open.spotify.com/episode/0x25dVaCtjWMmcjDJyuMM5" + }, + "href": "https://api.spotify.com/v1/episodes/0x25dVaCtjWMmcjDJyuMM5", + "html_description": "

This week on the podcast Ed is joined by actor, writer and Series 10 contestant, Katherine Parkinson. As well as discussing the new line up and this week's tasks they go back to Series 10 and talk about Katherine's time on the show - expect chat about clay masks, marble runs, spiders and THAT fart noise.


You can watch Series 11 of Taskmaster each Thursday on Channel 4 at 9pm.


Watch Taskmaster Bleeped on All 4


Get in touch with Ed and future guests:

taskmasterpodcast@gmail.com 


Visit the Taskmaster Youtube channel

www.youtube.com/taskmaster 


For all your Taskmaster goodies visit 

www.taskmasterstore.com  


Taskmaster the podcast is produced by Daisy Knight for Avalon Television Ltd

", + "id": "0x25dVaCtjWMmcjDJyuMM5", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8aee2ef0ad038698401b200131", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1fee2ef0ad038698401b200131", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68dee2ef0ad038698401b200131", + "width": 64 + } + ], + "is_externally_hosted": false, + "is_playable": true, + "language": "en", + "languages": [ + "en" + ], + "name": "Ep 26. Katherine Parkinson - S11 Ep.3", + "release_date": "2021-04-01", + "release_date_precision": "day", + "resume_point": { + "fully_played": true, + "resume_position_ms": 301000 + }, + "show": { + "available_markets": [ + "AR", + "AU", + "AT", + "BE", + "BO", + "BR", + "BG", + "CA", + "CL", + "CO", + "CR", + "CY", + "CZ", + "DK", + "DO", + "DE", + "EC", + "EE", + "SV", + "FI", + "FR", + "GR", + "GT", + "HN", + "HK", + "HU", + "IS", + "IE", + "IT", + "LV", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NI", + "NO", + "PA", + "PY", + "PE", + "PH", + "PL", + "PT", + "SG", + "SK", + "ES", + "SE", + "CH", + "TW", + "TR", + "UY", + "US", + "GB", + "AD", + "LI", + "MC", + "ID", + "JP", + "TH", + "VN", + "RO", + "IL", + "ZA", + "SA", + "AE", + "BH", + "QA", + "OM", + "KW", + "EG", + "MA", + "DZ", + "TN", + "LB", + "JO", + "PS", + "IN", + "BY", + "KZ", + "MD", + "UA", + "AL", + "BA", + "HR", + "ME", + "MK", + "RS", + "SI", + "KR", + "BD", + "PK", + "LK", + "GH", + "KE", + "NG", + "TZ", + "UG", + "AG", + "AM", + "BS", + "BB", + "BZ", + "BT", + "BW", + "BF", + "CV", + "CW", + "DM", + "FJ", + "GM", + "GE", + "GD", + "GW", + "GY", + "HT", + "JM", + "KI", + "LS", + "LR", + "MW", + "MV", + "ML", + "MH", + "FM", + "NA", + "NR", + "NE", + "PW", + "PG", + "PR", + "WS", + "SM", + "ST", + "SN", + "SC", + "SL", + "SB", + "KN", + "LC", + "VC", + "SR", + "TL", + "TO", + "TT", + "TV", + "VU", + "AZ", + "BN", + "BI", + "KH", + "CM", + "TD", + "KM", + "GQ", + "SZ", + "GA", + "GN", + "KG", + "LA", + "MO", + "MR", + "MN", + "NP", + "RW", + "TG", + "UZ", + "ZW", + "BJ", + "MG", + "MU", + "MZ", + "AO", + "CI", + "DJ", + "ZM", + "CD", + "CG", + "IQ", + "LY", + "TJ", + "VE", + "ET", + "XK" + ], + "copyrights": [], + "description": "This is the official Taskmaster podcast, hosted by former champion and chickpea lover, Ed Gamble. Each week, released straight after the show is broadcast on Channel 4, Ed will be joined by a special guest to dissect and discuss the latest episode. Past contestants, little Alex Horne, and even the Taskmaster himself will feature in this brand-new podcast from the producers of the BAFTA-winning comedy show.", + "explicit": true, + "external_urls": { + "spotify": "https://open.spotify.com/show/4BZc9sOdNilJJ8irsuOzdg" + }, + "href": "https://api.spotify.com/v1/shows/4BZc9sOdNilJJ8irsuOzdg", + "html_description": "This is the official Taskmaster podcast, hosted by former champion and chickpea lover, Ed Gamble. Each week, released straight after the show is broadcast on Channel 4, Ed will be joined by a special guest to dissect and discuss the latest episode. Past contestants, little Alex Horne, and even the Taskmaster himself will feature in this brand-new podcast from the producers of the BAFTA-winning comedy show.", + "id": "4BZc9sOdNilJJ8irsuOzdg", + "images": [ + { + "height": 640, + "url": "https://i.scdn.co/image/ab6765630000ba8a940dfd502920d407436baca2", + "width": 640 + }, + { + "height": 300, + "url": "https://i.scdn.co/image/ab67656300005f1f940dfd502920d407436baca2", + "width": 300 + }, + { + "height": 64, + "url": "https://i.scdn.co/image/ab6765630000f68d940dfd502920d407436baca2", + "width": 64 + } + ], + "is_externally_hosted": false, + "languages": [ + "en" + ], + "media_type": "audio", + "name": "Taskmaster The Podcast", + "publisher": "Avalon ", + "total_episodes": 200, + "type": "show", + "uri": "spotify:show:4BZc9sOdNilJJ8irsuOzdg" + }, + "type": "episode", + "uri": "spotify:episode:0x25dVaCtjWMmcjDJyuMM5" + } + } + ] +} diff --git a/tests/test_spotify.py b/tests/test_spotify.py index bb42645..6a7f991 100644 --- a/tests/test_spotify.py +++ b/tests/test_spotify.py @@ -1701,6 +1701,198 @@ async def test_get_too_many_chapters( responses.assert_not_called() # type: ignore[no-untyped-call] +async def test_get_episodes( + responses: aioresponses, + snapshot: SnapshotAssertion, + authenticated_client: SpotifyClient, +) -> None: + """Test retrieving episodes.""" + responses.get( + f"{SPOTIFY_URL}/v1/episodes?ids=3o0RYoo5iOMKSmEbunsbvW%2C3o0RYoo5iOMKSmEbunsbvW", + status=200, + body=load_fixture("episodes.json"), + ) + response = await authenticated_client.get_episodes( + ["3o0RYoo5iOMKSmEbunsbvW", "3o0RYoo5iOMKSmEbunsbvW"] + ) + assert response == snapshot + responses.assert_called_once_with( + f"{SPOTIFY_URL}/v1/episodes", + METH_GET, + headers=HEADERS, + params={"ids": "3o0RYoo5iOMKSmEbunsbvW,3o0RYoo5iOMKSmEbunsbvW"}, + json=None, + ) + + +async def test_get_no_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test retrieving no episodes.""" + assert await authenticated_client.get_episodes([]) == [] + responses.assert_not_called() # type: ignore[no-untyped-call] + + +async def test_get_too_many_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test retrieving too many episodes.""" + with pytest.raises( + ValueError, match="Maximum of 50 episodes can be requested at once" + ): + await authenticated_client.get_episodes(["abc"] * 51) + responses.assert_not_called() # type: ignore[no-untyped-call] + + +async def test_get_saved_episodes( + responses: aioresponses, + snapshot: SnapshotAssertion, + authenticated_client: SpotifyClient, +) -> None: + """Test retrieving saved episodes.""" + responses.get( + f"{SPOTIFY_URL}/v1/me/episodes?limit=48", + status=200, + body=load_fixture("saved_episodes.json"), + ) + response = await authenticated_client.get_saved_episodes() + assert response == snapshot + responses.assert_called_once_with( + f"{SPOTIFY_URL}/v1/me/episodes", + METH_GET, + headers=HEADERS, + params={"limit": 48}, + json=None, + ) + + +async def test_save_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test saving episodes.""" + responses.put( + f"{SPOTIFY_URL}/v1/me/episodes?ids=3o0RYoo5iOMKSmEbunsbvW", + status=200, + body="", + ) + await authenticated_client.save_episodes(["3o0RYoo5iOMKSmEbunsbvW"]) + responses.assert_called_once_with( + f"{SPOTIFY_URL}/v1/me/episodes", + METH_PUT, + headers=HEADERS, + params={"ids": "3o0RYoo5iOMKSmEbunsbvW"}, + json=None, + ) + + +async def test_save_no_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test saving no episodes.""" + await authenticated_client.save_episodes([]) + responses.assert_not_called() # type: ignore[no-untyped-call] + + +async def test_save_too_many_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test saving too many episodes.""" + with pytest.raises(ValueError, match="Maximum of 50 episodes can be saved at once"): + await authenticated_client.save_episodes(["abc"] * 51) + responses.assert_not_called() # type: ignore[no-untyped-call] + + +async def test_remove_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test removing episodes.""" + responses.delete( + f"{SPOTIFY_URL}/v1/me/episodes?ids=3o0RYoo5iOMKSmEbunsbvW", + status=200, + body="", + ) + await authenticated_client.remove_saved_episodes(["3o0RYoo5iOMKSmEbunsbvW"]) + responses.assert_called_once_with( + f"{SPOTIFY_URL}/v1/me/episodes", + METH_DELETE, + headers=HEADERS, + params={"ids": "3o0RYoo5iOMKSmEbunsbvW"}, + json=None, + ) + + +async def test_remove_no_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test removing no episodes.""" + await authenticated_client.remove_saved_episodes([]) + responses.assert_not_called() # type: ignore[no-untyped-call] + + +async def test_remove_too_many_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test removing too many episodes.""" + with pytest.raises( + ValueError, match="Maximum of 50 episodes can be removed at once" + ): + await authenticated_client.remove_saved_episodes(["abc"] * 51) + responses.assert_not_called() # type: ignore[no-untyped-call] + + +async def test_check_saved_episodes( + responses: aioresponses, + snapshot: SnapshotAssertion, + authenticated_client: SpotifyClient, +) -> None: + """Test checking saved episodes.""" + responses.get( + f"{SPOTIFY_URL}/v1/me/episodes/contains?ids=3o0RYoo5iOMKSmEbunsbvW%2C3o0RYoo5iOMKSmEbunsbvX", + status=200, + body=load_fixture("episode_saved.json"), + ) + response = await authenticated_client.are_episodes_saved( + ["3o0RYoo5iOMKSmEbunsbvW", "3o0RYoo5iOMKSmEbunsbvX"] + ) + assert response == snapshot + responses.assert_called_once_with( + f"{SPOTIFY_URL}/v1/me/episodes/contains", + METH_GET, + headers=HEADERS, + params={"ids": "3o0RYoo5iOMKSmEbunsbvW,3o0RYoo5iOMKSmEbunsbvX"}, + json=None, + ) + + +async def test_check_no_saved_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test checking no saved episodes.""" + assert await authenticated_client.are_episodes_saved([]) == {} + responses.assert_not_called() # type: ignore[no-untyped-call] + + +async def test_check_too_many_saved_episodes( + responses: aioresponses, + authenticated_client: SpotifyClient, +) -> None: + """Test checking too many saved episodes.""" + with pytest.raises( + ValueError, match="Maximum of 50 episodes can be checked at once" + ): + await authenticated_client.are_episodes_saved(["abc"] * 51) + responses.assert_not_called() # type: ignore[no-untyped-call] + + async def test_get_audio_features( responses: aioresponses, snapshot: SnapshotAssertion,