From 6cc2aa2d628da3bd5df4f765529a1c54ec40561b Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Tue, 2 Apr 2019 03:17:18 +0000 Subject: Reorganize models --- pandora/client.py | 16 +- pandora/models/ad.py | 36 +++ pandora/models/bookmark.py | 32 +++ pandora/models/pandora.py | 475 -------------------------------------- pandora/models/playlist.py | 197 ++++++++++++++++ pandora/models/search.py | 93 ++++++++ pandora/models/station.py | 123 ++++++++++ tests/test_pandora/test_client.py | 3 +- tests/test_pandora/test_models.py | 78 ++++--- tests/test_pydora/test_utils.py | 4 +- 10 files changed, 535 insertions(+), 522 deletions(-) create mode 100644 pandora/models/ad.py create mode 100644 pandora/models/bookmark.py delete mode 100644 pandora/models/pandora.py create mode 100644 pandora/models/playlist.py create mode 100644 pandora/models/search.py create mode 100644 pandora/models/station.py diff --git a/pandora/client.py b/pandora/client.py index 658fb0d..b077954 100644 --- a/pandora/client.py +++ b/pandora/client.py @@ -103,7 +103,7 @@ class APIClient(BaseAPIClient): """ def get_station_list(self): # pragma: no cover - from .models.pandora import StationList + from .models.station import StationList return StationList.from_json(self, self("user.getStationList", @@ -113,7 +113,7 @@ class APIClient(BaseAPIClient): return self("user.getStationListChecksum")["checksum"] def get_playlist(self, station_token, additional_urls=None): - from .models.pandora import Playlist + from .models.playlist import Playlist if additional_urls is None: additional_urls = [] @@ -143,13 +143,13 @@ class APIClient(BaseAPIClient): return playlist def get_bookmarks(self): # pragma: no cover - from .models.pandora import BookmarkList + from .models.bookmark import BookmarkList return BookmarkList.from_json(self, self("user.getBookmarks")) def get_station(self, station_token): # pragma: no cover - from .models.pandora import Station + from .models.station import Station return Station.from_json(self, self("station.getStation", @@ -175,7 +175,7 @@ class APIClient(BaseAPIClient): def search(self, search_text, include_near_matches=False, include_genre_stations=False): # pragma: no cover - from .models.pandora import SearchResult + from .models.search import SearchResult return SearchResult.from_json( self, @@ -197,7 +197,7 @@ class APIClient(BaseAPIClient): def create_station(self, search_token=None, artist_token=None, track_token=None): - from .models.pandora import Station + from .models.station import Station kwargs = {} @@ -226,7 +226,7 @@ class APIClient(BaseAPIClient): stationToken=station_token) def get_genre_stations(self): - from .models.pandora import GenreStationList + from .models.station import GenreStationList genre_stations = GenreStationList.from_json( self, self("station.getGenreStations")) @@ -270,7 +270,7 @@ class APIClient(BaseAPIClient): email=emails[0]) def get_ad_item(self, station_id, ad_token): - from .models.pandora import AdItem + from .models.ad import AdItem if not station_id: raise errors.ParameterMissing("The 'station_id' param must be " diff --git a/pandora/models/ad.py b/pandora/models/ad.py new file mode 100644 index 0000000..d5bcd89 --- /dev/null +++ b/pandora/models/ad.py @@ -0,0 +1,36 @@ +from ..errors import ParameterMissing +from ._base import Field +from .playlist import PlaylistModel, AudioField + + +class AdItem(PlaylistModel): + + title = Field("title") + company_name = Field("companyName") + tracking_tokens = Field("adTrackingTokens") + audio_url = AudioField("audioUrl") + image_url = Field("imageUrl") + click_through_url = Field("clickThroughUrl") + station_id = None + ad_token = None + + @property + def is_ad(self): + return True + + def register_ad(self, station_id=None): + if not station_id: + station_id = self.station_id + if self.tracking_tokens: + self._api_client.register_ad(station_id, self.tracking_tokens) + else: + raise ParameterMissing('No ad tracking tokens provided for ' + 'registration.') + + def prepare_playback(self): + try: + self.register_ad(self.station_id) + except ParameterMissing as exc: + if self.tracking_tokens: + raise exc + return super(AdItem, self).prepare_playback() diff --git a/pandora/models/bookmark.py b/pandora/models/bookmark.py new file mode 100644 index 0000000..810348b --- /dev/null +++ b/pandora/models/bookmark.py @@ -0,0 +1,32 @@ +from ._base import PandoraModel, Field, DateField + + +class Bookmark(PandoraModel): + + music_token = Field("musicToken") + artist_name = Field("artistName") + art_url = Field("artUrl") + bookmark_token = Field("bookmarkToken") + date_created = DateField("dateCreated") + + # song only + sample_url = Field("sampleUrl") + sample_gain = Field("sampleGain") + album_name = Field("albumName") + song_name = Field("songName") + + @property + def is_song_bookmark(self): + return self.song_name is not None + + def delete(self): + if self.is_song_bookmark: + self._api_client.delete_song_bookmark(self.bookmark_token) + else: + self._api_client.delete_artist_bookmark(self.bookmark_token) + + +class BookmarkList(PandoraModel): + + songs = Field("songs", model=Bookmark) + artists = Field("artists", model=Bookmark) diff --git a/pandora/models/pandora.py b/pandora/models/pandora.py deleted file mode 100644 index 341b0be..0000000 --- a/pandora/models/pandora.py +++ /dev/null @@ -1,475 +0,0 @@ -from enum import Enum - -from ..client import BaseAPIClient -from ..errors import ParameterMissing -from ._base import Field, DateField, SyntheticField -from ._base import PandoraModel, PandoraListModel, PandoraDictListModel - - -class AdditionalAudioUrl(Enum): - HTTP_40_AAC_MONO = 'HTTP_40_AAC_MONO' - HTTP_64_AAC = 'HTTP_64_AAC' - HTTP_32_AACPLUS = 'HTTP_32_AACPLUS' - HTTP_64_AACPLUS = 'HTTP_64_AACPLUS' - HTTP_24_AACPLUS_ADTS = 'HTTP_24_AACPLUS_ADTS' - HTTP_32_AACPLUS_ADTS = 'HTTP_32_AACPLUS_ADTS' - HTTP_64_AACPLUS_ADTS = 'HTTP_64_AACPLUS_ADTS' - HTTP_128_MP3 = 'HTTP_128_MP3' - HTTP_32_WMA = 'HTTP_32_WMA' - - -class PandoraType(Enum): - - TRACK = "TR" - ARTIST = "AR" - GENRE = "GR" - - @staticmethod - def from_model(client, value): - return PandoraType.from_string(value) - - @staticmethod - def from_string(value): - return { - "TR": PandoraType.TRACK, - "AR": PandoraType.ARTIST, - }.get(value, PandoraType.GENRE) - - -class Icon(PandoraModel): - - dominant_color = Field("dominantColor") - art_url = Field("artUrl") - - -class StationSeed(PandoraModel): - - seed_id = Field("seedId") - music_token = Field("musicToken") - pandora_id = Field("pandoraId") - pandora_type = Field("pandoraType", formatter=PandoraType.from_model) - - genre_name = Field("genreName") - song_name = Field("songName") - artist_name = Field("artistName") - art_url = Field("artUrl") - icon = Field("icon", model=Icon) - - -class StationSeeds(PandoraModel): - - genres = Field("genres", model=StationSeed) - songs = Field("songs", model=StationSeed) - artists = Field("artists", model=StationSeed) - - -class SongFeedback(PandoraModel): - - feedback_id = Field("feedbackId") - song_identity = Field("songIdentity") - is_positive = Field("isPositive") - pandora_id = Field("pandoraId") - album_art_url = Field("albumArtUrl") - music_token = Field("musicToken") - song_name = Field("songName") - artist_name = Field("artistName") - pandora_type = Field("pandoraType", formatter=PandoraType.from_model) - date_created = DateField("dateCreated") - - -class StationFeedback(PandoraModel): - - total_thumbs_up = Field("totalThumbsUp") - total_thumbs_down = Field("totalThumbsDown") - thumbs_up = Field("thumbsUp", model=SongFeedback) - thumbs_down = Field("thumbsDown", model=SongFeedback) - - -class Station(PandoraModel): - - can_add_music = Field("allowAddMusic") - can_delete = Field("allowDelete") - can_rename = Field("allowRename") - can_edit_description = Field("allowEditDescription") - process_skips = Field("processSkips") - is_shared = Field("isShared") - is_quickmix = Field("isQuickMix") - is_genre_station = Field("isGenreStation") - is_thumbprint_station = Field("isThumbprint") - - art_url = Field("artUrl") - date_created = DateField("dateCreated") - detail_url = Field("stationDetailUrl") - id = Field("stationId") - name = Field("stationName") - sharing_url = Field("stationSharingUrl") - thumb_count = Field("thumbCount") - token = Field("stationToken") - - genre = Field("genre", []) - quickmix_stations = Field("quickMixStationIds", []) - - seeds = Field("music", model=StationSeeds) - feedback = Field("feedback", model=StationFeedback) - - def get_playlist(self, additional_urls=None): - return iter(self._api_client.get_playlist(self.token, - additional_urls)) - - -class GenreStation(PandoraModel): - - id = Field("stationId") - name = Field("stationName") - token = Field("stationToken") - category = Field("categoryName") - - def get_playlist(self): # pragma: no cover - raise NotImplementedError("Genre stations do not have playlists. " - "Create a real station using the token.") - - -class StationList(PandoraListModel): - - checksum = Field("checksum") - - __index_key__ = "id" - __list_key__ = "stations" - __list_model__ = Station - - def has_changed(self): - checksum = self._api_client.get_station_list_checksum() - return checksum != self.checksum - - -class AudioField(SyntheticField): - - def formatter(self, api_client, data, value): - """Get audio-related fields - - Try to find fields for the audio url for specified preferred quality - level, or next-lowest available quality url otherwise. - """ - url_map = data.get("audioUrlMap") - audio_url = data.get("audioUrl") - - # Only an audio URL, not a quality map. This happens for most of the - # mobile client tokens and some of the others now. In this case - # substitute the empirically determined default values in the format - # used by the rest of the function so downstream consumers continue to - # work. - if audio_url and not url_map: - url_map = { - BaseAPIClient.HIGH_AUDIO_QUALITY: { - "audioUrl": audio_url, - "bitrate": 64, - "encoding": "aacplus", - } - } - elif not url_map: # No audio url available (e.g. ad tokens) - return None - - valid_audio_formats = [BaseAPIClient.HIGH_AUDIO_QUALITY, - BaseAPIClient.MED_AUDIO_QUALITY, - BaseAPIClient.LOW_AUDIO_QUALITY] - - # Only iterate over sublist, starting at preferred audio quality, or - # from the beginning of the list if nothing is found. Ensures that the - # bitrate used will always be the same or lower quality than was - # specified to prevent audio from skipping for slow connections. - preferred_quality = api_client.default_audio_quality - if preferred_quality in valid_audio_formats: - i = valid_audio_formats.index(preferred_quality) - valid_audio_formats = valid_audio_formats[i:] - - for quality in valid_audio_formats: - audio_url = url_map.get(quality) - - if audio_url: - return audio_url[self.field] - - return audio_url[self.field] if audio_url else None - - -class AdditionalUrlField(SyntheticField): - - def formatter(self, api_client, data, value): - """Parse additional url fields and map them to inputs - - Attempt to create a dictionary with keys being user input, and - response being the returned URL - """ - if value is None: - return None - - user_param = data['_paramAdditionalUrls'] - urls = {} - if isinstance(value, str): - urls[user_param[0]] = value - else: - for key, url in zip(user_param, value): - urls[key] = url - return urls - - -class PlaylistModel(PandoraModel): - - def get_is_playable(self): - if not self.audio_url: - return False - return self._api_client.transport.test_url(self.audio_url) - - def prepare_playback(self): - """Prepare Track for Playback - - This method must be called by clients before beginning playback - otherwise the track recieved may not be playable. - """ - return self - - def thumbs_up(self): # pragma: no cover - raise NotImplementedError - - def thumbs_down(self): # pragma: no cover - raise NotImplementedError - - def bookmark_song(self): # pragma: no cover - raise NotImplementedError - - def bookmark_artist(self): # pragma: no cover - raise NotImplementedError - - def sleep(self): # pragma: no cover - raise NotImplementedError - - -class PlaylistItem(PlaylistModel): - - artist_name = Field("artistName") - album_name = Field("albumName") - song_name = Field("songName") - song_rating = Field("songRating") - track_gain = Field("trackGain") - track_length = Field("trackLength") - track_token = Field("trackToken") - audio_url = AudioField("audioUrl") - bitrate = AudioField("bitrate") - encoding = AudioField("encoding") - album_art_url = Field("albumArtUrl") - allow_feedback = Field("allowFeedback") - station_id = Field("stationId") - - ad_token = Field("adToken") - - album_detail_url = Field("albumDetailUrl") - album_explore_url = Field("albumExplorerUrl") - - amazon_album_asin = Field("amazonAlbumAsin") - amazon_album_digital_asin = Field("amazonAlbumDigitalAsin") - amazon_album_url = Field("amazonAlbumUrl") - amazon_song_digital_asin = Field("amazonSongDigitalAsin") - - artist_detail_url = Field("artistDetailUrl") - artist_explore_url = Field("artistExplorerUrl") - - itunes_song_url = Field("itunesSongUrl") - - song_detail_url = Field("songDetailUrl") - song_explore_url = Field("songExplorerUrl") - - additional_audio_urls = AdditionalUrlField("additionalAudioUrl") - - @property - def is_ad(self): - return self.ad_token is not None - - def thumbs_up(self): # pragma: no cover - return self._api_client.add_feedback(self.track_token, True) - - def thumbs_down(self): # pragma: no cover - return self._api_client.add_feedback(self.track_token, False) - - def bookmark_song(self): # pragma: no cover - return self._api_client.add_song_bookmark(self.track_token) - - def bookmark_artist(self): # pragma: no cover - return self._api_client.add_artist_bookmark(self.track_token) - - def sleep(self): # pragma: no cover - return self._api_client.sleep_song(self.track_token) - - -class AdItem(PlaylistModel): - - title = Field("title") - company_name = Field("companyName") - tracking_tokens = Field("adTrackingTokens") - audio_url = AudioField("audioUrl") - image_url = Field("imageUrl") - click_through_url = Field("clickThroughUrl") - station_id = None - ad_token = None - - @property - def is_ad(self): - return True - - def register_ad(self, station_id=None): - if not station_id: - station_id = self.station_id - if self.tracking_tokens: - self._api_client.register_ad(station_id, self.tracking_tokens) - else: - raise ParameterMissing('No ad tracking tokens provided for ' - 'registration.') - - def prepare_playback(self): - try: - self.register_ad(self.station_id) - except ParameterMissing as exc: - if self.tracking_tokens: - raise exc - return super(AdItem, self).prepare_playback() - - -class Playlist(PandoraListModel): - - __list_key__ = "items" - __list_model__ = PlaylistItem - - -class Bookmark(PandoraModel): - - music_token = Field("musicToken") - artist_name = Field("artistName") - art_url = Field("artUrl") - bookmark_token = Field("bookmarkToken") - date_created = DateField("dateCreated") - - # song only - sample_url = Field("sampleUrl") - sample_gain = Field("sampleGain") - album_name = Field("albumName") - song_name = Field("songName") - - @property - def is_song_bookmark(self): - return self.song_name is not None - - def delete(self): - if self.is_song_bookmark: - self._api_client.delete_song_bookmark(self.bookmark_token) - else: - self._api_client.delete_artist_bookmark(self.bookmark_token) - - -class BookmarkList(PandoraModel): - - songs = Field("songs", model=Bookmark) - artists = Field("artists", model=Bookmark) - - -class SearchResultItem(PandoraModel): - - score = Field("score") - token = Field("musicToken") - - @property - def is_song(self): - return isinstance(self, SongSearchResultItem) - - @property - def is_artist(self): - return isinstance(self, ArtistSearchResultItem) and \ - self.token.startswith("R") - - @property - def is_composer(self): - return isinstance(self, ArtistSearchResultItem) and \ - self.token.startswith("C") - - @property - def is_genre_station(self): - return isinstance(self, GenreStationSearchResultItem) - - def create_station(self): # pragma: no cover - raise NotImplementedError - - @classmethod - def from_json(cls, api_client, data): - if data["musicToken"].startswith("S"): - return SongSearchResultItem.from_json(api_client, data) - elif data["musicToken"].startswith(("R", "C")): - return ArtistSearchResultItem.from_json(api_client, data) - elif data["musicToken"].startswith("G"): - return GenreStationSearchResultItem.from_json(api_client, data) - else: - raise NotImplementedError("Unknown result token type '{}'" - .format(data["musicToken"])) - - -class ArtistSearchResultItem(SearchResultItem): - - score = Field("score") - token = Field("musicToken") - artist = Field("artistName") - likely_match = Field("likelyMatch", default=False) - - def create_station(self): - self._api_client.create_station(artist_token=self.token) - - @classmethod - def from_json(cls, api_client, data): - return super(SearchResultItem, cls).from_json(api_client, data) - - -class SongSearchResultItem(SearchResultItem): - - score = Field("score") - token = Field("musicToken") - artist = Field("artistName") - song_name = Field("songName") - - def create_station(self): - self._api_client.create_station(track_token=self.token) - - @classmethod - def from_json(cls, api_client, data): - return super(SearchResultItem, cls).from_json(api_client, data) - - -class GenreStationSearchResultItem(SearchResultItem): - - score = Field("score") - token = Field("musicToken") - station_name = Field("stationName") - - def create_station(self): - self._api_client.create_station(search_token=self.token) - - @classmethod - def from_json(cls, api_client, data): - return super(SearchResultItem, cls).from_json(api_client, data) - - -class SearchResult(PandoraModel): - - nearest_matches_available = Field("nearMatchesAvailable") - explanation = Field("explanation") - songs = Field("songs", model=SongSearchResultItem) - artists = Field("artists", model=ArtistSearchResultItem) - genre_stations = Field("genreStations", model=GenreStationSearchResultItem) - - -class GenreStationList(PandoraDictListModel): - - checksum = Field("checksum") - - __dict_list_key__ = "categories" - __dict_key__ = "categoryName" - __list_key__ = "stations" - __list_model__ = GenreStation - - def has_changed(self): - checksum = self._api_client.get_station_list_checksum() - return checksum != self.checksum diff --git a/pandora/models/playlist.py b/pandora/models/playlist.py new file mode 100644 index 0000000..08510f1 --- /dev/null +++ b/pandora/models/playlist.py @@ -0,0 +1,197 @@ +from enum import Enum + +from ..client import BaseAPIClient +from ._base import Field, SyntheticField, PandoraModel, PandoraListModel + + +class AdditionalAudioUrl(Enum): + HTTP_40_AAC_MONO = 'HTTP_40_AAC_MONO' + HTTP_64_AAC = 'HTTP_64_AAC' + HTTP_32_AACPLUS = 'HTTP_32_AACPLUS' + HTTP_64_AACPLUS = 'HTTP_64_AACPLUS' + HTTP_24_AACPLUS_ADTS = 'HTTP_24_AACPLUS_ADTS' + HTTP_32_AACPLUS_ADTS = 'HTTP_32_AACPLUS_ADTS' + HTTP_64_AACPLUS_ADTS = 'HTTP_64_AACPLUS_ADTS' + HTTP_128_MP3 = 'HTTP_128_MP3' + HTTP_32_WMA = 'HTTP_32_WMA' + + +class PandoraType(Enum): + + TRACK = "TR" + ARTIST = "AR" + GENRE = "GR" + + @staticmethod + def from_model(client, value): + return PandoraType.from_string(value) + + @staticmethod + def from_string(value): + return { + "TR": PandoraType.TRACK, + "AR": PandoraType.ARTIST, + }.get(value, PandoraType.GENRE) + + +class AudioField(SyntheticField): + + def formatter(self, api_client, data, value): + """Get audio-related fields + + Try to find fields for the audio url for specified preferred quality + level, or next-lowest available quality url otherwise. + """ + url_map = data.get("audioUrlMap") + audio_url = data.get("audioUrl") + + # Only an audio URL, not a quality map. This happens for most of the + # mobile client tokens and some of the others now. In this case + # substitute the empirically determined default values in the format + # used by the rest of the function so downstream consumers continue to + # work. + if audio_url and not url_map: + url_map = { + BaseAPIClient.HIGH_AUDIO_QUALITY: { + "audioUrl": audio_url, + "bitrate": 64, + "encoding": "aacplus", + } + } + elif not url_map: # No audio url available (e.g. ad tokens) + return None + + valid_audio_formats = [BaseAPIClient.HIGH_AUDIO_QUALITY, + BaseAPIClient.MED_AUDIO_QUALITY, + BaseAPIClient.LOW_AUDIO_QUALITY] + + # Only iterate over sublist, starting at preferred audio quality, or + # from the beginning of the list if nothing is found. Ensures that the + # bitrate used will always be the same or lower quality than was + # specified to prevent audio from skipping for slow connections. + preferred_quality = api_client.default_audio_quality + if preferred_quality in valid_audio_formats: + i = valid_audio_formats.index(preferred_quality) + valid_audio_formats = valid_audio_formats[i:] + + for quality in valid_audio_formats: + audio_url = url_map.get(quality) + + if audio_url: + return audio_url[self.field] + + return audio_url[self.field] if audio_url else None + + +class AdditionalUrlField(SyntheticField): + + def formatter(self, api_client, data, value): + """Parse additional url fields and map them to inputs + + Attempt to create a dictionary with keys being user input, and + response being the returned URL + """ + if value is None: + return None + + user_param = data['_paramAdditionalUrls'] + urls = {} + if isinstance(value, str): + urls[user_param[0]] = value + else: + for key, url in zip(user_param, value): + urls[key] = url + return urls + + +class PlaylistModel(PandoraModel): + + def get_is_playable(self): + if not self.audio_url: + return False + return self._api_client.transport.test_url(self.audio_url) + + def prepare_playback(self): + """Prepare Track for Playback + + This method must be called by clients before beginning playback + otherwise the track recieved may not be playable. + """ + return self + + def thumbs_up(self): # pragma: no cover + raise NotImplementedError + + def thumbs_down(self): # pragma: no cover + raise NotImplementedError + + def bookmark_song(self): # pragma: no cover + raise NotImplementedError + + def bookmark_artist(self): # pragma: no cover + raise NotImplementedError + + def sleep(self): # pragma: no cover + raise NotImplementedError + + +class PlaylistItem(PlaylistModel): + + artist_name = Field("artistName") + album_name = Field("albumName") + song_name = Field("songName") + song_rating = Field("songRating") + track_gain = Field("trackGain") + track_length = Field("trackLength") + track_token = Field("trackToken") + audio_url = AudioField("audioUrl") + bitrate = AudioField("bitrate") + encoding = AudioField("encoding") + album_art_url = Field("albumArtUrl") + allow_feedback = Field("allowFeedback") + station_id = Field("stationId") + + ad_token = Field("adToken") + + album_detail_url = Field("albumDetailUrl") + album_explore_url = Field("albumExplorerUrl") + + amazon_album_asin = Field("amazonAlbumAsin") + amazon_album_digital_asin = Field("amazonAlbumDigitalAsin") + amazon_album_url = Field("amazonAlbumUrl") + amazon_song_digital_asin = Field("amazonSongDigitalAsin") + + artist_detail_url = Field("artistDetailUrl") + artist_explore_url = Field("artistExplorerUrl") + + itunes_song_url = Field("itunesSongUrl") + + song_detail_url = Field("songDetailUrl") + song_explore_url = Field("songExplorerUrl") + + additional_audio_urls = AdditionalUrlField("additionalAudioUrl") + + @property + def is_ad(self): + return self.ad_token is not None + + def thumbs_up(self): # pragma: no cover + return self._api_client.add_feedback(self.track_token, True) + + def thumbs_down(self): # pragma: no cover + return self._api_client.add_feedback(self.track_token, False) + + def bookmark_song(self): # pragma: no cover + return self._api_client.add_song_bookmark(self.track_token) + + def bookmark_artist(self): # pragma: no cover + return self._api_client.add_artist_bookmark(self.track_token) + + def sleep(self): # pragma: no cover + return self._api_client.sleep_song(self.track_token) + + +class Playlist(PandoraListModel): + + __list_key__ = "items" + __list_model__ = PlaylistItem diff --git a/pandora/models/search.py b/pandora/models/search.py new file mode 100644 index 0000000..89def8e --- /dev/null +++ b/pandora/models/search.py @@ -0,0 +1,93 @@ +from ._base import Field, PandoraModel + + +class SearchResultItem(PandoraModel): + + score = Field("score") + token = Field("musicToken") + + @property + def is_song(self): + return isinstance(self, SongSearchResultItem) + + @property + def is_artist(self): + return isinstance(self, ArtistSearchResultItem) and \ + self.token.startswith("R") + + @property + def is_composer(self): + return isinstance(self, ArtistSearchResultItem) and \ + self.token.startswith("C") + + @property + def is_genre_station(self): + return isinstance(self, GenreStationSearchResultItem) + + def create_station(self): # pragma: no cover + raise NotImplementedError + + @classmethod + def from_json(cls, api_client, data): + if data["musicToken"].startswith("S"): + return SongSearchResultItem.from_json(api_client, data) + elif data["musicToken"].startswith(("R", "C")): + return ArtistSearchResultItem.from_json(api_client, data) + elif data["musicToken"].startswith("G"): + return GenreStationSearchResultItem.from_json(api_client, data) + else: + raise NotImplementedError("Unknown result token type '{}'" + .format(data["musicToken"])) + + +class ArtistSearchResultItem(SearchResultItem): + + score = Field("score") + token = Field("musicToken") + artist = Field("artistName") + likely_match = Field("likelyMatch", default=False) + + def create_station(self): + self._api_client.create_station(artist_token=self.token) + + @classmethod + def from_json(cls, api_client, data): + return super(SearchResultItem, cls).from_json(api_client, data) + + +class SongSearchResultItem(SearchResultItem): + + score = Field("score") + token = Field("musicToken") + artist = Field("artistName") + song_name = Field("songName") + + def create_station(self): + self._api_client.create_station(track_token=self.token) + + @classmethod + def from_json(cls, api_client, data): + return super(SearchResultItem, cls).from_json(api_client, data) + + +class GenreStationSearchResultItem(SearchResultItem): + + score = Field("score") + token = Field("musicToken") + station_name = Field("stationName") + + def create_station(self): + self._api_client.create_station(search_token=self.token) + + @classmethod + def from_json(cls, api_client, data): + return super(SearchResultItem, cls).from_json(api_client, data) + + +class SearchResult(PandoraModel): + + nearest_matches_available = Field("nearMatchesAvailable") + explanation = Field("explanation") + songs = Field("songs", model=SongSearchResultItem) + artists = Field("artists", model=ArtistSearchResultItem) + genre_stations = Field("genreStations", model=GenreStationSearchResultItem) diff --git a/pandora/models/station.py b/pandora/models/station.py new file mode 100644 index 0000000..d4f4846 --- /dev/null +++ b/pandora/models/station.py @@ -0,0 +1,123 @@ +from ._base import Field, DateField +from ._base import PandoraModel, PandoraListModel, PandoraDictListModel +from .playlist import PandoraType + + +class Icon(PandoraModel): + + dominant_color = Field("dominantColor") + art_url = Field("artUrl") + + +class StationSeed(PandoraModel): + + seed_id = Field("seedId") + music_token = Field("musicToken") + pandora_id = Field("pandoraId") + pandora_type = Field("pandoraType", formatter=PandoraType.from_model) + + genre_name = Field("genreName") + song_name = Field("songName") + artist_name = Field("artistName") + art_url = Field("artUrl") + icon = Field("icon", model=Icon) + + +class StationSeeds(PandoraModel): + + genres = Field("genres", model=StationSeed) + songs = Field("songs", model=StationSeed) + artists = Field("artists", model=StationSeed) + + +class SongFeedback(PandoraModel): + + feedback_id = Field("feedbackId") + song_identity = Field("songIdentity") + is_positive = Field("isPositive") + pandora_id = Field("pandoraId") + album_art_url = Field("albumArtUrl") + music_token = Field("musicToken") + song_name = Field("songName") + artist_name = Field("artistName") + pandora_type = Field("pandoraType", formatter=PandoraType.from_model) + date_created = DateField("dateCreated") + + +class StationFeedback(PandoraModel): + + total_thumbs_up = Field("totalThumbsUp") + total_thumbs_down = Field("totalThumbsDown") + thumbs_up = Field("thumbsUp", model=SongFeedback) + thumbs_down = Field("thumbsDown", model=SongFeedback) + + +class Station(PandoraModel): + + can_add_music = Field("allowAddMusic") + can_delete = Field("allowDelete") + can_rename = Field("allowRename") + can_edit_description = Field("allowEditDescription") + process_skips = Field("processSkips") + is_shared = Field("isShared") + is_quickmix = Field("isQuickMix") + is_genre_station = Field("isGenreStation") + is_thumbprint_station = Field("isThumbprint") + + art_url = Field("artUrl") + date_created = DateField("dateCreated") + detail_url = Field("stationDetailUrl") + id = Field("stationId") + name = Field("stationName") + sharing_url = Field("stationSharingUrl") + thumb_count = Field("thumbCount") + token = Field("stationToken") + + genre = Field("genre", []) + quickmix_stations = Field("quickMixStationIds", []) + + seeds = Field("music", model=StationSeeds) + feedback = Field("feedback", model=StationFeedback) + + def get_playlist(self, additional_urls=None): + return iter(self._api_client.get_playlist(self.token, + additional_urls)) + + +class StationList(PandoraListModel): + + checksum = Field("checksum") + + __index_key__ = "id" + __list_key__ = "stations" + __list_model__ = Station + + def has_changed(self): + checksum = self._api_client.get_station_list_checksum() + return checksum != self.checksum + + +class GenreStation(PandoraModel): + + id = Field("stationId") + name = Field("stationName") + token = Field("stationToken") + category = Field("categoryName") + + def get_playlist(self): # pragma: no cover + raise NotImplementedError("Genre stations do not have playlists. " + "Create a real station using the token.") + + +class GenreStationList(PandoraDictListModel): + + checksum = Field("checksum") + + __dict_list_key__ = "categories" + __dict_key__ = "categoryName" + __list_key__ = "stations" + __list_model__ = GenreStation + + def has_changed(self): + checksum = self._api_client.get_station_list_checksum() + return checksum != self.checksum diff --git a/tests/test_pandora/test_client.py b/tests/test_pandora/test_client.py index 5bd200c..901e072 100644 --- a/tests/test_pandora/test_client.py +++ b/tests/test_pandora/test_client.py @@ -2,7 +2,8 @@ from unittest import TestCase from unittest.mock import Mock, call, patch from pandora import errors -from pandora.models.pandora import AdItem, AdditionalAudioUrl +from pandora.models.ad import AdItem +from pandora.models.playlist import AdditionalAudioUrl from pandora.client import APIClient, BaseAPIClient from tests.test_pandora.test_models import TestAdItem diff --git a/tests/test_pandora/test_models.py b/tests/test_pandora/test_models.py index 8e0e0b5..8cfbd9d 100644 --- a/tests/test_pandora/test_models.py +++ b/tests/test_pandora/test_models.py @@ -5,8 +5,12 @@ from unittest.mock import Mock, patch from pandora.client import APIClient from pandora.errors import ParameterMissing +import pandora.models.ad as am import pandora.models._base as m -import pandora.models.pandora as pm +import pandora.models.search as sm +import pandora.models.station as stm +import pandora.models.bookmark as bm +import pandora.models.playlist as plm class TestField(TestCase): @@ -59,7 +63,7 @@ class TestAdditionalUrlField(TestCase): '_paramAdditionalUrls': ['foo'] } - field = pm.AdditionalUrlField("additionalAudioUrl") + field = plm.AdditionalUrlField("additionalAudioUrl") ret = field.formatter(None, dummy_data, 'test') @@ -73,7 +77,7 @@ class TestAdditionalUrlField(TestCase): ] } - field = pm.AdditionalUrlField("additionalAudioUrl") + field = plm.AdditionalUrlField("additionalAudioUrl") ret = field.formatter(None, dummy_data, ['foo', 'bar']) @@ -274,7 +278,7 @@ class TestPlaylistItemModel(TestCase): WEIRD_FORMAT = {"audioUrlMap": {"highQuality": {}}} def test_audio_url_without_map(self): - item = pm.PlaylistItem.from_json(Mock(), self.AUDIO_URL_NO_MAP) + item = plm.PlaylistItem.from_json(Mock(), self.AUDIO_URL_NO_MAP) self.assertEqual(item.bitrate, 64) self.assertEqual(item.encoding, "aacplus") self.assertEqual(item.audio_url, "foo") @@ -284,7 +288,7 @@ class TestPlaylistItemModel(TestCase): # valid url... but I didn't add the original code so just going to test it # and leave it alone for now ~mcrute def test_empty_quality_map_url_is_map(self): - item = pm.PlaylistItem.from_json(Mock(), self.WEIRD_FORMAT) + item = plm.PlaylistItem.from_json(Mock(), self.WEIRD_FORMAT) self.assertIsNone(item.bitrate) self.assertIsNone(item.encoding) self.assertIsNone(item.audio_url) @@ -293,13 +297,13 @@ class TestPlaylistItemModel(TestCase): class TestPlaylistModel(TestCase): def test_unplayable_get_is_playable(self): - playlist = pm.PlaylistModel(Mock()) + playlist = plm.PlaylistModel(Mock()) playlist.audio_url = "" self.assertFalse(playlist.get_is_playable()) def test_playable_get_is_playable(self): client = Mock() - playlist = pm.PlaylistModel(client) + playlist = plm.PlaylistModel(client) playlist.audio_url = "foo" playlist.get_is_playable() client.transport.test_url.assert_called_with("foo") @@ -339,7 +343,7 @@ class TestAdItem(TestCase): def setUp(self): api_client_mock = Mock(spec=APIClient) api_client_mock.default_audio_quality = APIClient.HIGH_AUDIO_QUALITY - self.result = pm.AdItem.from_json(api_client_mock, self.JSON_DATA) + self.result = am.AdItem.from_json(api_client_mock, self.JSON_DATA) self.result.station_id = 'station_id_mock' self.result.ad_token = 'token_mock' @@ -355,14 +359,14 @@ class TestAdItem(TestCase): def test_register_ad_raises_if_no_tracking_tokens_available(self): with self.assertRaises(ParameterMissing): self.result.tracking_tokens = [] - self.result._api_client.register_ad = Mock(spec=pm.AdItem) + self.result._api_client.register_ad = Mock(spec=am.AdItem) self.result.register_ad('id_mock') assert self.result._api_client.register_ad.called def test_prepare_playback(self): - with patch.object(pm.PlaylistModel, 'prepare_playback') as super_mock: + with patch.object(plm.PlaylistModel, 'prepare_playback') as super_mock: self.result.register_ad = Mock() self.result.prepare_playback() @@ -370,7 +374,7 @@ class TestAdItem(TestCase): assert super_mock.called def test_prepare_playback_raises_paramater_missing(self): - with patch.object(pm.PlaylistModel, 'prepare_playback') as super_mock: + with patch.object(plm.PlaylistModel, 'prepare_playback') as super_mock: self.result.register_ad = Mock(side_effect=ParameterMissing( 'No ad tracking tokens provided for registration.') @@ -380,7 +384,7 @@ class TestAdItem(TestCase): assert not super_mock.called def test_prepare_playback_handles_paramater_missing_if_no_tokens(self): - with patch.object(pm.PlaylistModel, 'prepare_playback') as super_mock: + with patch.object(plm.PlaylistModel, 'prepare_playback') as super_mock: self.result.tracking_tokens = [] self.result.register_ad = Mock(side_effect=ParameterMissing( @@ -431,7 +435,7 @@ class TestSearchResultItem(TestCase): APIClient.HIGH_AUDIO_QUALITY def test_is_song(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.SONG_JSON_DATA) assert result.is_song assert not result.is_artist @@ -439,7 +443,7 @@ class TestSearchResultItem(TestCase): assert not result.is_genre_station def test_is_artist(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.ARTIST_JSON_DATA) assert not result.is_song assert result.is_artist @@ -447,7 +451,7 @@ class TestSearchResultItem(TestCase): assert not result.is_genre_station def test_is_composer(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.COMPOSER_JSON_DATA) assert not result.is_song assert not result.is_artist @@ -455,7 +459,7 @@ class TestSearchResultItem(TestCase): assert not result.is_genre_station def test_is_genre_station(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.GENRE_JSON_DATA) assert not result.is_song assert not result.is_artist @@ -464,7 +468,7 @@ class TestSearchResultItem(TestCase): def test_fails_if_unknown(self): with self.assertRaises(NotImplementedError): - pm.SearchResultItem.from_json( + sm.SearchResultItem.from_json( self.api_client_mock, self.UNKNOWN_JSON_DATA) @@ -490,14 +494,14 @@ class TestArtistSearchResultItem(TestCase): APIClient.HIGH_AUDIO_QUALITY def test_repr(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.ARTIST_JSON_DATA) expected = ( "ArtistSearchResultItem(artist='artist_name_mock', " "likely_match=False, score=100, token='R0000000')") self.assertEqual(expected, repr(result)) - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.COMPOSER_JSON_DATA) expected = ( "ArtistSearchResultItem(artist='composer_name_mock', " @@ -505,7 +509,7 @@ class TestArtistSearchResultItem(TestCase): self.assertEqual(expected, repr(result)) def test_create_station(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.ARTIST_JSON_DATA) result._api_client.create_station = Mock() @@ -529,7 +533,7 @@ class TestSongSearchResultItem(TestCase): APIClient.HIGH_AUDIO_QUALITY def test_repr(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.SONG_JSON_DATA) expected = ( "SongSearchResultItem(artist='artist_name_mock', score=100, " @@ -537,7 +541,7 @@ class TestSongSearchResultItem(TestCase): self.assertEqual(expected, repr(result)) def test_create_station(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.SONG_JSON_DATA) result._api_client.create_station = Mock() @@ -560,7 +564,7 @@ class TestGenreStationSearchResultItem(TestCase): APIClient.HIGH_AUDIO_QUALITY def test_repr(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.GENRE_JSON_DATA) expected = ( "GenreStationSearchResultItem(score=100, " @@ -568,7 +572,7 @@ class TestGenreStationSearchResultItem(TestCase): self.assertEqual(expected, repr(result)) def test_create_station(self): - result = pm.SearchResultItem.from_json( + result = sm.SearchResultItem.from_json( self.api_client_mock, self.GENRE_JSON_DATA) result._api_client.create_station = Mock() @@ -604,7 +608,7 @@ class TestSearchResult(TestCase): def setUp(self): api_client_mock = Mock(spec=APIClient) api_client_mock.default_audio_quality = APIClient.HIGH_AUDIO_QUALITY - self.result = pm.SearchResult.from_json( + self.result = sm.SearchResult.from_json( api_client_mock, self.JSON_DATA) def test_repr(self): @@ -634,7 +638,7 @@ class TestGenreStationList(TestCase): api_client = Mock() api_client.get_station_list_checksum.return_value = "foo" - stations = pm.GenreStationList.from_json(api_client, self.TEST_DATA) + stations = stm.GenreStationList.from_json(api_client, self.TEST_DATA) self.assertTrue(stations.has_changed()) @@ -649,7 +653,7 @@ class TestStationList(TestCase): api_client = Mock() api_client.get_station_list_checksum.return_value = "foo" - stations = pm.StationList.from_json(api_client, self.TEST_DATA) + stations = stm.StationList.from_json(api_client, self.TEST_DATA) self.assertTrue(stations.has_changed()) @@ -662,31 +666,31 @@ class TestBookmark(TestCase): self.client = Mock() def test_is_song_bookmark(self): - s = pm.Bookmark.from_json(self.client, self.SONG_BOOKMARK) - a = pm.Bookmark.from_json(self.client, self.ARTIST_BOOKMARK) + s = bm.Bookmark.from_json(self.client, self.SONG_BOOKMARK) + a = bm.Bookmark.from_json(self.client, self.ARTIST_BOOKMARK) self.assertTrue(s.is_song_bookmark) self.assertFalse(a.is_song_bookmark) def test_delete_song_bookmark(self): - pm.Bookmark.from_json(self.client, self.SONG_BOOKMARK).delete() + bm.Bookmark.from_json(self.client, self.SONG_BOOKMARK).delete() self.client.delete_song_bookmark.assert_called_with("token") def test_delete_artist_bookmark(self): - pm.Bookmark.from_json(self.client, self.ARTIST_BOOKMARK).delete() + bm.Bookmark.from_json(self.client, self.ARTIST_BOOKMARK).delete() self.client.delete_artist_bookmark.assert_called_with("token") class TestPandoraType(TestCase): def test_it_can_be_built_from_a_model(self): - pt = pm.PandoraType.from_model(None, "TR") - self.assertIs(pm.PandoraType.TRACK, pt) + pt = plm.PandoraType.from_model(None, "TR") + self.assertIs(plm.PandoraType.TRACK, pt) def test_it_can_be_built_from_string(self): - pt = pm.PandoraType.from_string("TR") - self.assertIs(pm.PandoraType.TRACK, pt) + pt = plm.PandoraType.from_string("TR") + self.assertIs(plm.PandoraType.TRACK, pt) def test_it_returns_genre_for_unknown_string(self): - pt = pm.PandoraType.from_string("FOO") - self.assertIs(pm.PandoraType.GENRE, pt) + pt = plm.PandoraType.from_string("FOO") + self.assertIs(plm.PandoraType.GENRE, pt) diff --git a/tests/test_pydora/test_utils.py b/tests/test_pydora/test_utils.py index 5dca83c..475a7b5 100644 --- a/tests/test_pydora/test_utils.py +++ b/tests/test_pydora/test_utils.py @@ -3,7 +3,9 @@ from unittest.mock import Mock, patch from pandora.client import APIClient from pandora.errors import InvalidAuthToken, ParameterMissing -from pandora.models.pandora import Station, AdItem, PlaylistItem +from pandora.models.ad import AdItem +from pandora.models.station import Station +from pandora.models.playlist import PlaylistItem from pydora.utils import iterate_forever -- cgit v1.2.3