2 # This file is part of my.gpodder.org.
4 # my.gpodder.org is free software: you can redistribute it and/or modify it
5 # under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or (at your
7 # option) any later version.
9 # my.gpodder.org is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11 # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
12 # License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with my.gpodder.org. If not, see <http://www.gnu.org/licenses/>.
18 from functools
import partial
19 from itertools
import imap
, chain
20 from collections
import defaultdict
, namedtuple
21 from datetime
import datetime
22 from importlib
import import_module
24 import dateutil
.parser
31 from django
.http
import HttpResponse
, HttpResponseBadRequest
, Http404
, HttpResponseNotFound
32 from django
.contrib
.sites
.models
import RequestSite
33 from django
.views
.decorators
.csrf
import csrf_exempt
34 from django
.views
.decorators
.cache
import never_cache
35 from django
.utils
.decorators
import method_decorator
36 from django
.views
.generic
.base
import View
37 from django
.conf
import settings
as dsettings
39 from mygpo
.api
.constants
import EPISODE_ACTION_TYPES
, DEVICE_TYPES
40 from mygpo
.api
.httpresponse
import JsonResponse
41 from mygpo
.api
.sanitizing
import sanitize_url
, sanitize_urls
42 from mygpo
.api
.advanced
.directory
import episode_data
, podcast_data
43 from mygpo
.api
.backend
import get_device
, BulkSubscribe
44 from mygpo
.log
import log
45 from mygpo
.utils
import parse_time
, format_time
, parse_bool
, get_timestamp
46 from mygpo
.decorators
import allowed_methods
, repeat_on_conflict
47 from mygpo
.core
import models
48 from mygpo
.core
.models
import SanitizingRule
, Podcast
49 from mygpo
.core
.tasks
import auto_flattr_episode
50 from mygpo
.users
.models
import PodcastUserState
, EpisodeAction
, \
51 EpisodeUserState
, DeviceDoesNotExist
, DeviceUIDException
, \
52 InvalidEpisodeActionAttributes
53 from mygpo
.users
.settings
import FLATTR_AUTO
54 from mygpo
.core
.json
import json
, JSONDecodeError
55 from mygpo
.api
.basic_auth
import require_valid_user
, check_username
56 from mygpo
.db
.couchdb
import BulkException
, bulk_save_retry
57 from mygpo
.db
.couchdb
.episode
import episode_by_id
, \
58 favorite_episodes_for_user
, episodes_for_podcast
59 from mygpo
.db
.couchdb
.podcast
import podcast_for_url
60 from mygpo
.db
.couchdb
.podcast_state
import subscribed_podcast_ids_by_device
61 from mygpo
.db
.couchdb
.episode_state
import get_podcasts_episode_states
, \
62 episode_state_for_ref_urls
, get_episode_actions
65 # keys that are allowed in episode actions
66 EPISODE_ACTION_KEYS
= ('position', 'episode', 'action', 'device', 'timestamp',
67 'started', 'total', 'podcast')
74 @allowed_methods(['GET', 'POST'])
75 def subscriptions(request
, username
, device_uid
):
78 now_
= get_timestamp(now
)
80 if request
.method
== 'GET':
83 device
= request
.user
.get_device_by_uid(device_uid
)
84 except DeviceDoesNotExist
as e
:
85 return HttpResponseNotFound(str(e
))
87 since_
= request
.GET
.get('since', None)
89 return HttpResponseBadRequest('parameter since missing')
91 since
= datetime
.fromtimestamp(float(since_
))
93 return HttpResponseBadRequest('since-value is not a valid timestamp')
95 changes
= get_subscription_changes(request
.user
, device
, since
, now
)
97 return JsonResponse(changes
)
99 elif request
.method
== 'POST':
100 d
= get_device(request
.user
, device_uid
,
101 request
.META
.get('HTTP_USER_AGENT', ''))
104 return HttpResponseBadRequest('POST data must not be empty')
106 actions
= json
.loads(request
.body
)
107 add
= actions
['add'] if 'add' in actions
else []
108 rem
= actions
['remove'] if 'remove' in actions
else []
110 add
= filter(None, add
)
111 rem
= filter(None, rem
)
114 update_urls
= update_subscriptions(request
.user
, d
, add
, rem
)
115 except ValueError, e
:
116 return HttpResponseBadRequest(e
)
118 return JsonResponse({
120 'update_urls': update_urls
,
124 def update_subscriptions(user
, device
, add
, remove
):
128 raise ValueError('can not add and remove %s at the same time' % a
)
130 add_s
= list(sanitize_urls(add
, 'podcast'))
131 rem_s
= list(sanitize_urls(remove
, 'podcast'))
133 assert len(add
) == len(add_s
) and len(remove
) == len(rem_s
)
135 updated_urls
= filter(lambda (a
, b
): a
!= b
, zip(add
+ remove
, add_s
+ rem_s
))
137 add_s
= filter(None, add_s
)
138 rem_s
= filter(None, rem_s
)
140 # If two different URLs (in add and remove) have
141 # been sanitized to the same, we ignore the removal
142 rem_s
= filter(lambda x
: x
not in add_s
, rem_s
)
144 subscriber
= BulkSubscribe(user
, device
)
147 subscriber
.add_action(a
, 'subscribe')
150 subscriber
.add_action(r
, 'unsubscribe')
154 except BulkException
as be
:
155 for err
in be
.errors
:
156 log('Advanced API: %(username)s: Updating subscription for '
157 '%(podcast_url)s on %(device_uid)s failed: '
158 '%(rerror)s (%(reason)s)'.format(username
=user
.username
,
159 podcast_url
=err
.doc
, device_uid
=device
.uid
,
160 error
=err
.error
, reason
=err
.reason
)
166 def get_subscription_changes(user
, device
, since
, until
):
167 add_urls
, rem_urls
= device
.get_subscription_changes(since
, until
)
168 until_
= get_timestamp(until
)
169 return {'add': add_urls
, 'remove': rem_urls
, 'timestamp': until_
}
176 @allowed_methods(['GET', 'POST'])
177 def episodes(request
, username
, version
=1):
179 version
= int(version
)
181 now_
= get_timestamp(now
)
182 ua_string
= request
.META
.get('HTTP_USER_AGENT', '')
184 if request
.method
== 'POST':
186 actions
= json
.loads(request
.body
)
187 except (JSONDecodeError
, UnicodeDecodeError) as e
:
188 msg
= 'Advanced API: could not decode episode update POST data for user %s: %s' % (username
, e
)
190 return HttpResponseBadRequest(msg
)
192 log('start: user %s: %d actions from %s' % (request
.user
._id
, len(actions
), ua_string
))
194 # handle in background
195 if len(actions
) > dsettings
.API_ACTIONS_MAX_NONBG
:
196 bg_handler
= dsettings
.API_ACTIONS_BG_HANDLER
197 if bg_handler
is not None:
199 modname
, funname
= bg_handler
.rsplit('.', 1)
200 mod
= import_module(modname
)
201 fun
= getattr(mod
, funname
)
203 fun(request
.user
, actions
, now
, ua_string
)
205 # TODO: return 202 Accepted
206 return JsonResponse({'timestamp': now_
, 'update_urls': []})
210 update_urls
= update_episodes(request
.user
, actions
, now
, ua_string
)
211 except DeviceUIDException
as e
:
213 s
= u
'could not update episodes for user %s: %s %s: %s' % (username
, e
, traceback
.format_exc(), actions
)
214 log(s
.decode('utf-8', errors
='ignore'))
215 return HttpResponseBadRequest(str(e
))
216 except InvalidEpisodeActionAttributes
as e
:
218 log(u
'could not update episodes for user %s: %s %s: %s' % (username
, e
, traceback
.format_exc(), actions
))
219 return HttpResponseBadRequest(str(e
))
221 log('done: user %s: %d actions from %s' % (request
.user
._id
, len(actions
), ua_string
))
222 return JsonResponse({'timestamp': now_
, 'update_urls': update_urls
})
224 elif request
.method
== 'GET':
225 podcast_url
= request
.GET
.get('podcast', None)
226 device_uid
= request
.GET
.get('device', None)
227 since_
= request
.GET
.get('since', None)
228 aggregated
= parse_bool(request
.GET
.get('aggregated', False))
231 since
= int(since_
) if since_
else None
233 return HttpResponseBadRequest('since-value is not a valid timestamp')
236 podcast
= podcast_for_url(podcast_url
)
245 device
= request
.user
.get_device_by_uid(device_uid
)
246 except DeviceDoesNotExist
as e
:
247 return HttpResponseNotFound(str(e
))
252 changes
= get_episode_changes(request
.user
, podcast
, device
, since
,
253 now_
, aggregated
, version
)
255 return JsonResponse(changes
)
259 def convert_position(action
):
260 """ convert position parameter for API 1 compatibility """
261 pos
= getattr(action
, 'position', None)
263 action
.position
= format_time(pos
)
268 def get_episode_changes(user
, podcast
, device
, since
, until
, aggregated
, version
):
270 devices
= dict( (dev
.id, dev
.uid
) for dev
in user
.devices
)
273 if podcast
is not None:
274 args
['podcast_id'] = podcast
.get_id()
276 if device
is not None:
277 args
['device_id'] = device
.id
279 actions
= get_episode_actions(user
._id
, since
, until
, **args
)
282 actions
= imap(convert_position
, actions
)
284 clean_data
= partial(clean_episode_action_data
,
285 user
=user
, devices
=devices
)
287 actions
= map(clean_data
, actions
)
288 actions
= filter(None, actions
)
291 actions
= dict( (a
['episode'], a
) for a
in actions
).values()
293 return {'actions': actions
, 'timestamp': until
}
298 def clean_episode_action_data(action
, user
, devices
):
300 if None in (action
.get('podcast', None), action
.get('episode', None)):
303 if 'device_id' in action
:
304 device_id
= action
['device_id']
305 device_uid
= devices
.get(device_id
)
307 action
['device'] = device_uid
309 del action
['device_id']
311 # remove superfluous keys
312 for x
in action
.keys():
313 if x
not in EPISODE_ACTION_KEYS
:
316 # set missing keys to None
317 for x
in EPISODE_ACTION_KEYS
:
321 if action
['action'] != 'play':
322 if 'position' in action
:
323 del action
['position']
325 if 'total' in action
:
328 if 'started' in action
:
329 del action
['started']
331 if 'playmark' in action
:
332 del action
['playmark']
335 action
['position'] = action
.get('position', False) or 0
343 def update_episodes(user
, actions
, now
, ua_string
):
346 grouped_actions
= defaultdict(list)
348 # group all actions by their episode
349 for action
in actions
:
351 podcast_url
= action
['podcast']
352 podcast_url
= sanitize_append(podcast_url
, 'podcast', update_urls
)
353 if podcast_url
== '':
356 episode_url
= action
['episode']
357 episode_url
= sanitize_append(episode_url
, 'episode', update_urls
)
358 if episode_url
== '':
361 act
= parse_episode_action(action
, user
, update_urls
, now
, ua_string
)
362 grouped_actions
[ (podcast_url
, episode_url
) ].append(act
)
365 auto_flattr_episodes
= []
367 # Prepare the updates for each episode state
370 for (p_url
, e_url
), action_list
in grouped_actions
.iteritems():
371 episode_state
= episode_state_for_ref_urls(user
, p_url
, e_url
)
373 if any(a
['action'] == 'play' for a
in actions
):
374 auto_flattr_episodes
.append(episode_state
.episode
)
376 fun
= partial(update_episode_actions
, action_list
=action_list
)
377 obj_funs
.append( (episode_state
, fun
) )
379 bulk_save_retry(obj_funs
)
381 if user
.get_wksetting(FLATTR_AUTO
):
382 for episode_id
in auto_flattr_episodes
:
383 auto_flattr_episode
.delay(user
, episode_id
)
388 def update_episode_actions(episode_state
, action_list
):
389 """ Adds actions to the episode state and saves if necessary """
391 len1
= len(episode_state
.actions
)
392 episode_state
.add_actions(action_list
)
394 if len(episode_state
.actions
) == len1
:
401 def parse_episode_action(action
, user
, update_urls
, now
, ua_string
):
402 action_str
= action
.get('action', None)
403 if not valid_episodeaction(action_str
):
404 raise Exception('invalid action %s' % action_str
)
406 new_action
= EpisodeAction()
408 new_action
.action
= action
['action']
410 if action
.get('device', False):
411 device
= get_device(user
, action
['device'], ua_string
)
412 new_action
.device
= device
.id
414 if action
.get('timestamp', False):
415 new_action
.timestamp
= dateutil
.parser
.parse(action
['timestamp'])
417 new_action
.timestamp
= now
418 new_action
.timestamp
= new_action
.timestamp
.replace(microsecond
=0)
420 new_action
.upload_timestamp
= get_timestamp(now
)
422 new_action
.started
= action
.get('started', None)
423 new_action
.playmark
= action
.get('position', None)
424 new_action
.total
= action
.get('total', None)
433 # Workaround for mygpoclient 1.0: It uses "PUT" requests
434 # instead of "POST" requests for uploading device settings
435 @allowed_methods(['POST', 'PUT'])
436 def device(request
, username
, device_uid
):
437 d
= get_device(request
.user
, device_uid
,
438 request
.META
.get('HTTP_USER_AGENT', ''))
440 data
= json
.loads(request
.body
)
442 if 'caption' in data
:
443 if not data
['caption']:
444 return HttpResponseBadRequest('caption must not be empty')
445 d
.name
= data
['caption']
448 if not valid_devicetype(data
['type']):
449 return HttpResponseBadRequest('invalid device type %s' % data
['type'])
450 d
.type = data
['type']
453 request
.user
.update_device(d
)
455 return HttpResponse()
458 def valid_devicetype(type):
459 for t
in DEVICE_TYPES
:
464 def valid_episodeaction(type):
465 for t
in EPISODE_ACTION_TYPES
:
475 @allowed_methods(['GET'])
476 def devices(request
, username
):
477 devices
= filter(lambda d
: not d
.deleted
, request
.user
.devices
)
478 devices
= map(device_data
, devices
)
479 return JsonResponse(devices
)
482 def device_data(device
):
485 caption
= device
.name
,
487 subscriptions
= len(subscribed_podcast_ids_by_device(device
)),
492 def get_podcast_data(podcasts
, domain
, url
):
493 """ Gets podcast data for a URL from a dict of podcasts """
494 podcast
= podcasts
.get(url
)
495 return podcast_data(podcast
, domain
)
498 def get_episode_data(podcasts
, domain
, clean_action_data
, include_actions
, episode_status
):
499 """ Get episode data for an episode status object """
500 podcast_id
= episode_status
.episode
.podcast
501 podcast
= podcasts
.get(podcast_id
, None)
502 t
= episode_data(episode_status
.episode
, domain
, podcast
)
503 t
['status'] = episode_status
.status
505 # include latest action (bug 1419)
506 if include_actions
and episode_status
.action
:
507 t
['action'] = clean_action_data(episode_status
.action
)
513 class DeviceUpdates(View
):
515 @method_decorator(csrf_exempt
)
516 @method_decorator(require_valid_user
)
517 @method_decorator(check_username
)
518 @method_decorator(never_cache
)
519 def get(self
, request
, username
, device_uid
):
521 now_
= get_timestamp(now
)
524 device
= request
.user
.get_device_by_uid(device_uid
)
525 except DeviceDoesNotExist
as e
:
526 return HttpResponseNotFound(str(e
))
528 since_
= request
.GET
.get('since', None)
530 return HttpResponseBadRequest('parameter since missing')
532 since
= datetime
.fromtimestamp(float(since_
))
534 return HttpResponseBadRequest("'since' is not a valid timestamp")
536 include_actions
= parse_bool(request
.GET
.get('include_actions', False))
538 ret
= get_subscription_changes(request
.user
, device
, since
, now
)
539 domain
= RequestSite(request
).domain
541 subscriptions
= list(device
.get_subscribed_podcasts())
543 podcasts
= dict( (p
.url
, p
) for p
in subscriptions
)
544 prepare_podcast_data
= partial(get_podcast_data
, podcasts
, domain
)
546 ret
['add'] = map(prepare_podcast_data
, ret
['add'])
548 devices
= dict( (dev
.id, dev
.uid
) for dev
in request
.user
.devices
)
549 clean_action_data
= partial(clean_episode_action_data
,
550 user
=request
.user
, devices
=devices
)
552 # index subscribed podcasts by their Id for fast access
553 podcasts
= dict( (p
.get_id(), p
) for p
in subscriptions
)
554 prepare_episode_data
= partial(get_episode_data
, podcasts
, domain
,
555 clean_action_data
, include_actions
)
557 episode_updates
= self
.get_episode_updates(request
.user
,
558 subscriptions
, since
)
559 ret
['updates'] = map(prepare_episode_data
, episode_updates
)
561 return JsonResponse(ret
)
564 def get_episode_updates(self
, user
, subscribed_podcasts
, since
,
566 """ Returns the episode updates since the timestamp """
568 EpisodeStatus
= namedtuple('EpisodeStatus', 'episode status action')
574 episode_jobs
= [gevent
.spawn(episodes_for_podcast
, p
, since
,
575 limit
=max_per_podcast
) for p
in subscribed_podcasts
]
576 gevent
.joinall(episode_jobs
)
577 episodes
= chain
.from_iterable(job
.get() for job
in episode_jobs
)
580 episodes
= chain
.from_iterable(episodes_for_podcast(p
, since
,
581 limit
=max_per_podcast
) for p
in subscribed_podcasts
)
584 for episode
in episodes
:
585 episode_status
[episode
._id
] = EpisodeStatus(episode
, 'new', None)
590 e_action_jobs
= [gevent
.spawn(get_podcasts_episode_states
, p
,
591 user
._id
) for p
in subscribed_podcasts
]
592 gevent
.joinall(e_action_jobs
)
593 e_actions
= chain
.from_iterable(job
.get() for job
in e_action_jobs
)
596 e_actions
= chain
.from_iterable(get_podcasts_episode_states(p
,
597 user
._id
) for p
in subscribed_podcasts
)
600 for action
in e_actions
:
601 e_id
= action
['episode_id']
603 if e_id
in episode_status
:
604 episode
= episode_status
[e_id
].episode
606 episode
= episode_by_id(e_id
)
608 episode_status
[e_id
] = EpisodeStatus(episode
, action
['action'],
611 return episode_status
.itervalues()
617 def favorites(request
, username
):
618 favorites
= favorite_episodes_for_user(request
.user
)
619 domain
= RequestSite(request
).domain
620 e_data
= lambda e
: episode_data(e
, domain
)
621 ret
= map(e_data
, favorites
)
622 return JsonResponse(ret
)
625 def sanitize_append(url
, obj_type
, sanitized_list
):
626 urls
= sanitize_url(url
, obj_type
)
628 sanitized_list
.append( (url
, urls
) )