From 1af31525abcbf416d0946059cd505747f72657c1 Mon Sep 17 00:00:00 2001 From: felix Date: Thu, 2 Apr 2020 15:39:54 +0300 Subject: [PATCH] Podcast endpoints, library, search and player --- tekore/client/__init__.py | 12 ++- tekore/client/api/__init__.py | 2 + tekore/client/api/episode.py | 55 ++++++++++++++ tekore/client/api/library.py | 84 ++++++++++++++++++++- tekore/client/api/player/modify.py | 6 +- tekore/client/api/player/view.py | 34 +++++++-- tekore/client/api/search.py | 16 ++-- tekore/client/api/show.py | 101 ++++++++++++++++++++++++++ tekore/client/full.py | 4 + tekore/model/__init__.py | 28 ++++++- tekore/model/album/base.py | 8 +- tekore/model/context.py | 1 + tekore/model/currently_playing.py | 13 +++- tekore/model/episode.py | 67 +++++++++++++++++ tekore/model/member.py | 8 +- tekore/model/show/__init__.py | 37 ++++++++++ tekore/model/show/base.py | 24 ++++++ tekore/model/show/full.py | 14 ++++ tekore/scope.py | 2 + tests/client/_resources.py | 5 ++ tests/client/episode.py | 39 ++++++++++ tests/client/library.py | 113 ++++++++++++++++++----------- tests/client/player.py | 14 +++- tests/client/search.py | 6 ++ tests/client/show.py | 35 +++++++++ 25 files changed, 653 insertions(+), 75 deletions(-) create mode 100644 tekore/client/api/episode.py create mode 100644 tekore/client/api/show.py create mode 100644 tekore/model/episode.py create mode 100644 tekore/model/show/__init__.py create mode 100644 tekore/model/show/base.py create mode 100644 tekore/model/show/full.py create mode 100644 tests/client/episode.py create mode 100644 tests/client/show.py diff --git a/tekore/client/__init__.py b/tekore/client/__init__.py index b46458b1..d060d661 100644 --- a/tekore/client/__init__.py +++ b/tekore/client/__init__.py @@ -51,7 +51,11 @@ ---------- .. autoclass:: SpotifyBrowse :members: - :inherited-members: + +Episode API +----------- +.. autoclass:: SpotifyEpisode + :members: Follow API ---------- @@ -85,6 +89,12 @@ .. autoclass:: SpotifySearch :members: +Show API +-------- +.. autoclass:: SpotifyShow + :members: + + Track API --------- .. autoclass:: SpotifyTrack diff --git a/tekore/client/api/__init__.py b/tekore/client/api/__init__.py index c8a7ceeb..f3c86940 100644 --- a/tekore/client/api/__init__.py +++ b/tekore/client/api/__init__.py @@ -1,11 +1,13 @@ from tekore.client.api.album import SpotifyAlbum from tekore.client.api.artist import SpotifyArtist from tekore.client.api.browse import SpotifyBrowse +from tekore.client.api.episode import SpotifyEpisode from tekore.client.api.follow import SpotifyFollow from tekore.client.api.library import SpotifyLibrary from tekore.client.api.personalisation import SpotifyPersonalisation from tekore.client.api.player import SpotifyPlayer from tekore.client.api.playlist import SpotifyPlaylist from tekore.client.api.search import SpotifySearch +from tekore.client.api.show import SpotifyShow from tekore.client.api.track import SpotifyTrack from tekore.client.api.user import SpotifyUser diff --git a/tekore/client/api/episode.py b/tekore/client/api/episode.py new file mode 100644 index 00000000..6493aa72 --- /dev/null +++ b/tekore/client/api/episode.py @@ -0,0 +1,55 @@ +from tekore.client.process import single, model_list +from tekore.client.chunked import chunked, join_lists +from tekore.client.decor import send_and_process +from tekore.client.base import SpotifyBase +from tekore.serialise import ModelList +from tekore.model import FullEpisode + + +class SpotifyEpisode(SpotifyBase): + @send_and_process(single(FullEpisode)) + def episode( + self, + episode_id: str, + market: str = None + ) -> FullEpisode: + """ + Get information for an episode. + + Parameters + ---------- + episode_id + episode ID + market + an ISO 3166-1 alpha-2 country code + + Returns + ------- + FullEpisode + episode object + """ + return self._get('episodes/' + episode_id, market=market) + + @chunked('episode_ids', 1, 50, join_lists) + @send_and_process(model_list(FullEpisode, 'episodes')) + def episodes( + self, + episode_ids: list, + market: str = None + ) -> ModelList: + """ + Get information for multiple episodes. + + Parameters + ---------- + episode_ids + the episode IDs, max 50 without chunking + market + an ISO 3166-1 alpha-2 country code + + Returns + ------- + ModelList + list of episode objects + """ + return self._get('episodes/?ids=' + ','.join(episode_ids), market=market) diff --git a/tekore/client/api/library.py b/tekore/client/api/library.py index a31555e6..a98ba57a 100644 --- a/tekore/client/api/library.py +++ b/tekore/client/api/library.py @@ -4,7 +4,7 @@ from tekore.client.chunked import chunked, join_lists, return_none from tekore.client.decor import send_and_process, maximise_limit from tekore.client.base import SpotifyBase -from tekore.model import SavedAlbumPaging, SavedTrackPaging +from tekore.model import SavedAlbumPaging, SavedTrackPaging, SavedShowPaging class SpotifyLibrary(SpotifyBase): @@ -165,3 +165,85 @@ def saved_tracks_delete(self, track_ids: list) -> None: list of track IDs, max 50 without chunking """ return self._delete('me/tracks/?ids=' + ','.join(track_ids)) + + @send_and_process(single(SavedShowPaging)) + @maximise_limit(50) + def saved_shows( + self, + market: str = None, + limit: int = 20, + offset: int = 0 + ) -> SavedShowPaging: + """ + Get a list of the shows saved in the current user's Your Music library. + + Requires the user-library-read scope. + + Parameters + ---------- + market + an ISO 3166-1 alpha-2 country code or 'from_token' + limit + the number of items to return (1..50) + offset + the index of the first item to return + + Returns + ------- + SavedShowPaging + paging object containing saved shows + """ + return self._get('me/shows', market=market, limit=limit, offset=offset) + + @chunked('show_ids', 1, 50, join_lists) + @send_and_process(nothing) + def saved_shows_contains(self, show_ids: list) -> List[bool]: + """ + Check if user has saved shows. + + Requires the user-library-read scope. + + Parameters + ---------- + show_ids + list of show IDs, max 50 without chunking + + Returns + ------- + list + list of booleans in the same order the show IDs were given + """ + return self._get('me/shows/contains?ids=' + ','.join(show_ids)) + + @chunked('show_ids', 1, 50, return_none) + @send_and_process(nothing) + def saved_shows_add(self, show_ids: list) -> None: + """ + Save shows for current user. + + Requires the user-library-modify scope. + + Parameters + ---------- + show_ids + list of show IDs, max 50 without chunking + """ + return self._put('me/shows/?ids=' + ','.join(show_ids)) + + @chunked('show_ids', 1, 50, return_none) + @send_and_process(nothing) + def saved_shows_delete(self, show_ids: list, market: str = None) -> None: + """ + Remove shows for current user. + + Requires the user-library-modify scope. + + Parameters + ---------- + show_ids + list of show IDs, max 50 without chunking + market + an ISO 3166-1 alpha-2 country code, only remove shows that are + available in the specified market, overrided by token's country + """ + return self._delete('me/shows/?ids=' + ','.join(show_ids), market=market) diff --git a/tekore/client/api/player/modify.py b/tekore/client/api/player/modify.py index 481e4884..e80a7b09 100644 --- a/tekore/client/api/player/modify.py +++ b/tekore/client/api/player/modify.py @@ -125,16 +125,16 @@ def playback_start_context( @send_and_process(nothing) def playback_queue_add(self, uri: str, device_id: str = None) -> None: """ - Add a track to a user's queue. + Add a track or an episode to a user's queue. Requires the user-modify-playback-state scope. Parameters ---------- uri - resource to add, currently only tracks are supported + resource to add, track or episode device_id - devide to add the track on + devide to extend the queue on """ return self._post('me/player/queue', uri=uri, device_id=device_id) diff --git a/tekore/client/api/player/view.py b/tekore/client/api/player/view.py index a87f29ce..fb8e64b9 100644 --- a/tekore/client/api/player/view.py +++ b/tekore/client/api/player/view.py @@ -16,47 +16,67 @@ class SpotifyPlayerView(SpotifyBase): @send_and_process(single(CurrentlyPlayingContext)) def playback( self, - market: str = None + market: str = None, + additional_types: List[str] = None ) -> CurrentlyPlayingContext: """ Get information about user's current playback. - Requires the user-read-playback-state or - the user-read-currently-playing scope. + Requires the user-read-playback-state scope. Parameters ---------- market an ISO 3166-1 alpha-2 country code or 'from_token' + additional_types + item types to return, valid types are 'track' and 'episode', + types not specified are returned as None values Returns ------- CurrentlyPlayingContext information about current playback """ - return self._get('me/player', market=market) + if additional_types is not None: + additional_types = ','.join(additional_types) + return self._get( + 'me/player', + market=market, + additional_types=additional_types + ) @send_and_process(single(CurrentlyPlaying)) def playback_currently_playing( self, - market: str = None + market: str = None, + additional_types: List[str] = None ) -> CurrentlyPlaying: """ Get user's currently playing track. - Requires the user-read-playback-state scope. + Requires the user-read-playback-state or + the user-read-currently-playing scope. Parameters ---------- market an ISO 3166-1 alpha-2 country code or 'from_token' + additional_types + item types to return, valid types are 'track' and 'episode', + types not specified are returned as None values Returns ------- CurrentlyPlaying information about the current track playing """ - return self._get('me/player/currently-playing', market=market) + if additional_types is not None: + additional_types = ','.join(additional_types) + return self._get( + 'me/player/currently-playing', + market=market, + additional_types=additional_types + ) @send_and_process(single(PlayHistoryPaging)) @maximise_limit(50) diff --git a/tekore/client/api/search.py b/tekore/client/api/search.py index f034ac4b..8072656c 100644 --- a/tekore/client/api/search.py +++ b/tekore/client/api/search.py @@ -5,13 +5,17 @@ FullTrackPaging, SimpleAlbumPaging, SimplePlaylistPaging, + SimpleEpisodePaging, + SimpleShowPaging, ) paging_type = { - 'artist': FullArtistOffsetPaging, - 'album': SimpleAlbumPaging, - 'playlist': SimplePlaylistPaging, - 'track': FullTrackPaging, + 'artists': FullArtistOffsetPaging, + 'albums': SimpleAlbumPaging, + 'episodes': SimpleEpisodePaging, + 'playlists': SimplePlaylistPaging, + 'shows': SimpleShowPaging, + 'tracks': FullTrackPaging, } @@ -19,7 +23,7 @@ def search_result(json: dict): """ Unpack search result dicts into respective paging type constructors. """ - return tuple(paging_type[key[:-1]](**json[key]) for key in json.keys()) + return tuple(paging_type[key](**json[key]) for key in json.keys()) class SpotifySearch(SpotifyBase): @@ -46,7 +50,7 @@ def search( search query types resources to search for, tuple of strings containing - artist, album, track and/or playlist + artist, album, track, playlist, show and/or episode market an ISO 3166-1 alpha-2 country code or 'from_token' limit diff --git a/tekore/client/api/show.py b/tekore/client/api/show.py new file mode 100644 index 00000000..1e223878 --- /dev/null +++ b/tekore/client/api/show.py @@ -0,0 +1,101 @@ +from tekore.client.process import single, model_list +from tekore.client.chunked import chunked, join_lists +from tekore.client.decor import send_and_process, maximise_limit +from tekore.client.base import SpotifyBase +from tekore.serialise import ModelList +from tekore.model import FullShow, SimpleEpisodePaging + + +class SpotifyShow(SpotifyBase): + """ + Market parameters don't accept from_token, + instead token country overriders market. + If neither is specified, the show and episodes are considered unavailable. + """ + @send_and_process(single(FullShow)) + def show( + self, + show_id: str, + market: str = None + ) -> FullShow: + """ + Get information for a show. + + Reading the user's episode resume points requires + the user-read-playback-position scope. + + Parameters + ---------- + show_id + show ID + market + an ISO 3166-1 alpha-2 country code + + Returns + ------- + FullShow + show object + """ + return self._get('shows/' + show_id, market=market) + + @chunked('show_ids', 1, 50, join_lists) + @send_and_process(model_list(FullShow, 'shows')) + def shows( + self, + show_ids: list, + market: str = None + ) -> ModelList: + """ + Get information for multiple shows. + + Reading the user's episode resume points requires + the user-read-playback-position scope. + + Parameters + ---------- + show_ids + the show IDs, max 50 without chunking + market + an ISO 3166-1 alpha-2 country code + + Returns + ------- + ModelList + list of show objects + """ + return self._get('shows/?ids=' + ','.join(show_ids), market=market) + + @send_and_process(single(SimpleEpisodePaging)) + @maximise_limit(50) + def show_episodes( + self, + show_id: str, + market: str = None, + limit: int = 20, + offset: int = 0 + ) -> SimpleEpisodePaging: + """ + Get episodes of a show. + + Parameters + ---------- + show_id + show ID + market + an ISO 3166-1 alpha-2 country code + limit + the number of items to return (1..50) + offset + the index of the first item to return + + Returns + ------- + SimpleEpisodePaging + paging containing simplified episode objects + """ + return self._get( + f'shows/{show_id}/episodes', + market=market, + limit=limit, + offset=offset + ) diff --git a/tekore/client/full.py b/tekore/client/full.py index 064c9322..65e2417e 100644 --- a/tekore/client/full.py +++ b/tekore/client/full.py @@ -5,12 +5,14 @@ SpotifyAlbum, SpotifyArtist, SpotifyBrowse, + SpotifyEpisode, SpotifyFollow, SpotifyLibrary, SpotifyPersonalisation, SpotifyPlayer, SpotifyPlaylist, SpotifySearch, + SpotifyShow, SpotifyTrack, SpotifyUser, ) @@ -20,12 +22,14 @@ class Spotify( SpotifyAlbum, SpotifyArtist, SpotifyBrowse, + SpotifyEpisode, SpotifyFollow, SpotifyLibrary, SpotifyPersonalisation, SpotifyPlayer, SpotifyPlaylist, SpotifySearch, + SpotifyShow, SpotifyTrack, SpotifyUser, SpotifyPaging, diff --git a/tekore/model/__init__.py b/tekore/model/__init__.py index 985f63b7..e08a48ae 100644 --- a/tekore/model/__init__.py +++ b/tekore/model/__init__.py @@ -53,6 +53,13 @@ :undoc-members: :show-inheritance: +Episode-related +--------------- +.. automodule:: tekore.model.episode + :members: + :undoc-members: + :show-inheritance: + Errors ------ .. automodule:: tekore.model.error @@ -69,7 +76,6 @@ Playback-related ---------------- - Currently playing ***************** .. automodule:: tekore.model.currently_playing @@ -106,6 +112,23 @@ :undoc-members: :show-inheritance: +Show-related +------------ +.. automodule:: tekore.model.show.base + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: tekore.model.show + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: tekore.model.show.full + :members: + :undoc-members: + :show-inheritance: + Track-related ------------- .. automodule:: tekore.model.track @@ -175,6 +198,7 @@ RepeatState, ) from tekore.model.device import Device +from tekore.model.episode import SimpleEpisodePaging, FullEpisode from tekore.model.error import PlayerErrorReason from tekore.model.member import Image from tekore.model.play_history import PlayHistoryPaging @@ -184,6 +208,8 @@ SimplePlaylistPaging, ) from tekore.model.recommendations import Recommendations, RecommendationAttribute +from tekore.model.show import SavedShowPaging, SimpleShowPaging +from tekore.model.show.full import FullShow from tekore.model.track import ( FullTrack, SimpleTrackPaging, diff --git a/tekore/model/album/base.py b/tekore/model/album/base.py index e8788a29..d04280cc 100644 --- a/tekore/model/album/base.py +++ b/tekore/model/album/base.py @@ -4,7 +4,7 @@ from tekore.serialise import SerialisableEnum from tekore.model.base import Item from tekore.model.artist import SimpleArtist -from tekore.model.member import Image +from tekore.model.member import Image, ReleaseDatePrecision class AlbumType(SerialisableEnum): @@ -13,12 +13,6 @@ class AlbumType(SerialisableEnum): single = 'single' -class ReleaseDatePrecision(SerialisableEnum): - year = 'year' - month = 'month' - day = 'day' - - @dataclass(repr=False) class Album(Item): album_type: AlbumType diff --git a/tekore/model/context.py b/tekore/model/context.py index 9853e8c8..70ceff09 100644 --- a/tekore/model/context.py +++ b/tekore/model/context.py @@ -6,6 +6,7 @@ class ContextType(SerialisableEnum): album = 'album' artist = 'artist' playlist = 'playlist' + show = 'show' @dataclass(repr=False) diff --git a/tekore/model/currently_playing.py b/tekore/model/currently_playing.py index 5abb4bde..0dffd0b6 100644 --- a/tekore/model/currently_playing.py +++ b/tekore/model/currently_playing.py @@ -1,10 +1,11 @@ -from typing import Optional +from typing import Optional, Union from dataclasses import dataclass from tekore.serialise import SerialisableDataclass, SerialisableEnum from tekore.model.context import Context from tekore.model.device import Device from tekore.model.track import FullTrack +from tekore.model.episode import FullEpisode class CurrentlyPlayingType(SerialisableEnum): @@ -42,6 +43,12 @@ def __post_init__(self): self.disallows = Disallows(**self.disallows) +item_type = { + 'track': FullTrack, + 'episode': FullEpisode, +} + + @dataclass(repr=False) class CurrentlyPlaying(SerialisableDataclass): """ @@ -53,7 +60,7 @@ class CurrentlyPlaying(SerialisableDataclass): timestamp: int context: Optional[Context] progress_ms: Optional[int] - item: Optional[FullTrack] + item: Union[FullTrack, FullEpisode, None] def __post_init__(self): self.actions = Actions(**self.actions) @@ -64,7 +71,7 @@ def __post_init__(self): if self.context is not None: self.context = Context(**self.context) if self.item is not None: - self.item = FullTrack(**self.item) + self.item = item_type[self.item['type']](**self.item) @dataclass(repr=False) diff --git a/tekore/model/episode.py b/tekore/model/episode.py new file mode 100644 index 00000000..9244fb11 --- /dev/null +++ b/tekore/model/episode.py @@ -0,0 +1,67 @@ +from typing import List +from dataclasses import dataclass + +from tekore.model.base import Item +from tekore.model.paging import OffsetPaging +from tekore.model.member import Image, ReleaseDatePrecision +from tekore.model.show import SimpleShow +from tekore.serialise import SerialisableDataclass + + +@dataclass(repr=False) +class ResumePoint(SerialisableDataclass): + fully_played: bool + resume_position_ms: int + + +@dataclass(repr=False) +class Episode(Item): + audio_preview_url: str + description: str + duration_ms: int + explicit: bool + external_urls: dict + images: List[Image] + is_externally_hosted: bool + is_playable: bool + language: str + languages: List[str] + name: str + release_date: str + release_date_precision: ReleaseDatePrecision + + def __post_init__(self): + self.images = [Image(**i) for i in self.images] + self.release_date_precision = ReleaseDatePrecision[ + self.release_date_precision + ] + + +@dataclass(repr=False) +class SimpleEpisode(Episode): + resume_point: ResumePoint = None + + def __post_init__(self): + super().__post_init__() + if self.resume_point is not None: + self.resume_point = ResumePoint(**self.resume_point) + + +@dataclass(repr=False) +class FullEpisode(Episode): + show: SimpleShow + resume_point: ResumePoint = None + + def __post_init__(self): + super().__post_init__() + self.show = SimpleShow(**self.show) + if self.resume_point is not None: + self.resume_point = ResumePoint(**self.resume_point) + + +@dataclass(repr=False) +class SimpleEpisodePaging(OffsetPaging): + items = List[SimpleEpisode] + + def __post_init__(self): + self.items = [SimpleEpisode(**i) for i in self.items] diff --git a/tekore/model/member.py b/tekore/model/member.py index db92dd39..2180de50 100644 --- a/tekore/model/member.py +++ b/tekore/model/member.py @@ -1,5 +1,11 @@ from dataclasses import dataclass -from tekore.serialise import SerialisableDataclass +from tekore.serialise import SerialisableDataclass, SerialisableEnum + + +class ReleaseDatePrecision(SerialisableEnum): + year = 'year' + month = 'month' + day = 'day' @dataclass(repr=False) diff --git a/tekore/model/show/__init__.py b/tekore/model/show/__init__.py new file mode 100644 index 00000000..e8473611 --- /dev/null +++ b/tekore/model/show/__init__.py @@ -0,0 +1,37 @@ +from typing import List +from dataclasses import dataclass + +from tekore.model.show.base import Show +from tekore.model.paging import OffsetPaging +from tekore.serialise import SerialisableDataclass, Timestamp + + +@dataclass(repr=False) +class SimpleShow(Show): + pass + + +@dataclass(repr=False) +class SimpleShowPaging(OffsetPaging): + items: List[SimpleShow] + + def __post_init__(self): + self.items = [SimpleShow(**i) for i in self.items] + + +@dataclass(repr=False) +class SavedShow(SerialisableDataclass): + added_at: Timestamp + show: SimpleShow + + def __post_init__(self): + self.added_at = Timestamp.from_string(self.added_at) + self.show = SimpleShow(**self.show) + + +@dataclass(repr=False) +class SavedShowPaging(OffsetPaging): + items: List[SavedShow] + + def __post_init__(self): + self.items = [SavedShow(**i) for i in self.items] diff --git a/tekore/model/show/base.py b/tekore/model/show/base.py new file mode 100644 index 00000000..2082710b --- /dev/null +++ b/tekore/model/show/base.py @@ -0,0 +1,24 @@ +from typing import List +from dataclasses import dataclass + +from tekore.model.base import Item +from tekore.model.member import Copyright, Image + + +@dataclass(repr=False) +class Show(Item): + available_markets: List[str] + copyrights: List[Copyright] + description: str + explicit: bool + external_urls: dict + images: List[Image] + is_externally_hosted: bool + languages: List[str] + media_type: str + name: str + publisher: str + + def __post_init__(self): + self.copyrights = [Copyright(**c) for c in self.copyrights] + self.images = [Image(**i) for i in self.images] diff --git a/tekore/model/show/full.py b/tekore/model/show/full.py new file mode 100644 index 00000000..3eb89a93 --- /dev/null +++ b/tekore/model/show/full.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from tekore.model.show import Show +from tekore.model.episode import SimpleEpisodePaging + + +@dataclass(repr=False) +class FullShow(Show): + episodes: SimpleEpisodePaging = None + + def __post_init__(self): + super().__post_init__() + if self.episodes is not None: + self.episodes = SimpleEpisodePaging(**self.episodes) diff --git a/tekore/scope.py b/tekore/scope.py index 8aa2677c..90369a57 100644 --- a/tekore/scope.py +++ b/tekore/scope.py @@ -61,6 +61,7 @@ class AuthorisationScopes(Enum): user_read_currently_playing = 'user-read-currently-playing' user_read_playback_state = 'user-read-playback-state' + user_read_playback_position = 'user-read-playback-position' user_modify_playback_state = 'user-modify-playback-state' playlist_modify_public = 'playlist-modify-public' @@ -160,6 +161,7 @@ def __rsub__(self, other) -> 'Scope': AuthorisationScopes.user_library_read, AuthorisationScopes.user_read_currently_playing, AuthorisationScopes.user_read_playback_state, + AuthorisationScopes.user_read_playback_position, AuthorisationScopes.playlist_read_collaborative, AuthorisationScopes.playlist_read_private ) diff --git a/tests/client/_resources.py b/tests/client/_resources.py index fe8e6cf8..6b13e7dd 100644 --- a/tests/client/_resources.py +++ b/tests/client/_resources.py @@ -21,6 +21,11 @@ user_id = 'spinninrecordsofficial' user_ids = [user_id, 'samsmithworld'] +show_id = '0Lsi13D8nWRkwbEkfNeItS' +show_ids = [show_id, '7Arl2fVUFHCGyNZwM39bJO'] +episode_id = '4SgOTqHfk56yqrTjXjWCNM' +episode_ids = [episode_id, '5WCB6tVX4aJX0432KCBTK8'] + image = ( '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHB' 'wcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDA' diff --git a/tests/client/episode.py b/tests/client/episode.py new file mode 100644 index 00000000..d303e413 --- /dev/null +++ b/tests/client/episode.py @@ -0,0 +1,39 @@ +from tests._cred import TestCaseWithCredentials, TestCaseWithUserCredentials +from ._resources import episode_id, episode_ids + +from tekore.client.api import SpotifyEpisode +from requests import HTTPError + + +class TestSpotifyEpisodeAsApp(TestCaseWithCredentials): + def setUp(self): + self.client = SpotifyEpisode(self.app_token) + + def test_episode_not_found_without_market(self): + with self.assertRaises(HTTPError): + self.client.episode(episode_id) + + def test_episode_found_with_market(self): + episode = self.client.episode(episode_id, market='FI') + self.assertEqual(episode.id, episode_id) + + def test_resume_point_is_none(self): + episode = self.client.episode(episode_id, market='FI') + self.assertIsNone(episode.resume_point) + + def test_episodes(self): + episodes = self.client.episodes(episode_ids, market='FI') + self.assertListEqual(episode_ids, [e.id for e in episodes]) + + +class TestSpotifyEpisodeAsUser(TestCaseWithUserCredentials): + def setUp(self): + self.client = SpotifyEpisode(self.user_token) + + def test_found_without_market(self): + episode = self.client.episode(episode_id) + self.assertEqual(episode.id, episode_id) + + def test_resume_point_exists(self): + episode = self.client.episode(episode_id) + self.assertIsNotNone(episode.resume_point) diff --git a/tests/client/library.py b/tests/client/library.py index 415bf36f..c3b3db58 100644 --- a/tests/client/library.py +++ b/tests/client/library.py @@ -1,79 +1,104 @@ from unittest import SkipTest from tests._cred import TestCaseWithUserCredentials -from ._resources import album_ids, track_ids +from ._resources import album_ids, track_ids, show_ids from tekore.client.api import SpotifyLibrary class TestSpotifyFollow(TestCaseWithUserCredentials): """ - If current user has saved the tested tracks, + If the current user has saved the tested tracks, albums or shows, they will be deleted and added again. """ @classmethod def setUpClass(cls): super().setUpClass() - client = SpotifyLibrary(cls.user_token) + cls.client = SpotifyLibrary(cls.user_token) try: - cls.current_albums = client.saved_albums_contains( - album_ids - ) - cls.current_tracks = client.saved_tracks_contains( - track_ids - ) + cls.current_albums = cls.client.saved_albums_contains(album_ids) + cls.current_tracks = cls.client.saved_tracks_contains(track_ids) + cls.current_shows = cls.client.saved_shows_contains(show_ids) except Exception as e: raise SkipTest('State before tests could not be determined!') from e - def setUp(self): - self.client = SpotifyLibrary(self.user_token) + cls.call = { + 'albums': cls.client.saved_albums_contains, + 'shows': cls.client.saved_shows_contains, + 'tracks': cls.client.saved_tracks_contains, + } + + def assert_contains(self, type_: str, ids: list): + self.assertTrue(all(self.call[type_](ids))) + + def assert_not_contains(self, type_: str, ids: list): + self.assertFalse(any(self.call[type_](ids))) def test_saved_albums(self): self.client.saved_albums() + self.client.saved_albums_delete(album_ids) - def test_saved_albums_add(self): self.client.saved_albums_add(album_ids) + with self.subTest('Albums added'): + self.assert_contains('albums', album_ids) - def test_saved_albums_delete(self): self.client.saved_albums_delete(album_ids) + with self.subTest('Albums deleted'): + self.assert_not_contains('albums', album_ids) def test_saved_tracks(self): self.client.saved_tracks() + self.client.saved_tracks_delete(track_ids) - def test_saved_tracks_add(self): self.client.saved_tracks_add(track_ids) + with self.subTest('Tracks added'): + self.assert_contains('tracks', track_ids) - def test_saved_tracks_delete(self): self.client.saved_tracks_delete(track_ids) + with self.subTest('Tracks deleted'): + self.assert_not_contains('tracks', track_ids) + + def test_saved_shows(self): + self.client.saved_shows() + self.client.saved_shows_delete(show_ids) + + self.client.saved_shows_add(show_ids) + with self.subTest('Shows added'): + self.assert_contains('shows', show_ids) + + self.client.saved_shows_delete(show_ids) + with self.subTest('Shows deleted'): + self.assert_not_contains('shows', show_ids) + + @staticmethod + def _revert(ids, current, add, remove): + added = [item for i, item in enumerate(ids) if current[i]] + if added: + add(added) + + removed = [item for i, item in enumerate(ids) if not current[i]] + if removed: + remove(removed) @classmethod def tearDownClass(cls): - client = SpotifyLibrary(cls.user_token) - - album_adds = [ - a for i, a in enumerate(album_ids) - if cls.current_albums[i] - ] - if album_adds: - client.saved_albums_add(album_adds) - - album_dels = [ - a for i, a in enumerate(album_ids) - if not cls.current_albums[i] - ] - if album_dels: - client.saved_albums_delete(album_dels) - - track_adds = [ - t for i, t in enumerate(track_ids) - if cls.current_tracks[i] - ] - if track_adds: - client.saved_tracks_add(track_adds) - - track_dels = [ - t for i, t in enumerate(track_ids) - if not cls.current_tracks[i] - ] - if track_dels: - client.saved_tracks_delete(track_dels) + cls._revert( + album_ids, + cls.current_albums, + cls.client.saved_albums_add, + cls.client.saved_albums_delete + ) + + cls._revert( + track_ids, + cls.current_tracks, + cls.client.saved_tracks_add, + cls.client.saved_tracks_delete + ) + + cls._revert( + show_ids, + cls.current_shows, + cls.client.saved_shows_add, + cls.client.saved_shows_delete + ) diff --git a/tests/client/player.py b/tests/client/player.py index 9a38bd46..8011a467 100644 --- a/tests/client/player.py +++ b/tests/client/player.py @@ -2,7 +2,7 @@ from requests import HTTPError from tests._cred import TestCaseWithUserCredentials, skip_or_fail -from ._resources import track_ids, album_id +from ._resources import track_ids, album_id, episode_id from tekore.client.api import SpotifyPlayer from tekore.client import Spotify from tekore.convert import to_uri @@ -114,6 +114,18 @@ def test_player(self): self.client.playback_next() self.assertPlaying('Queue consumed on next', track_ids[0]) + self.client.playback_queue_add(to_uri('episode', episode_id)) + self.client.playback_next() + playing = self.currently_playing() + with self.subTest('Add episode to queue'): + self.assertEqual(playing.currently_playing_type.value, 'episode') + + with self.subTest('Playback takes additional types'): + self.client.playback(additional_types=['episode']) + + with self.subTest('Currently playing takes additional types'): + self.client.playback_currently_playing(additional_types=['episode']) + def tearDown(self): if self.playback is None: self.client.playback_pause() diff --git a/tests/client/search.py b/tests/client/search.py index aa1d4ca7..a45a2653 100644 --- a/tests/client/search.py +++ b/tests/client/search.py @@ -17,3 +17,9 @@ def test_search_below_limit_succeeds(self): def test_search_beyond_limit_raises(self): with self.assertRaises(HTTPError): self.client.search('piano', types=('playlist',), limit=1, offset=2000) + + def test_search_shows(self): + self.client.search('sleep', types=('show',), limit=1) + + def test_search_episodes(self): + self.client.search('piano', types=('episode',), limit=1) diff --git a/tests/client/show.py b/tests/client/show.py new file mode 100644 index 00000000..f76f8bbe --- /dev/null +++ b/tests/client/show.py @@ -0,0 +1,35 @@ +from tests._cred import TestCaseWithCredentials, TestCaseWithUserCredentials +from ._resources import show_id, show_ids + +from tekore.client.api import SpotifyShow +from requests import HTTPError + + +class TestSpotifyShowAsApp(TestCaseWithCredentials): + def setUp(self): + self.client = SpotifyShow(self.app_token) + + def test_show_not_found_without_market(self): + with self.assertRaises(HTTPError): + self.client.show(show_id) + + def test_show_found_with_market(self): + show = self.client.show(show_id, market='FI') + self.assertEqual(show.id, show_id) + + def test_shows(self): + shows = self.client.shows(show_ids, market='FI') + self.assertListEqual(show_ids, [s.id for s in shows]) + + def test_show_episodes(self): + episodes = self.client.show_episodes(show_id, market='FI', limit=1) + self.assertIsNotNone(episodes.items[0]) + + +class TestSpotifyShowAsUser(TestCaseWithUserCredentials): + def setUp(self): + self.client = SpotifyShow(self.user_token) + + def test_show_found_without_market(self): + show = self.client.show(show_id) + self.assertEqual(show_id, show.id)