diff options
author | Mike Crute <mike@crute.us> | 2019-04-02 03:17:18 +0000 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2019-04-02 03:24:37 +0000 |
commit | 6cc2aa2d628da3bd5df4f765529a1c54ec40561b (patch) | |
tree | e026f777604bd1cd4a426d611aa97a4f0fc968cf /pandora | |
parent | 8d316038441194526d6183c0496f765c6ff5a418 (diff) | |
download | pydora-6cc2aa2d628da3bd5df4f765529a1c54ec40561b.tar.bz2 pydora-6cc2aa2d628da3bd5df4f765529a1c54ec40561b.tar.xz pydora-6cc2aa2d628da3bd5df4f765529a1c54ec40561b.zip |
Reorganize models
Diffstat (limited to 'pandora')
-rw-r--r-- | pandora/client.py | 16 | ||||
-rw-r--r-- | pandora/models/ad.py | 36 | ||||
-rw-r--r-- | pandora/models/bookmark.py | 32 | ||||
-rw-r--r-- | pandora/models/pandora.py | 475 | ||||
-rw-r--r-- | pandora/models/playlist.py | 197 | ||||
-rw-r--r-- | pandora/models/search.py | 93 | ||||
-rw-r--r-- | pandora/models/station.py | 123 |
7 files changed, 489 insertions, 483 deletions
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): | |||
103 | """ | 103 | """ |
104 | 104 | ||
105 | def get_station_list(self): # pragma: no cover | 105 | def get_station_list(self): # pragma: no cover |
106 | from .models.pandora import StationList | 106 | from .models.station import StationList |
107 | 107 | ||
108 | return StationList.from_json(self, | 108 | return StationList.from_json(self, |
109 | self("user.getStationList", | 109 | self("user.getStationList", |
@@ -113,7 +113,7 @@ class APIClient(BaseAPIClient): | |||
113 | return self("user.getStationListChecksum")["checksum"] | 113 | return self("user.getStationListChecksum")["checksum"] |
114 | 114 | ||
115 | def get_playlist(self, station_token, additional_urls=None): | 115 | def get_playlist(self, station_token, additional_urls=None): |
116 | from .models.pandora import Playlist | 116 | from .models.playlist import Playlist |
117 | 117 | ||
118 | if additional_urls is None: | 118 | if additional_urls is None: |
119 | additional_urls = [] | 119 | additional_urls = [] |
@@ -143,13 +143,13 @@ class APIClient(BaseAPIClient): | |||
143 | return playlist | 143 | return playlist |
144 | 144 | ||
145 | def get_bookmarks(self): # pragma: no cover | 145 | def get_bookmarks(self): # pragma: no cover |
146 | from .models.pandora import BookmarkList | 146 | from .models.bookmark import BookmarkList |
147 | 147 | ||
148 | return BookmarkList.from_json(self, | 148 | return BookmarkList.from_json(self, |
149 | self("user.getBookmarks")) | 149 | self("user.getBookmarks")) |
150 | 150 | ||
151 | def get_station(self, station_token): # pragma: no cover | 151 | def get_station(self, station_token): # pragma: no cover |
152 | from .models.pandora import Station | 152 | from .models.station import Station |
153 | 153 | ||
154 | return Station.from_json(self, | 154 | return Station.from_json(self, |
155 | self("station.getStation", | 155 | self("station.getStation", |
@@ -175,7 +175,7 @@ class APIClient(BaseAPIClient): | |||
175 | def search(self, search_text, | 175 | def search(self, search_text, |
176 | include_near_matches=False, | 176 | include_near_matches=False, |
177 | include_genre_stations=False): # pragma: no cover | 177 | include_genre_stations=False): # pragma: no cover |
178 | from .models.pandora import SearchResult | 178 | from .models.search import SearchResult |
179 | 179 | ||
180 | return SearchResult.from_json( | 180 | return SearchResult.from_json( |
181 | self, | 181 | self, |
@@ -197,7 +197,7 @@ class APIClient(BaseAPIClient): | |||
197 | 197 | ||
198 | def create_station(self, search_token=None, artist_token=None, | 198 | def create_station(self, search_token=None, artist_token=None, |
199 | track_token=None): | 199 | track_token=None): |
200 | from .models.pandora import Station | 200 | from .models.station import Station |
201 | 201 | ||
202 | kwargs = {} | 202 | kwargs = {} |
203 | 203 | ||
@@ -226,7 +226,7 @@ class APIClient(BaseAPIClient): | |||
226 | stationToken=station_token) | 226 | stationToken=station_token) |
227 | 227 | ||
228 | def get_genre_stations(self): | 228 | def get_genre_stations(self): |
229 | from .models.pandora import GenreStationList | 229 | from .models.station import GenreStationList |
230 | 230 | ||
231 | genre_stations = GenreStationList.from_json( | 231 | genre_stations = GenreStationList.from_json( |
232 | self, self("station.getGenreStations")) | 232 | self, self("station.getGenreStations")) |
@@ -270,7 +270,7 @@ class APIClient(BaseAPIClient): | |||
270 | email=emails[0]) | 270 | email=emails[0]) |
271 | 271 | ||
272 | def get_ad_item(self, station_id, ad_token): | 272 | def get_ad_item(self, station_id, ad_token): |
273 | from .models.pandora import AdItem | 273 | from .models.ad import AdItem |
274 | 274 | ||
275 | if not station_id: | 275 | if not station_id: |
276 | raise errors.ParameterMissing("The 'station_id' param must be " | 276 | 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 @@ | |||
1 | from ..errors import ParameterMissing | ||
2 | from ._base import Field | ||
3 | from .playlist import PlaylistModel, AudioField | ||
4 | |||
5 | |||
6 | class AdItem(PlaylistModel): | ||
7 | |||
8 | title = Field("title") | ||
9 | company_name = Field("companyName") | ||
10 | tracking_tokens = Field("adTrackingTokens") | ||
11 | audio_url = AudioField("audioUrl") | ||
12 | image_url = Field("imageUrl") | ||
13 | click_through_url = Field("clickThroughUrl") | ||
14 | station_id = None | ||
15 | ad_token = None | ||
16 | |||
17 | @property | ||
18 | def is_ad(self): | ||
19 | return True | ||
20 | |||
21 | def register_ad(self, station_id=None): | ||
22 | if not station_id: | ||
23 | station_id = self.station_id | ||
24 | if self.tracking_tokens: | ||
25 | self._api_client.register_ad(station_id, self.tracking_tokens) | ||
26 | else: | ||
27 | raise ParameterMissing('No ad tracking tokens provided for ' | ||
28 | 'registration.') | ||
29 | |||
30 | def prepare_playback(self): | ||
31 | try: | ||
32 | self.register_ad(self.station_id) | ||
33 | except ParameterMissing as exc: | ||
34 | if self.tracking_tokens: | ||
35 | raise exc | ||
36 | 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 @@ | |||
1 | from ._base import PandoraModel, Field, DateField | ||
2 | |||
3 | |||
4 | class Bookmark(PandoraModel): | ||
5 | |||
6 | music_token = Field("musicToken") | ||
7 | artist_name = Field("artistName") | ||
8 | art_url = Field("artUrl") | ||
9 | bookmark_token = Field("bookmarkToken") | ||
10 | date_created = DateField("dateCreated") | ||
11 | |||
12 | # song only | ||
13 | sample_url = Field("sampleUrl") | ||
14 | sample_gain = Field("sampleGain") | ||
15 | album_name = Field("albumName") | ||
16 | song_name = Field("songName") | ||
17 | |||
18 | @property | ||
19 | def is_song_bookmark(self): | ||
20 | return self.song_name is not None | ||
21 | |||
22 | def delete(self): | ||
23 | if self.is_song_bookmark: | ||
24 | self._api_client.delete_song_bookmark(self.bookmark_token) | ||
25 | else: | ||
26 | self._api_client.delete_artist_bookmark(self.bookmark_token) | ||
27 | |||
28 | |||
29 | class BookmarkList(PandoraModel): | ||
30 | |||
31 | songs = Field("songs", model=Bookmark) | ||
32 | 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 @@ | |||
1 | from enum import Enum | ||
2 | |||
3 | from ..client import BaseAPIClient | ||
4 | from ..errors import ParameterMissing | ||
5 | from ._base import Field, DateField, SyntheticField | ||
6 | from ._base import PandoraModel, PandoraListModel, PandoraDictListModel | ||
7 | |||
8 | |||
9 | class AdditionalAudioUrl(Enum): | ||
10 | HTTP_40_AAC_MONO = 'HTTP_40_AAC_MONO' | ||
11 | HTTP_64_AAC = 'HTTP_64_AAC' | ||
12 | HTTP_32_AACPLUS = 'HTTP_32_AACPLUS' | ||
13 | HTTP_64_AACPLUS = 'HTTP_64_AACPLUS' | ||
14 | HTTP_24_AACPLUS_ADTS = 'HTTP_24_AACPLUS_ADTS' | ||
15 | HTTP_32_AACPLUS_ADTS = 'HTTP_32_AACPLUS_ADTS' | ||
16 | HTTP_64_AACPLUS_ADTS = 'HTTP_64_AACPLUS_ADTS' | ||
17 | HTTP_128_MP3 = 'HTTP_128_MP3' | ||
18 | HTTP_32_WMA = 'HTTP_32_WMA' | ||
19 | |||
20 | |||
21 | class PandoraType(Enum): | ||
22 | |||
23 | TRACK = "TR" | ||
24 | ARTIST = "AR" | ||
25 | GENRE = "GR" | ||
26 | |||
27 | @staticmethod | ||
28 | def from_model(client, value): | ||
29 | return PandoraType.from_string(value) | ||
30 | |||
31 | @staticmethod | ||
32 | def from_string(value): | ||
33 | return { | ||
34 | "TR": PandoraType.TRACK, | ||
35 | "AR": PandoraType.ARTIST, | ||
36 | }.get(value, PandoraType.GENRE) | ||
37 | |||
38 | |||
39 | class Icon(PandoraModel): | ||
40 | |||
41 | dominant_color = Field("dominantColor") | ||
42 | art_url = Field("artUrl") | ||
43 | |||
44 | |||
45 | class StationSeed(PandoraModel): | ||
46 | |||
47 | seed_id = Field("seedId") | ||
48 | music_token = Field("musicToken") | ||
49 | pandora_id = Field("pandoraId") | ||
50 | pandora_type = Field("pandoraType", formatter=PandoraType.from_model) | ||
51 | |||
52 | genre_name = Field("genreName") | ||
53 | song_name = Field("songName") | ||
54 | artist_name = Field("artistName") | ||
55 | art_url = Field("artUrl") | ||
56 | icon = Field("icon", model=Icon) | ||
57 | |||
58 | |||
59 | class StationSeeds(PandoraModel): | ||
60 | |||
61 | genres = Field("genres", model=StationSeed) | ||
62 | songs = Field("songs", model=StationSeed) | ||
63 | artists = Field("artists", model=StationSeed) | ||
64 | |||
65 | |||
66 | class SongFeedback(PandoraModel): | ||
67 | |||
68 | feedback_id = Field("feedbackId") | ||
69 | song_identity = Field("songIdentity") | ||
70 | is_positive = Field("isPositive") | ||
71 | pandora_id = Field("pandoraId") | ||
72 | album_art_url = Field("albumArtUrl") | ||
73 | music_token = Field("musicToken") | ||
74 | song_name = Field("songName") | ||
75 | artist_name = Field("artistName") | ||
76 | pandora_type = Field("pandoraType", formatter=PandoraType.from_model) | ||
77 | date_created = DateField("dateCreated") | ||
78 | |||
79 | |||
80 | class StationFeedback(PandoraModel): | ||
81 | |||
82 | total_thumbs_up = Field("totalThumbsUp") | ||
83 | total_thumbs_down = Field("totalThumbsDown") | ||
84 | thumbs_up = Field("thumbsUp", model=SongFeedback) | ||
85 | thumbs_down = Field("thumbsDown", model=SongFeedback) | ||
86 | |||
87 | |||
88 | class Station(PandoraModel): | ||
89 | |||
90 | can_add_music = Field("allowAddMusic") | ||
91 | can_delete = Field("allowDelete") | ||
92 | can_rename = Field("allowRename") | ||
93 | can_edit_description = Field("allowEditDescription") | ||
94 | process_skips = Field("processSkips") | ||
95 | is_shared = Field("isShared") | ||
96 | is_quickmix = Field("isQuickMix") | ||
97 | is_genre_station = Field("isGenreStation") | ||
98 | is_thumbprint_station = Field("isThumbprint") | ||
99 | |||
100 | art_url = Field("artUrl") | ||
101 | date_created = DateField("dateCreated") | ||
102 | detail_url = Field("stationDetailUrl") | ||
103 | id = Field("stationId") | ||
104 | name = Field("stationName") | ||
105 | sharing_url = Field("stationSharingUrl") | ||
106 | thumb_count = Field("thumbCount") | ||
107 | token = Field("stationToken") | ||
108 | |||
109 | genre = Field("genre", []) | ||
110 | quickmix_stations = Field("quickMixStationIds", []) | ||
111 | |||
112 | seeds = Field("music", model=StationSeeds) | ||
113 | feedback = Field("feedback", model=StationFeedback) | ||
114 | |||
115 | def get_playlist(self, additional_urls=None): | ||
116 | return iter(self._api_client.get_playlist(self.token, | ||
117 | additional_urls)) | ||
118 | |||
119 | |||
120 | class GenreStation(PandoraModel): | ||
121 | |||
122 | id = Field("stationId") | ||
123 | name = Field("stationName") | ||
124 | token = Field("stationToken") | ||
125 | category = Field("categoryName") | ||
126 | |||
127 | def get_playlist(self): # pragma: no cover | ||
128 | raise NotImplementedError("Genre stations do not have playlists. " | ||
129 | "Create a real station using the token.") | ||
130 | |||
131 | |||
132 | class StationList(PandoraListModel): | ||
133 | |||
134 | checksum = Field("checksum") | ||
135 | |||
136 | __index_key__ = "id" | ||
137 | __list_key__ = "stations" | ||
138 | __list_model__ = Station | ||
139 | |||
140 | def has_changed(self): | ||
141 | checksum = self._api_client.get_station_list_checksum() | ||
142 | return checksum != self.checksum | ||
143 | |||
144 | |||
145 | class AudioField(SyntheticField): | ||
146 | |||
147 | def formatter(self, api_client, data, value): | ||
148 | """Get audio-related fields | ||
149 | |||
150 | Try to find fields for the audio url for specified preferred quality | ||
151 | level, or next-lowest available quality url otherwise. | ||
152 | """ | ||
153 | url_map = data.get("audioUrlMap") | ||
154 | audio_url = data.get("audioUrl") | ||
155 | |||
156 | # Only an audio URL, not a quality map. This happens for most of the | ||
157 | # mobile client tokens and some of the others now. In this case | ||
158 | # substitute the empirically determined default values in the format | ||
159 | # used by the rest of the function so downstream consumers continue to | ||
160 | # work. | ||
161 | if audio_url and not url_map: | ||
162 | url_map = { | ||
163 | BaseAPIClient.HIGH_AUDIO_QUALITY: { | ||
164 | "audioUrl": audio_url, | ||
165 | "bitrate": 64, | ||
166 | "encoding": "aacplus", | ||
167 | } | ||
168 | } | ||
169 | elif not url_map: # No audio url available (e.g. ad tokens) | ||
170 | return None | ||
171 | |||
172 | valid_audio_formats = [BaseAPIClient.HIGH_AUDIO_QUALITY, | ||
173 | BaseAPIClient.MED_AUDIO_QUALITY, | ||
174 | BaseAPIClient.LOW_AUDIO_QUALITY] | ||
175 | |||
176 | # Only iterate over sublist, starting at preferred audio quality, or | ||
177 | # from the beginning of the list if nothing is found. Ensures that the | ||
178 | # bitrate used will always be the same or lower quality than was | ||
179 | # specified to prevent audio from skipping for slow connections. | ||
180 | preferred_quality = api_client.default_audio_quality | ||
181 | if preferred_quality in valid_audio_formats: | ||
182 | i = valid_audio_formats.index(preferred_quality) | ||
183 | valid_audio_formats = valid_audio_formats[i:] | ||
184 | |||
185 | for quality in valid_audio_formats: | ||
186 | audio_url = url_map.get(quality) | ||
187 | |||
188 | if audio_url: | ||
189 | return audio_url[self.field] | ||
190 | |||
191 | return audio_url[self.field] if audio_url else None | ||
192 | |||
193 | |||
194 | class AdditionalUrlField(SyntheticField): | ||
195 | |||
196 | def formatter(self, api_client, data, value): | ||
197 | """Parse additional url fields and map them to inputs | ||
198 | |||
199 | Attempt to create a dictionary with keys being user input, and | ||
200 | response being the returned URL | ||
201 | """ | ||
202 | if value is None: | ||
203 | return None | ||
204 | |||
205 | user_param = data['_paramAdditionalUrls'] | ||
206 | urls = {} | ||
207 | if isinstance(value, str): | ||
208 | urls[user_param[0]] = value | ||
209 | else: | ||
210 | for key, url in zip(user_param, value): | ||
211 | urls[key] = url | ||
212 | return urls | ||
213 | |||
214 | |||
215 | class PlaylistModel(PandoraModel): | ||
216 | |||
217 | def get_is_playable(self): | ||
218 | if not self.audio_url: | ||
219 | return False | ||
220 | return self._api_client.transport.test_url(self.audio_url) | ||
221 | |||
222 | def prepare_playback(self): | ||
223 | """Prepare Track for Playback | ||
224 | |||
225 | This method must be called by clients before beginning playback | ||
226 | otherwise the track recieved may not be playable. | ||
227 | """ | ||
228 | return self | ||
229 | |||
230 | def thumbs_up(self): # pragma: no cover | ||
231 | raise NotImplementedError | ||
232 | |||
233 | def thumbs_down(self): # pragma: no cover | ||
234 | raise NotImplementedError | ||
235 | |||
236 | def bookmark_song(self): # pragma: no cover | ||
237 | raise NotImplementedError | ||
238 | |||
239 | def bookmark_artist(self): # pragma: no cover | ||
240 | raise NotImplementedError | ||
241 | |||
242 | def sleep(self): # pragma: no cover | ||
243 | raise NotImplementedError | ||
244 | |||
245 | |||
246 | class PlaylistItem(PlaylistModel): | ||
247 | |||
248 | artist_name = Field("artistName") | ||
249 | album_name = Field("albumName") | ||
250 | song_name = Field("songName") | ||
251 | song_rating = Field("songRating") | ||
252 | track_gain = Field("trackGain") | ||
253 | track_length = Field("trackLength") | ||
254 | track_token = Field("trackToken") | ||
255 | audio_url = AudioField("audioUrl") | ||
256 | bitrate = AudioField("bitrate") | ||
257 | encoding = AudioField("encoding") | ||
258 | album_art_url = Field("albumArtUrl") | ||
259 | allow_feedback = Field("allowFeedback") | ||
260 | station_id = Field("stationId") | ||
261 | |||
262 | ad_token = Field("adToken") | ||
263 | |||
264 | album_detail_url = Field("albumDetailUrl") | ||
265 | album_explore_url = Field("albumExplorerUrl") | ||
266 | |||
267 | amazon_album_asin = Field("amazonAlbumAsin") | ||
268 | amazon_album_digital_asin = Field("amazonAlbumDigitalAsin") | ||
269 | amazon_album_url = Field("amazonAlbumUrl") | ||
270 | amazon_song_digital_asin = Field("amazonSongDigitalAsin") | ||
271 | |||
272 | artist_detail_url = Field("artistDetailUrl") | ||
273 | artist_explore_url = Field("artistExplorerUrl") | ||
274 | |||
275 | itunes_song_url = Field("itunesSongUrl") | ||
276 | |||
277 | song_detail_url = Field("songDetailUrl") | ||
278 | song_explore_url = Field("songExplorerUrl") | ||
279 | |||
280 | additional_audio_urls = AdditionalUrlField("additionalAudioUrl") | ||
281 | |||
282 | @property | ||
283 | def is_ad(self): | ||
284 | return self.ad_token is not None | ||
285 | |||
286 | def thumbs_up(self): # pragma: no cover | ||
287 | return self._api_client.add_feedback(self.track_token, True) | ||
288 | |||
289 | def thumbs_down(self): # pragma: no cover | ||
290 | return self._api_client.add_feedback(self.track_token, False) | ||
291 | |||
292 | def bookmark_song(self): # pragma: no cover | ||
293 | return self._api_client.add_song_bookmark(self.track_token) | ||
294 | |||
295 | def bookmark_artist(self): # pragma: no cover | ||
296 | return self._api_client.add_artist_bookmark(self.track_token) | ||
297 | |||
298 | def sleep(self): # pragma: no cover | ||
299 | return self._api_client.sleep_song(self.track_token) | ||
300 | |||
301 | |||
302 | class AdItem(PlaylistModel): | ||
303 | |||
304 | title = Field("title") | ||
305 | company_name = Field("companyName") | ||
306 | tracking_tokens = Field("adTrackingTokens") | ||
307 | audio_url = AudioField("audioUrl") | ||
308 | image_url = Field("imageUrl") | ||
309 | click_through_url = Field("clickThroughUrl") | ||
310 | station_id = None | ||
311 | ad_token = None | ||
312 | |||
313 | @property | ||
314 | def is_ad(self): | ||
315 | return True | ||
316 | |||
317 | def register_ad(self, station_id=None): | ||
318 | if not station_id: | ||
319 | station_id = self.station_id | ||
320 | if self.tracking_tokens: | ||
321 | self._api_client.register_ad(station_id, self.tracking_tokens) | ||
322 | else: | ||
323 | raise ParameterMissing('No ad tracking tokens provided for ' | ||
324 | 'registration.') | ||
325 | |||
326 | def prepare_playback(self): | ||
327 | try: | ||
328 | self.register_ad(self.station_id) | ||
329 | except ParameterMissing as exc: | ||
330 | if self.tracking_tokens: | ||
331 | raise exc | ||
332 | return super(AdItem, self).prepare_playback() | ||
333 | |||
334 | |||
335 | class Playlist(PandoraListModel): | ||
336 | |||
337 | __list_key__ = "items" | ||
338 | __list_model__ = PlaylistItem | ||
339 | |||
340 | |||
341 | class Bookmark(PandoraModel): | ||
342 | |||
343 | music_token = Field("musicToken") | ||
344 | artist_name = Field("artistName") | ||
345 | art_url = Field("artUrl") | ||
346 | bookmark_token = Field("bookmarkToken") | ||
347 | date_created = DateField("dateCreated") | ||
348 | |||
349 | # song only | ||
350 | sample_url = Field("sampleUrl") | ||
351 | sample_gain = Field("sampleGain") | ||
352 | album_name = Field("albumName") | ||
353 | song_name = Field("songName") | ||
354 | |||
355 | @property | ||
356 | def is_song_bookmark(self): | ||
357 | return self.song_name is not None | ||
358 | |||
359 | def delete(self): | ||
360 | if self.is_song_bookmark: | ||
361 | self._api_client.delete_song_bookmark(self.bookmark_token) | ||
362 | else: | ||
363 | self._api_client.delete_artist_bookmark(self.bookmark_token) | ||
364 | |||
365 | |||
366 | class BookmarkList(PandoraModel): | ||
367 | |||
368 | songs = Field("songs", model=Bookmark) | ||
369 | artists = Field("artists", model=Bookmark) | ||
370 | |||
371 | |||
372 | class SearchResultItem(PandoraModel): | ||
373 | |||
374 | score = Field("score") | ||
375 | token = Field("musicToken") | ||
376 | |||
377 | @property | ||
378 | def is_song(self): | ||
379 | return isinstance(self, SongSearchResultItem) | ||
380 | |||
381 | @property | ||
382 | def is_artist(self): | ||
383 | return isinstance(self, ArtistSearchResultItem) and \ | ||
384 | self.token.startswith("R") | ||
385 | |||
386 | @property | ||
387 | def is_composer(self): | ||
388 | return isinstance(self, ArtistSearchResultItem) and \ | ||
389 | self.token.startswith("C") | ||
390 | |||
391 | @property | ||
392 | def is_genre_station(self): | ||
393 | return isinstance(self, GenreStationSearchResultItem) | ||
394 | |||
395 | def create_station(self): # pragma: no cover | ||
396 | raise NotImplementedError | ||
397 | |||
398 | @classmethod | ||
399 | def from_json(cls, api_client, data): | ||
400 | if data["musicToken"].startswith("S"): | ||
401 | return SongSearchResultItem.from_json(api_client, data) | ||
402 | elif data["musicToken"].startswith(("R", "C")): | ||
403 | return ArtistSearchResultItem.from_json(api_client, data) | ||
404 | elif data["musicToken"].startswith("G"): | ||
405 | return GenreStationSearchResultItem.from_json(api_client, data) | ||
406 | else: | ||
407 | raise NotImplementedError("Unknown result token type '{}'" | ||
408 | .format(data["musicToken"])) | ||
409 | |||
410 | |||
411 | class ArtistSearchResultItem(SearchResultItem): | ||
412 | |||
413 | score = Field("score") | ||
414 | token = Field("musicToken") | ||
415 | artist = Field("artistName") | ||
416 | likely_match = Field("likelyMatch", default=False) | ||
417 | |||
418 | def create_station(self): | ||
419 | self._api_client.create_station(artist_token=self.token) | ||
420 | |||
421 | @classmethod | ||
422 | def from_json(cls, api_client, data): | ||
423 | return super(SearchResultItem, cls).from_json(api_client, data) | ||
424 | |||
425 | |||
426 | class SongSearchResultItem(SearchResultItem): | ||
427 | |||
428 | score = Field("score") | ||
429 | token = Field("musicToken") | ||
430 | artist = Field("artistName") | ||
431 | song_name = Field("songName") | ||
432 | |||
433 | def create_station(self): | ||
434 | self._api_client.create_station(track_token=self.token) | ||
435 | |||
436 | @classmethod | ||
437 | def from_json(cls, api_client, data): | ||
438 | return super(SearchResultItem, cls).from_json(api_client, data) | ||
439 | |||
440 | |||
441 | class GenreStationSearchResultItem(SearchResultItem): | ||
442 | |||
443 | score = Field("score") | ||
444 | token = Field("musicToken") | ||
445 | station_name = Field("stationName") | ||
446 | |||
447 | def create_station(self): | ||
448 | self._api_client.create_station(search_token=self.token) | ||
449 | |||
450 | @classmethod | ||
451 | def from_json(cls, api_client, data): | ||
452 | return super(SearchResultItem, cls).from_json(api_client, data) | ||
453 | |||
454 | |||
455 | class SearchResult(PandoraModel): | ||
456 | |||
457 | nearest_matches_available = Field("nearMatchesAvailable") | ||
458 | explanation = Field("explanation") | ||
459 | songs = Field("songs", model=SongSearchResultItem) | ||
460 | artists = Field("artists", model=ArtistSearchResultItem) | ||
461 | genre_stations = Field("genreStations", model=GenreStationSearchResultItem) | ||
462 | |||
463 | |||
464 | class GenreStationList(PandoraDictListModel): | ||
465 | |||
466 | checksum = Field("checksum") | ||
467 | |||
468 | __dict_list_key__ = "categories" | ||
469 | __dict_key__ = "categoryName" | ||
470 | __list_key__ = "stations" | ||
471 | __list_model__ = GenreStation | ||
472 | |||
473 | def has_changed(self): | ||
474 | checksum = self._api_client.get_station_list_checksum() | ||
475 | 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 @@ | |||
1 | from enum import Enum | ||
2 | |||
3 | from ..client import BaseAPIClient | ||
4 | from ._base import Field, SyntheticField, PandoraModel, PandoraListModel | ||
5 | |||
6 | |||
7 | class AdditionalAudioUrl(Enum): | ||
8 | HTTP_40_AAC_MONO = 'HTTP_40_AAC_MONO' | ||
9 | HTTP_64_AAC = 'HTTP_64_AAC' | ||
10 | HTTP_32_AACPLUS = 'HTTP_32_AACPLUS' | ||
11 | HTTP_64_AACPLUS = 'HTTP_64_AACPLUS' | ||
12 | HTTP_24_AACPLUS_ADTS = 'HTTP_24_AACPLUS_ADTS' | ||
13 | HTTP_32_AACPLUS_ADTS = 'HTTP_32_AACPLUS_ADTS' | ||
14 | HTTP_64_AACPLUS_ADTS = 'HTTP_64_AACPLUS_ADTS' | ||
15 | HTTP_128_MP3 = 'HTTP_128_MP3' | ||
16 | HTTP_32_WMA = 'HTTP_32_WMA' | ||
17 | |||
18 | |||
19 | class PandoraType(Enum): | ||
20 | |||
21 | TRACK = "TR" | ||
22 | ARTIST = "AR" | ||
23 | GENRE = "GR" | ||
24 | |||
25 | @staticmethod | ||
26 | def from_model(client, value): | ||
27 | return PandoraType.from_string(value) | ||
28 | |||
29 | @staticmethod | ||
30 | def from_string(value): | ||
31 | return { | ||
32 | "TR": PandoraType.TRACK, | ||
33 | "AR": PandoraType.ARTIST, | ||
34 | }.get(value, PandoraType.GENRE) | ||
35 | |||
36 | |||
37 | class AudioField(SyntheticField): | ||
38 | |||
39 | def formatter(self, api_client, data, value): | ||
40 | """Get audio-related fields | ||
41 | |||
42 | Try to find fields for the audio url for specified preferred quality | ||
43 | level, or next-lowest available quality url otherwise. | ||
44 | """ | ||
45 | url_map = data.get("audioUrlMap") | ||
46 | audio_url = data.get("audioUrl") | ||
47 | |||
48 | # Only an audio URL, not a quality map. This happens for most of the | ||
49 | # mobile client tokens and some of the others now. In this case | ||
50 | # substitute the empirically determined default values in the format | ||
51 | # used by the rest of the function so downstream consumers continue to | ||
52 | # work. | ||
53 | if audio_url and not url_map: | ||
54 | url_map = { | ||
55 | BaseAPIClient.HIGH_AUDIO_QUALITY: { | ||
56 | "audioUrl": audio_url, | ||
57 | "bitrate": 64, | ||
58 | "encoding": "aacplus", | ||
59 | } | ||
60 | } | ||
61 | elif not url_map: # No audio url available (e.g. ad tokens) | ||
62 | return None | ||
63 | |||
64 | valid_audio_formats = [BaseAPIClient.HIGH_AUDIO_QUALITY, | ||
65 | BaseAPIClient.MED_AUDIO_QUALITY, | ||
66 | BaseAPIClient.LOW_AUDIO_QUALITY] | ||
67 | |||
68 | # Only iterate over sublist, starting at preferred audio quality, or | ||
69 | # from the beginning of the list if nothing is found. Ensures that the | ||
70 | # bitrate used will always be the same or lower quality than was | ||
71 | # specified to prevent audio from skipping for slow connections. | ||
72 | preferred_quality = api_client.default_audio_quality | ||
73 | if preferred_quality in valid_audio_formats: | ||
74 | i = valid_audio_formats.index(preferred_quality) | ||
75 | valid_audio_formats = valid_audio_formats[i:] | ||
76 | |||
77 | for quality in valid_audio_formats: | ||
78 | audio_url = url_map.get(quality) | ||
79 | |||
80 | if audio_url: | ||
81 | return audio_url[self.field] | ||
82 | |||
83 | return audio_url[self.field] if audio_url else None | ||
84 | |||
85 | |||
86 | class AdditionalUrlField(SyntheticField): | ||
87 | |||
88 | def formatter(self, api_client, data, value): | ||
89 | """Parse additional url fields and map them to inputs | ||
90 | |||
91 | Attempt to create a dictionary with keys being user input, and | ||
92 | response being the returned URL | ||
93 | """ | ||
94 | if value is None: | ||
95 | return None | ||
96 | |||
97 | user_param = data['_paramAdditionalUrls'] | ||
98 | urls = {} | ||
99 | if isinstance(value, str): | ||
100 | urls[user_param[0]] = value | ||
101 | else: | ||
102 | for key, url in zip(user_param, value): | ||
103 | urls[key] = url | ||
104 | return urls | ||
105 | |||
106 | |||
107 | class PlaylistModel(PandoraModel): | ||
108 | |||
109 | def get_is_playable(self): | ||
110 | if not self.audio_url: | ||
111 | return False | ||
112 | return self._api_client.transport.test_url(self.audio_url) | ||
113 | |||
114 | def prepare_playback(self): | ||
115 | """Prepare Track for Playback | ||
116 | |||
117 | This method must be called by clients before beginning playback | ||
118 | otherwise the track recieved may not be playable. | ||
119 | """ | ||
120 | return self | ||
121 | |||
122 | def thumbs_up(self): # pragma: no cover | ||
123 | raise NotImplementedError | ||
124 | |||
125 | def thumbs_down(self): # pragma: no cover | ||
126 | raise NotImplementedError | ||
127 | |||
128 | def bookmark_song(self): # pragma: no cover | ||
129 | raise NotImplementedError | ||
130 | |||
131 | def bookmark_artist(self): # pragma: no cover | ||
132 | raise NotImplementedError | ||
133 | |||
134 | def sleep(self): # pragma: no cover | ||
135 | raise NotImplementedError | ||
136 | |||
137 | |||
138 | class PlaylistItem(PlaylistModel): | ||
139 | |||
140 | artist_name = Field("artistName") | ||
141 | album_name = Field("albumName") | ||
142 | song_name = Field("songName") | ||
143 | song_rating = Field("songRating") | ||
144 | track_gain = Field("trackGain") | ||
145 | track_length = Field("trackLength") | ||
146 | track_token = Field("trackToken") | ||
147 | audio_url = AudioField("audioUrl") | ||
148 | bitrate = AudioField("bitrate") | ||
149 | encoding = AudioField("encoding") | ||
150 | album_art_url = Field("albumArtUrl") | ||
151 | allow_feedback = Field("allowFeedback") | ||
152 | station_id = Field("stationId") | ||
153 | |||
154 | ad_token = Field("adToken") | ||
155 | |||
156 | album_detail_url = Field("albumDetailUrl") | ||
157 | album_explore_url = Field("albumExplorerUrl") | ||
158 | |||
159 | amazon_album_asin = Field("amazonAlbumAsin") | ||
160 | amazon_album_digital_asin = Field("amazonAlbumDigitalAsin") | ||
161 | amazon_album_url = Field("amazonAlbumUrl") | ||
162 | amazon_song_digital_asin = Field("amazonSongDigitalAsin") | ||
163 | |||
164 | artist_detail_url = Field("artistDetailUrl") | ||
165 | artist_explore_url = Field("artistExplorerUrl") | ||
166 | |||
167 | itunes_song_url = Field("itunesSongUrl") | ||
168 | |||
169 | song_detail_url = Field("songDetailUrl") | ||
170 | song_explore_url = Field("songExplorerUrl") | ||
171 | |||
172 | additional_audio_urls = AdditionalUrlField("additionalAudioUrl") | ||
173 | |||
174 | @property | ||
175 | def is_ad(self): | ||
176 | return self.ad_token is not None | ||
177 | |||
178 | def thumbs_up(self): # pragma: no cover | ||
179 | return self._api_client.add_feedback(self.track_token, True) | ||
180 | |||
181 | def thumbs_down(self): # pragma: no cover | ||
182 | return self._api_client.add_feedback(self.track_token, False) | ||
183 | |||
184 | def bookmark_song(self): # pragma: no cover | ||
185 | return self._api_client.add_song_bookmark(self.track_token) | ||
186 | |||
187 | def bookmark_artist(self): # pragma: no cover | ||
188 | return self._api_client.add_artist_bookmark(self.track_token) | ||
189 | |||
190 | def sleep(self): # pragma: no cover | ||
191 | return self._api_client.sleep_song(self.track_token) | ||
192 | |||
193 | |||
194 | class Playlist(PandoraListModel): | ||
195 | |||
196 | __list_key__ = "items" | ||
197 | __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 @@ | |||
1 | from ._base import Field, PandoraModel | ||
2 | |||
3 | |||
4 | class SearchResultItem(PandoraModel): | ||
5 | |||
6 | score = Field("score") | ||
7 | token = Field("musicToken") | ||
8 | |||
9 | @property | ||
10 | def is_song(self): | ||
11 | return isinstance(self, SongSearchResultItem) | ||
12 | |||
13 | @property | ||
14 | def is_artist(self): | ||
15 | return isinstance(self, ArtistSearchResultItem) and \ | ||
16 | self.token.startswith("R") | ||
17 | |||
18 | @property | ||
19 | def is_composer(self): | ||
20 | return isinstance(self, ArtistSearchResultItem) and \ | ||
21 | self.token.startswith("C") | ||
22 | |||
23 | @property | ||
24 | def is_genre_station(self): | ||
25 | return isinstance(self, GenreStationSearchResultItem) | ||
26 | |||
27 | def create_station(self): # pragma: no cover | ||
28 | raise NotImplementedError | ||
29 | |||
30 | @classmethod | ||
31 | def from_json(cls, api_client, data): | ||
32 | if data["musicToken"].startswith("S"): | ||
33 | return SongSearchResultItem.from_json(api_client, data) | ||
34 | elif data["musicToken"].startswith(("R", "C")): | ||
35 | return ArtistSearchResultItem.from_json(api_client, data) | ||
36 | elif data["musicToken"].startswith("G"): | ||
37 | return GenreStationSearchResultItem.from_json(api_client, data) | ||
38 | else: | ||
39 | raise NotImplementedError("Unknown result token type '{}'" | ||
40 | .format(data["musicToken"])) | ||
41 | |||
42 | |||
43 | class ArtistSearchResultItem(SearchResultItem): | ||
44 | |||
45 | score = Field("score") | ||
46 | token = Field("musicToken") | ||
47 | artist = Field("artistName") | ||
48 | likely_match = Field("likelyMatch", default=False) | ||
49 | |||
50 | def create_station(self): | ||
51 | self._api_client.create_station(artist_token=self.token) | ||
52 | |||
53 | @classmethod | ||
54 | def from_json(cls, api_client, data): | ||
55 | return super(SearchResultItem, cls).from_json(api_client, data) | ||
56 | |||
57 | |||
58 | class SongSearchResultItem(SearchResultItem): | ||
59 | |||
60 | score = Field("score") | ||
61 | token = Field("musicToken") | ||
62 | artist = Field("artistName") | ||
63 | song_name = Field("songName") | ||
64 | |||
65 | def create_station(self): | ||
66 | self._api_client.create_station(track_token=self.token) | ||
67 | |||
68 | @classmethod | ||
69 | def from_json(cls, api_client, data): | ||
70 | return super(SearchResultItem, cls).from_json(api_client, data) | ||
71 | |||
72 | |||
73 | class GenreStationSearchResultItem(SearchResultItem): | ||
74 | |||
75 | score = Field("score") | ||
76 | token = Field("musicToken") | ||
77 | station_name = Field("stationName") | ||
78 | |||
79 | def create_station(self): | ||
80 | self._api_client.create_station(search_token=self.token) | ||
81 | |||
82 | @classmethod | ||
83 | def from_json(cls, api_client, data): | ||
84 | return super(SearchResultItem, cls).from_json(api_client, data) | ||
85 | |||
86 | |||
87 | class SearchResult(PandoraModel): | ||
88 | |||
89 | nearest_matches_available = Field("nearMatchesAvailable") | ||
90 | explanation = Field("explanation") | ||
91 | songs = Field("songs", model=SongSearchResultItem) | ||
92 | artists = Field("artists", model=ArtistSearchResultItem) | ||
93 | 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 @@ | |||
1 | from ._base import Field, DateField | ||
2 | from ._base import PandoraModel, PandoraListModel, PandoraDictListModel | ||
3 | from .playlist import PandoraType | ||
4 | |||
5 | |||
6 | class Icon(PandoraModel): | ||
7 | |||
8 | dominant_color = Field("dominantColor") | ||
9 | art_url = Field("artUrl") | ||
10 | |||
11 | |||
12 | class StationSeed(PandoraModel): | ||
13 | |||
14 | seed_id = Field("seedId") | ||
15 | music_token = Field("musicToken") | ||
16 | pandora_id = Field("pandoraId") | ||
17 | pandora_type = Field("pandoraType", formatter=PandoraType.from_model) | ||
18 | |||
19 | genre_name = Field("genreName") | ||
20 | song_name = Field("songName") | ||
21 | artist_name = Field("artistName") | ||
22 | art_url = Field("artUrl") | ||
23 | icon = Field("icon", model=Icon) | ||
24 | |||
25 | |||
26 | class StationSeeds(PandoraModel): | ||
27 | |||
28 | genres = Field("genres", model=StationSeed) | ||
29 | songs = Field("songs", model=StationSeed) | ||
30 | artists = Field("artists", model=StationSeed) | ||
31 | |||
32 | |||
33 | class SongFeedback(PandoraModel): | ||
34 | |||
35 | feedback_id = Field("feedbackId") | ||
36 | song_identity = Field("songIdentity") | ||
37 | is_positive = Field("isPositive") | ||
38 | pandora_id = Field("pandoraId") | ||
39 | album_art_url = Field("albumArtUrl") | ||
40 | music_token = Field("musicToken") | ||
41 | song_name = Field("songName") | ||
42 | artist_name = Field("artistName") | ||
43 | pandora_type = Field("pandoraType", formatter=PandoraType.from_model) | ||
44 | date_created = DateField("dateCreated") | ||
45 | |||
46 | |||
47 | class StationFeedback(PandoraModel): | ||
48 | |||
49 | total_thumbs_up = Field("totalThumbsUp") | ||
50 | total_thumbs_down = Field("totalThumbsDown") | ||
51 | thumbs_up = Field("thumbsUp", model=SongFeedback) | ||
52 | thumbs_down = Field("thumbsDown", model=SongFeedback) | ||
53 | |||
54 | |||
55 | class Station(PandoraModel): | ||
56 | |||
57 | can_add_music = Field("allowAddMusic") | ||
58 | can_delete = Field("allowDelete") | ||
59 | can_rename = Field("allowRename") | ||
60 | can_edit_description = Field("allowEditDescription") | ||
61 | process_skips = Field("processSkips") | ||
62 | is_shared = Field("isShared") | ||
63 | is_quickmix = Field("isQuickMix") | ||
64 | is_genre_station = Field("isGenreStation") | ||
65 | is_thumbprint_station = Field("isThumbprint") | ||
66 | |||
67 | art_url = Field("artUrl") | ||
68 | date_created = DateField("dateCreated") | ||
69 | detail_url = Field("stationDetailUrl") | ||
70 | id = Field("stationId") | ||
71 | name = Field("stationName") | ||
72 | sharing_url = Field("stationSharingUrl") | ||
73 | thumb_count = Field("thumbCount") | ||
74 | token = Field("stationToken") | ||
75 | |||
76 | genre = Field("genre", []) | ||
77 | quickmix_stations = Field("quickMixStationIds", []) | ||
78 | |||
79 | seeds = Field("music", model=StationSeeds) | ||
80 | feedback = Field("feedback", model=StationFeedback) | ||
81 | |||
82 | def get_playlist(self, additional_urls=None): | ||
83 | return iter(self._api_client.get_playlist(self.token, | ||
84 | additional_urls)) | ||
85 | |||
86 | |||
87 | class StationList(PandoraListModel): | ||
88 | |||
89 | checksum = Field("checksum") | ||
90 | |||
91 | __index_key__ = "id" | ||
92 | __list_key__ = "stations" | ||
93 | __list_model__ = Station | ||
94 | |||
95 | def has_changed(self): | ||
96 | checksum = self._api_client.get_station_list_checksum() | ||
97 | return checksum != self.checksum | ||
98 | |||
99 | |||
100 | class GenreStation(PandoraModel): | ||
101 | |||
102 | id = Field("stationId") | ||
103 | name = Field("stationName") | ||
104 | token = Field("stationToken") | ||
105 | category = Field("categoryName") | ||
106 | |||
107 | def get_playlist(self): # pragma: no cover | ||
108 | raise NotImplementedError("Genre stations do not have playlists. " | ||
109 | "Create a real station using the token.") | ||
110 | |||
111 | |||
112 | class GenreStationList(PandoraDictListModel): | ||
113 | |||
114 | checksum = Field("checksum") | ||
115 | |||
116 | __dict_list_key__ = "categories" | ||
117 | __dict_key__ = "categoryName" | ||
118 | __list_key__ = "stations" | ||
119 | __list_model__ = GenreStation | ||
120 | |||
121 | def has_changed(self): | ||
122 | checksum = self._api_client.get_station_list_checksum() | ||
123 | return checksum != self.checksum | ||