aggregated download of episode actions (bug 1030)
[mygpo.git] / mygpo / api / advanced / __init__.py
blob4255ca4669a04a7b0e2702bc69c4d5317bf61ce9
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 mygpo.api.basic_auth import require_valid_user, check_username
19 from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, Http404, HttpResponseNotAllowed
20 from mygpo.api.models import Device, Podcast, SubscriptionAction, Episode, EpisodeAction, SUBSCRIBE_ACTION, UNSUBSCRIBE_ACTION, EPISODE_ACTION_TYPES, DEVICE_TYPES, Subscription
21 from mygpo.api.httpresponse import JsonResponse
22 from mygpo.api.sanitizing import sanitize_url
23 from django.core import serializers
24 from time import mktime, gmtime, strftime
25 from datetime import datetime, timedelta
26 import dateutil.parser
27 from mygpo.log import log
28 from mygpo.utils import parse_time, parse_bool
29 from django.db import IntegrityError
30 import re
31 from django.views.decorators.csrf import csrf_exempt
33 try:
34 #try to import the JSON module (if we are on Python 2.6)
35 import json
37 # Python 2.5 seems to have a different json module
38 if not 'dumps' in dir(json):
39 raise ImportError
41 except ImportError:
42 # No JSON module available - fallback to simplejson (Python < 2.6)
43 print "No JSON module available - fallback to simplejson (Python < 2.6)"
44 import simplejson as json
47 @csrf_exempt
48 @require_valid_user
49 @check_username
50 def subscriptions(request, username, device_uid):
52 now = datetime.now()
53 now_ = int(mktime(now.timetuple()))
55 if request.method == 'GET':
56 try:
57 d = Device.objects.get(user=request.user, uid=device_uid, deleted=False)
58 except Device.DoesNotExist:
59 raise Http404('device %s does not exist' % device_uid)
61 try:
62 since_ = request.GET['since']
63 except KeyError:
64 return HttpResponseBadRequest('parameter since missing')
66 since = datetime.fromtimestamp(float(since_))
68 changes = get_subscription_changes(request.user, d, since, now)
70 return JsonResponse(changes)
72 elif request.method == 'POST':
73 d, created = Device.objects.get_or_create(user=request.user, uid=device_uid, defaults = {'type': 'other', 'name': 'New Device'})
75 if d.deleted:
76 d.deleted = False
77 d.save()
79 actions = json.loads(request.raw_post_data)
80 add = actions['add'] if 'add' in actions else []
81 rem = actions['remove'] if 'remove' in actions else []
83 try:
84 update_urls = update_subscriptions(request.user, d, add, rem)
85 except IntegrityError, e:
86 return HttpResponseBadRequest(e)
88 return JsonResponse({
89 'timestamp': now_,
90 'update_urls': update_urls,
93 else:
94 return HttpResponseNotAllowed(['GET', 'POST'])
97 def update_subscriptions(user, device, add, remove):
98 updated_urls = []
99 add_sanitized = []
100 rem_sanitized = []
102 for a in add:
103 if a in remove:
104 raise IntegrityError('can not add and remove %s at the same time' % a)
106 for u in add:
107 us = sanitize_url(u)
108 if u != us: updated_urls.append( (u, us) )
109 if us != '': add_sanitized.append(us)
111 for u in remove:
112 us = sanitize_url(u)
113 if u != us: updated_urls.append( (u, us) )
114 if us != '' and us not in add_sanitized:
115 rem_sanitized.append(us)
117 for a in add_sanitized:
118 p, p_created = Podcast.objects.get_or_create(url=a)
119 try:
120 s = SubscriptionAction.objects.create(podcast=p,device=device,action=SUBSCRIBE_ACTION)
121 except IntegrityError, e:
122 log('can\'t add subscription %s for user %s: %s' % (a, user, e))
124 for r in rem_sanitized:
125 p, p_created = Podcast.objects.get_or_create(url=r)
126 try:
127 s = SubscriptionAction.objects.create(podcast=p,device=device,action=UNSUBSCRIBE_ACTION)
128 except IntegrityError, e:
129 log('can\'t remove subscription %s for user %s: %s' % (r, user, e))
131 return updated_urls
133 def get_subscription_changes(user, device, since, until):
134 actions = {}
135 for a in SubscriptionAction.objects.filter(device=device, timestamp__gt=since, timestamp__lte=until).order_by('timestamp'):
136 #ordered by ascending date; newer entries overwriter older ones
137 actions[a.podcast] = a
139 add = []
140 remove = []
142 for a in actions.values():
143 if a.action == SUBSCRIBE_ACTION:
144 add.append(a.podcast.url)
145 elif a.action == UNSUBSCRIBE_ACTION:
146 remove.append(a.podcast.url)
148 until_ = int(mktime(until.timetuple()))
149 return {'add': add, 'remove': remove, 'timestamp': until_}
152 @csrf_exempt
153 @require_valid_user
154 @check_username
155 def episodes(request, username, version=1):
157 version = int(version)
158 now = datetime.now()
159 now_ = int(mktime(now.timetuple()))
161 if request.method == 'POST':
162 try:
163 actions = json.loads(request.raw_post_data)
164 except KeyError, e:
165 log('could not parse episode update info for user %s: %s' % (username, e))
166 return HttpResponseBadRequest()
168 try:
169 update_urls = update_episodes(request.user, actions)
170 except Exception, e:
171 log('could not update episodes for user %s: %s' % (username, e))
172 return HttpResponseBadRequest(e)
174 return JsonResponse({'timestamp': now_, 'update_urls': update_urls})
176 elif request.method == 'GET':
177 podcast_url= request.GET.get('podcast', None)
178 device_uid = request.GET.get('device', None)
179 since_ = request.GET.get('since', None)
180 aggregated = parse_bool(request.GET.get('aggregated', False))
182 since = datetime.fromtimestamp(float(since_)) if since_ else None
184 try:
185 podcast = Podcast.objects.get(url=podcast_url) if podcast_url else None
186 device = Device.objects.get(user=request.user,uid=device_uid, deleted=False) if device_uid else None
187 except:
188 raise Http404
190 print aggregated
192 return JsonResponse(get_episode_changes(request.user, podcast, device, since, now, aggregated, version))
194 else:
195 return HttpResponseNotAllowed(['POST', 'GET'])
198 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
199 if aggregated:
200 actions = {}
201 else:
202 actions = []
203 eactions = EpisodeAction.objects.filter(user=user, timestamp__lte=until)
205 if podcast:
206 eactions = eactions.filter(episode__podcast=podcast)
208 if device:
209 eactions = eactions.filter(device=device)
211 if since: # we can't use None with __gt
212 eactions = eactions.filter(timestamp__gt=since)
214 if aggregated:
215 eactions = eactions.order_by('timestamp')
217 for a in eactions:
218 action = {
219 'podcast': a.episode.podcast.url,
220 'episode': a.episode.url,
221 'action': a.action,
222 'timestamp': a.timestamp.strftime('%Y-%m-%dT%H:%M:%S') #2009-12-12T09:00:00
225 if a.action == 'play' and a.playmark:
226 if version == 1:
227 t = gmtime(a.playmark)
228 action['position'] = strftime('%H:%M:%S', t)
229 else:
230 action['position'] = int(a.playmark)
231 action['started'] = int(a.started)
232 action['total'] = int(a.total)
234 if aggregated:
235 actions[a.episode] = action
236 else:
237 actions.append(action)
239 until_ = int(mktime(until.timetuple()))
241 if aggregated:
242 actions = list(actions.itervalues())
244 return {'actions': actions, 'timestamp': until_}
247 def update_episodes(user, actions):
248 update_urls = []
250 for e in actions:
251 u = e['podcast']
252 us = sanitize_url(u)
253 if u != us: update_urls.append( (u, us) )
254 if us == '': continue
256 podcast, p_created = Podcast.objects.get_or_create(url=us)
258 eu = e['episode']
259 eus = sanitize_url(eu, podcast=False, episode=True)
260 if eu != eus: update_urls.append( (eu, eus) )
261 if eus == '': continue
263 episode, e_created = Episode.objects.get_or_create(podcast=podcast, url=eus)
264 action = e['action']
265 if not valid_episodeaction(action):
266 raise Exception('invalid action %s' % action)
268 if 'device' in e:
269 device, created = Device.objects.get_or_create(user=user, uid=e['device'], defaults={'name': 'Unknown', 'type': 'other'})
271 # undelete a previously deleted device
272 if device.deleted:
273 device.deleted = False
274 device.save()
276 else:
277 device, created = None, False
278 timestamp = dateutil.parser.parse(e['timestamp']) if 'timestamp' in e else datetime.now()
280 # Time values for play actions and their keys in JSON
281 PLAY_ACTION_KEYS = ('position', 'started', 'total')
282 time_values = {}
284 for key in PLAY_ACTION_KEYS:
285 if key in e:
286 if action != 'play':
287 # Key found, but must not be supplied (no play action!)
288 return HttpResponseBadRequest('%s only allowed in play actions' % key)
290 try:
291 time_values[key] = parse_time(e[key])
292 except ValueError:
293 log('could not parse %s parameter (value: %s) for user %s' % (key, repr(e[key]), user))
294 return HttpResponseBadRequest('Wrong format for %s: %s' % (key, repr(e[key])))
295 else:
296 # Value not supplied by client
297 time_values[key] = None
299 # Sanity check: If started or total are given, require position
300 if (time_values['started'] is not None or \
301 time_values['total'] is not None) and \
302 time_values['position'] is None:
303 return HttpResponseBadRequest('started and total require position')
305 try:
306 EpisodeAction.objects.create(user=user, episode=episode, device=device, action=action, timestamp=timestamp,
307 playmark=time_values['position'], started=time_values['started'], total=time_values['total'])
308 except Exception, e:
309 log('error while adding episode action (user %s, episode %s, device %s, action %s, timestamp %s): %s' % (user, episode, device, action, timestamp, e))
311 return update_urls
314 @csrf_exempt
315 @require_valid_user
316 @check_username
317 def device(request, username, device_uid):
319 # Workaround for mygpoclient 1.0: It uses "PUT" requests
320 # instead of "POST" requests for uploading device settings
321 if request.method in ('POST', 'PUT'):
322 d, created = Device.objects.get_or_create(user=request.user, uid=device_uid)
324 #undelete a previously deleted device
325 if d.deleted:
326 d.deleted = False
327 d.save()
329 data = json.loads(request.raw_post_data)
331 if 'caption' in data:
332 d.name = data['caption']
334 if 'type' in data:
335 if not valid_devicetype(data['type']):
336 return HttpResponseBadRequest('invalid device type %s' % data['type'])
337 d.type = data['type']
339 d.save()
341 return HttpResponse()
343 else:
344 return HttpResponseNotAllowed(['POST'])
346 def valid_devicetype(type):
347 for t in DEVICE_TYPES:
348 if t[0] == type:
349 return True
350 return False
352 def valid_episodeaction(type):
353 for t in EPISODE_ACTION_TYPES:
354 if t[0] == type:
355 return True
356 return False
359 @csrf_exempt
360 @require_valid_user
361 @check_username
362 def devices(request, username):
364 if request.method == 'GET':
365 devices = []
366 for d in Device.objects.filter(user=request.user, deleted=False):
367 devices.append({
368 'id': d.uid,
369 'caption': d.name,
370 'type': d.type,
371 'subscriptions': Subscription.objects.filter(device=d).count()
374 return JsonResponse(devices)
376 else:
377 return HttpResponseNotAllowed(['GET'])