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/>.
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):
35 class UpdateResult(object):
36 """Container for subscription update results
39 update_urls - A list of (old_url, new_url) tuples
40 since - A timestamp value for use in future requests
43 def __init__(self
, update_urls
, since
):
44 self
.update_urls
= update_urls
48 class SubscriptionChanges(object):
49 """Container for subscription changes
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
57 def __init__(self
, add
, remove
, since
):
63 class EpisodeActionChanges(object):
64 """Container for added episode actions
67 actions - A list of EpisodeAction objects
68 since - A timestamp value for use in future requests
71 def __init__(self
, actions
, since
):
72 self
.actions
= actions
76 class PodcastDevice(object):
77 """This class encapsulates a podcast device
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
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
:
91 'Invalid device type "%s" (see VALID_TYPES)' %
94 # Check if subscriptions is a numeric value
99 'Subscription must be a numeric value but was %s' %
102 self
.device_id
= device_id
103 self
.caption
= caption
105 self
.subscriptions
= int(subscriptions
)
108 """String representation of this device
110 >>> device = PodcastDevice('mygpo', 'My Device', 'mobile', 10)
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
)
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
:
147 'Invalid action type "%s" (see VALID_ACTIONS)' %
150 # Disallow play-only attributes for non-play actions
152 if started
is not None:
154 'Started can only be set for the "play" action')
155 elif position
is not None:
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:
165 'Timestamp has to be in ISO 8601 format but was %s' %
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:
175 started
= int(started
)
178 'Started must be an integer value (seconds) but was %s' %
181 # Check that "position" is a number if it's set
182 if position
is not None:
184 position
= int(position
)
187 'Position must be an integer value (seconds) but was %s' %
190 # Check that "total" is a number if it's set
191 if total
is not None:
196 'Total must be an integer value (seconds) but was %s' %
199 self
.podcast
= podcast
200 self
.episode
= episode
203 self
.timestamp
= timestamp
204 self
.started
= started
205 self
.position
= position
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
):
217 for mandatory
in ('podcast', 'episode', 'action'):
218 value
= getattr(self
, mandatory
)
221 for optional
in ('device', 'timestamp',
222 'started', 'position', 'total'):
223 value
= getattr(self
, optional
)
224 if value
is not None:
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
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
):
266 'add_urls must be a list of strings but was %s' %
269 if not all(isinstance(x
, str) for x
in remove_urls
):
271 'remove_urls must be a list of strings but was %s' %
274 data
= {'add': add_urls
, 'remove': remove_urls
}
275 response
= self
._client
.POST(uri
, data
)
278 raise InvalidResponse('Got empty response')
280 if 'timestamp' not in response
:
281 raise InvalidResponse('Response does not contain timestamp')
284 since
= int(response
['timestamp'])
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')
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' %
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
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
)
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' %
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' %
346 since
= int(data
['timestamp'])
348 raise InvalidResponse(
349 'Timestamp has invalid format in response: %s' %
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
)
365 raise InvalidResponse('Got empty response')
367 if 'timestamp' not in response
:
368 raise InvalidResponse('Response does not contain timestamp')
371 since
= int(response
['timestamp'])
373 raise InvalidResponse(
374 'Invalid value %s for timestamp in response' %
375 response
['timestamp'])
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
,
390 data
= self
._client
.GET(uri
)
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')
402 since
= int(data
['timestamp'])
404 raise InvalidResponse('Invalid value for timestamp: ' +
407 dicts
= data
['actions']
409 actions
= [EpisodeAction
.from_dictionary(d
) for d
in dicts
]
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
)
431 if caption
is not None:
432 data
['caption'] = caption
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
)
448 raise InvalidResponse('No response received')
451 return [PodcastDevice
.from_dictionary(d
) for d
in dicts
]
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
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
)