Merge pull request #19 from auouymous/setuptools
[mygpoclient.git] / mygpoclient / api.py
blobdec30b16d96b1ab91116f9e198baf1eb74b4aaec
1 # -*- coding: utf-8 -*-
2 # gpodder.net API Client
3 # Copyright (C) 2009-2013 Thomas Perl and the gPodder Team
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 try:
19 # Python 2
20 str = unicode
21 except BaseException:
22 # Python 3
23 pass
25 from mygpoclient import util
26 from mygpoclient import simple
27 from mygpoclient import public
30 # Additional error types for the advanced API client
31 class InvalidResponse(Exception):
32 pass
35 class UpdateResult(object):
36 """Container for subscription update results
38 Attributes:
39 update_urls - A list of (old_url, new_url) tuples
40 since - A timestamp value for use in future requests
41 """
43 def __init__(self, update_urls, since):
44 self.update_urls = update_urls
45 self.since = since
48 class SubscriptionChanges(object):
49 """Container for subscription changes
51 Attributes:
52 add - A list of URLs that have been added
53 remove - A list of URLs that have been removed
54 since - A timestamp value for use in future requests
55 """
57 def __init__(self, add, remove, since):
58 self.add = add
59 self.remove = remove
60 self.since = since
63 class EpisodeActionChanges(object):
64 """Container for added episode actions
66 Attributes:
67 actions - A list of EpisodeAction objects
68 since - A timestamp value for use in future requests
69 """
71 def __init__(self, actions, since):
72 self.actions = actions
73 self.since = since
76 class PodcastDevice(object):
77 """This class encapsulates a podcast device
79 Attributes:
80 device_id - The ID used to refer to this device
81 caption - A user-defined "name" for this device
82 type - A valid type of podcast device (see VALID_TYPES)
83 subscriptions - The number of podcasts this device is subscribed to
84 """
85 VALID_TYPES = ('desktop', 'laptop', 'mobile', 'server', 'other')
87 def __init__(self, device_id, caption, type, subscriptions):
88 # Check if the device type is valid
89 if type not in self.VALID_TYPES:
90 raise ValueError(
91 'Invalid device type "%s" (see VALID_TYPES)' %
92 type)
94 # Check if subscriptions is a numeric value
95 try:
96 int(subscriptions)
97 except BaseException:
98 raise ValueError(
99 'Subscription must be a numeric value but was %s' %
100 subscriptions)
102 self.device_id = device_id
103 self.caption = caption
104 self.type = type
105 self.subscriptions = int(subscriptions)
107 def __str__(self):
108 """String representation of this device
110 >>> device = PodcastDevice('mygpo', 'My Device', 'mobile', 10)
111 >>> print(device)
112 PodcastDevice('mygpo', 'My Device', 'mobile', 10)
114 return '%s(%r, %r, %r, %r)' % (self.__class__.__name__,
115 self.device_id, self.caption, self.type, self.subscriptions)
117 @classmethod
118 def from_dictionary(cls, d):
119 return cls(d['id'], d['caption'], d['type'], d['subscriptions'])
122 class EpisodeAction(object):
123 """This class encapsulates an episode action
125 The mandatory attributes are:
126 podcast - The feed URL of the podcast
127 episode - The enclosure URL or GUID of the episode
128 action - One of 'download', 'play', 'delete' or 'new'
130 The optional attributes are:
131 device - The device_id on which the action has taken place
132 timestamp - When the action took place (in XML time format)
133 started - The start time of a play event in seconds
134 position - The current position of a play event in seconds
135 total - The total time of the episode (for play events)
137 The attribute "position" is only valid for "play" action types.
139 VALID_ACTIONS = ('download', 'play', 'delete', 'new', 'flattr')
141 def __init__(self, podcast, episode, action,
142 device=None, timestamp=None,
143 started=None, position=None, total=None):
144 # Check if the action is valid
145 if action not in self.VALID_ACTIONS:
146 raise ValueError(
147 'Invalid action type "%s" (see VALID_ACTIONS)' %
148 action)
150 # Disallow play-only attributes for non-play actions
151 if action != 'play':
152 if started is not None:
153 raise ValueError(
154 'Started can only be set for the "play" action')
155 elif position is not None:
156 raise ValueError(
157 'Position can only be set for the "play" action')
158 elif total is not None:
159 raise ValueError('Total can only be set for the "play" action')
161 # Check the format of the timestamp value
162 if timestamp is not None:
163 if util.iso8601_to_datetime(timestamp) is None:
164 raise ValueError(
165 'Timestamp has to be in ISO 8601 format but was %s' %
166 timestamp)
168 # Check if we have a "position" value if we have started or total
169 if position is None and (started is not None or total is not None):
170 raise ValueError('Started or total set, but no position given')
172 # Check that "started" is a number if it's set
173 if started is not None:
174 try:
175 started = int(started)
176 except ValueError:
177 raise ValueError(
178 'Started must be an integer value (seconds) but was %s' %
179 started)
181 # Check that "position" is a number if it's set
182 if position is not None:
183 try:
184 position = int(position)
185 except ValueError:
186 raise ValueError(
187 'Position must be an integer value (seconds) but was %s' %
188 position)
190 # Check that "total" is a number if it's set
191 if total is not None:
192 try:
193 total = int(total)
194 except ValueError:
195 raise ValueError(
196 'Total must be an integer value (seconds) but was %s' %
197 total)
199 self.podcast = podcast
200 self.episode = episode
201 self.action = action
202 self.device = device
203 self.timestamp = timestamp
204 self.started = started
205 self.position = position
206 self.total = total
208 @classmethod
209 def from_dictionary(cls, d):
210 return cls(d['podcast'], d['episode'], d['action'],
211 d.get('device'), d.get('timestamp'),
212 d.get('started'), d.get('position'), d.get('total'))
214 def to_dictionary(self):
215 d = {}
217 for mandatory in ('podcast', 'episode', 'action'):
218 value = getattr(self, mandatory)
219 d[mandatory] = value
221 for optional in ('device', 'timestamp',
222 'started', 'position', 'total'):
223 value = getattr(self, optional)
224 if value is not None:
225 d[optional] = value
227 return d
230 class MygPodderClient(simple.SimpleClient):
231 """gpodder.net API Client
233 This is the API client that implements both the Simple and
234 Advanced API of gpodder.net. See the SimpleClient class
235 for a smaller class that only implements the Simple API.
238 @simple.needs_credentials
239 def get_subscriptions(self, device):
240 # Overloaded to accept PodcastDevice objects as arguments
241 device = getattr(device, 'device_id', device)
242 return simple.SimpleClient.get_subscriptions(self, device)
244 @simple.needs_credentials
245 def put_subscriptions(self, device, urls):
246 # Overloaded to accept PodcastDevice objects as arguments
247 device = getattr(device, 'device_id', device)
248 return simple.SimpleClient.put_subscriptions(self, device, urls)
250 @simple.needs_credentials
251 def update_subscriptions(self, device_id, add_urls=[], remove_urls=[]):
252 """Update the subscription list for a given device.
254 Returns a UpdateResult object that contains a list of (sanitized)
255 URLs and a "since" value that can be used for future calls to
256 pull_subscriptions.
258 For every (old_url, new_url) tuple in the updated_urls list of
259 the resulting object, the client should rewrite the URL in its
260 subscription list so that new_url is used instead of old_url.
262 uri = self._locator.add_remove_subscriptions_uri(device_id)
264 if not all(isinstance(x, str) for x in add_urls):
265 raise ValueError(
266 'add_urls must be a list of strings but was %s' %
267 add_urls)
269 if not all(isinstance(x, str) for x in remove_urls):
270 raise ValueError(
271 'remove_urls must be a list of strings but was %s' %
272 remove_urls)
274 data = {'add': add_urls, 'remove': remove_urls}
275 response = self._client.POST(uri, data)
277 if response is None:
278 raise InvalidResponse('Got empty response')
280 if 'timestamp' not in response:
281 raise InvalidResponse('Response does not contain timestamp')
283 try:
284 since = int(response['timestamp'])
285 except ValueError:
286 raise InvalidResponse(
287 'Invalid value %s for timestamp in response' %
288 response['timestamp'])
290 if 'update_urls' not in response:
291 raise InvalidResponse('Response does not contain update_urls')
293 try:
294 update_urls = [(a, b) for a, b in response['update_urls']]
295 except BaseException:
296 raise InvalidResponse(
297 'Invalid format of update_urls in response: %s' %
298 response['update_urls'])
300 if not all(isinstance(a, str) and isinstance(b, str)
301 for a, b in update_urls):
302 raise InvalidResponse(
303 'Invalid format of update_urls in response: %s' %
304 update_urls)
306 return UpdateResult(update_urls, since)
308 @simple.needs_credentials
309 def pull_subscriptions(self, device_id, since=None):
310 """Downloads subscriptions since the time of the last update
312 The "since" parameter should be a timestamp that has been
313 retrieved previously by a call to update_subscriptions or
314 pull_subscriptions.
316 Returns a SubscriptionChanges object with two lists (one for
317 added and one for removed podcast URLs) and a "since" value
318 that can be used for future calls to this method.
320 uri = self._locator.subscription_updates_uri(device_id, since)
321 data = self._client.GET(uri)
323 if data is None:
324 raise InvalidResponse('Got empty response')
326 if 'add' not in data:
327 raise InvalidResponse('List of added podcasts not in response')
329 if 'remove' not in data:
330 raise InvalidResponse('List of removed podcasts not in response')
332 if 'timestamp' not in data:
333 raise InvalidResponse('Timestamp missing from response')
335 if not all(isinstance(x, str) for x in data['add']):
336 raise InvalidResponse(
337 'Invalid value(s) in list of added podcasts: %s' %
338 data['add'])
340 if not all(isinstance(x, str) for x in data['remove']):
341 raise InvalidResponse(
342 'Invalid value(s) in list of removed podcasts: %s' %
343 data['remove'])
345 try:
346 since = int(data['timestamp'])
347 except ValueError:
348 raise InvalidResponse(
349 'Timestamp has invalid format in response: %s' %
350 data['timestamp'])
352 return SubscriptionChanges(data['add'], data['remove'], since)
354 @simple.needs_credentials
355 def upload_episode_actions(self, actions=[]):
356 """Uploads a list of EpisodeAction objects to the server
358 Returns the timestamp that can be used for retrieving changes.
360 uri = self._locator.upload_episode_actions_uri()
361 actions = [action.to_dictionary() for action in actions]
362 response = self._client.POST(uri, actions)
364 if response is None:
365 raise InvalidResponse('Got empty response')
367 if 'timestamp' not in response:
368 raise InvalidResponse('Response does not contain timestamp')
370 try:
371 since = int(response['timestamp'])
372 except ValueError:
373 raise InvalidResponse(
374 'Invalid value %s for timestamp in response' %
375 response['timestamp'])
377 return since
379 @simple.needs_credentials
380 def download_episode_actions(self, since=None,
381 podcast=None, device_id=None):
382 """Downloads a list of EpisodeAction objects from the server
384 Returns a EpisodeActionChanges object with the list of
385 new actions and a "since" timestamp that can be used for
386 future calls to this method when retrieving episodes.
388 uri = self._locator.download_episode_actions_uri(since,
389 podcast, device_id)
390 data = self._client.GET(uri)
392 if data is None:
393 raise InvalidResponse('Got empty response')
395 if 'actions' not in data:
396 raise InvalidResponse('Response does not contain actions')
398 if 'timestamp' not in data:
399 raise InvalidResponse('Response does not contain timestamp')
401 try:
402 since = int(data['timestamp'])
403 except ValueError:
404 raise InvalidResponse('Invalid value for timestamp: ' +
405 data['timestamp'])
407 dicts = data['actions']
408 try:
409 actions = [EpisodeAction.from_dictionary(d) for d in dicts]
410 except KeyError:
411 raise InvalidResponse('Missing keys in action list response')
413 return EpisodeActionChanges(actions, since)
415 @simple.needs_credentials
416 def update_device_settings(self, device_id, caption=None, type=None):
417 """Update the description of a device on the server
419 This changes the caption and/or type of a given device
420 on the server. If the device does not exist, it is
421 created with the given settings.
423 The parameters caption and type are both optional and
424 when set to a value other than None will be used to
425 update the device settings.
427 Returns True if the request succeeded, False otherwise.
429 uri = self._locator.device_settings_uri(device_id)
430 data = {}
431 if caption is not None:
432 data['caption'] = caption
433 if type is not None:
434 data['type'] = type
435 return self._client.POST(uri, data) is None
437 @simple.needs_credentials
438 def get_devices(self):
439 """Returns a list of this user's PodcastDevice objects
441 The resulting list can be used to display a selection
442 list to the user or to determine device IDs to pull
443 the subscription list from.
445 uri = self._locator.device_list_uri()
446 dicts = self._client.GET(uri)
447 if dicts is None:
448 raise InvalidResponse('No response received')
450 try:
451 return [PodcastDevice.from_dictionary(d) for d in dicts]
452 except KeyError:
453 raise InvalidResponse('Missing keys in device list response')
455 def get_favorite_episodes(self):
456 """Returns a List of Episode Objects containing the Users
457 favorite Episodes"""
458 uri = self._locator.favorite_episodes_uri()
459 return [public.Episode.from_dict(d) for d in self._client.GET(uri)]
461 def get_settings(self, type, scope_param1=None, scope_param2=None):
462 """Returns a Dictionary with the set settings for the type & specified scope"""
463 uri = self._locator.settings_uri(type, scope_param1, scope_param2)
464 return self._client.GET(uri)
466 def set_settings(self, type, scope_param1,
467 scope_param2, set={}, remove=[]):
468 """Returns a Dictionary with the set settings for the type & specified scope"""
469 uri = self._locator.settings_uri(type, scope_param1, scope_param2)
470 data = {"set": set, "remove": remove}
471 return self._client.POST(uri, data)