[API] handle missing URLs in episode action upload
[mygpo.git] / mygpo / api / simple.py
blobc4b743afa2106054b2b38067c8e5b5202c65c979
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 import json
19 import string
20 from itertools import islice
21 from functools import wraps
23 from django.shortcuts import render
24 from django.core.cache import cache
25 from django.http import HttpResponse, HttpResponseBadRequest
26 from django.views.decorators.cache import cache_page
27 from django.views.decorators.csrf import csrf_exempt
28 from django.views.decorators.cache import never_cache
29 from django.contrib.sites.requests import RequestSite
30 from django.utils.translation import ugettext as _
32 from mygpo.api.basic_auth import require_valid_user, check_username
33 from mygpo.api.backend import get_device
34 from mygpo.podcasts.models import Podcast
35 from mygpo.api.opml import Exporter, Importer
36 from mygpo.api.httpresponse import JsonResponse
37 from mygpo.directory.models import ExamplePodcast
38 from mygpo.api.advanced.directory import podcast_data
39 from mygpo.subscriptions import get_subscribed_podcasts, subscribe, unsubscribe
40 from mygpo.directory.search import search_podcasts
41 from mygpo.decorators import allowed_methods, cors_origin
42 from mygpo.utils import parse_range, normalize_feed_url
44 import logging
45 logger = logging.getLogger(__name__)
48 ALLOWED_FORMATS = ('txt', 'opml', 'json', 'jsonp', 'xml')
50 def check_format(fn):
51 @wraps(fn)
52 def tmp(request, format, *args, **kwargs):
53 if not format in ALLOWED_FORMATS:
54 return HttpResponseBadRequest('Invalid format')
56 return fn(request, *args, format=format, **kwargs)
57 return tmp
60 @csrf_exempt
61 @require_valid_user
62 @check_username
63 @check_format
64 @never_cache
65 @allowed_methods(['GET', 'PUT', 'POST'])
66 @cors_origin()
67 def subscriptions(request, username, device_uid, format):
69 user_agent = request.META.get('HTTP_USER_AGENT', '')
71 if request.method == 'GET':
72 title = _('%(username)s\'s Subscription List') % {'username': username}
73 subscriptions = get_subscriptions(request.user, device_uid, user_agent)
74 return format_podcast_list(subscriptions, format, title, jsonp_padding=request.GET.get('jsonp'))
76 elif request.method in ('PUT', 'POST'):
77 try:
78 body = request.body.decode('utf-8')
79 subscriptions = parse_subscription(body, format)
81 except ValueError as e:
82 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e))
84 return set_subscriptions(subscriptions, request.user, device_uid,
85 user_agent)
88 @csrf_exempt
89 @require_valid_user
90 @check_username
91 @check_format
92 @never_cache
93 @allowed_methods(['GET'])
94 @cors_origin()
95 def all_subscriptions(request, username, format):
97 try:
98 scale = int(request.GET.get('scale_logo', 64))
99 except (TypeError, ValueError):
100 return HttpResponseBadRequest('scale_logo has to be a numeric value')
102 if scale not in range(1, 257):
103 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
106 subscriptions = get_subscribed_podcasts(request.user)
107 title = _('%(username)s\'s Subscription List') % {'username': username}
108 domain = RequestSite(request).domain
109 p_data = lambda p: podcast_data(p, domain, scale)
110 return format_podcast_list(subscriptions, format, title,
111 json_map=p_data, xml_template='podcasts.xml', request=request)
114 def format_podcast_list(obj_list, format, title, get_podcast=None,
115 json_map=lambda x: x.url, jsonp_padding=None,
116 xml_template=None, request=None, template_args={}):
118 Formats a list of podcasts for use in a API response
120 obj_list is a list of podcasts or objects that contain podcasts
121 format is one if txt, opml or json
122 title is a label of the list
123 if obj_list is a list of objects containing podcasts, get_podcast is the
124 function used to get the podcast out of the each of these objects
125 json_map is a function returning the contents of an object (from obj_list)
126 that should be contained in the result (only used for format='json')
129 def default_get_podcast(p):
130 return p
132 get_podcast = get_podcast or default_get_podcast
134 if format == 'txt':
135 podcasts = map(get_podcast, obj_list)
136 s = '\n'.join([p.url for p in podcasts] + [''])
137 return HttpResponse(s, content_type='text/plain')
139 elif format == 'opml':
140 podcasts = map(get_podcast, obj_list)
141 exporter = Exporter(title)
142 opml = exporter.generate(podcasts)
143 return HttpResponse(opml, content_type='text/xml')
145 elif format == 'json':
146 objs = list(map(json_map, obj_list))
147 return JsonResponse(objs)
149 elif format == 'jsonp':
150 ALLOWED_FUNCNAME = string.ascii_letters + string.digits + '_'
152 if not jsonp_padding:
153 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
155 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
156 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
158 objs = map(json_map, obj_list)
159 return JsonResponse(objs, jsonp_padding=jsonp_padding)
161 elif format == 'xml':
162 if None in (xml_template, request):
163 return HttpResponseBadRequest('XML is not a valid format for this request')
165 podcasts = map(json_map, obj_list)
166 template_args.update({'podcasts': podcasts})
168 return render(request, xml_template, template_args,
169 content_type='application/xml')
171 else:
172 return None
175 def get_subscriptions(user, device_uid, user_agent=None):
176 device = get_device(user, device_uid, user_agent)
177 return device.get_subscribed_podcasts()
180 def parse_subscription(raw_post_data, format):
181 """ Parses the data according to the format """
182 if format == 'txt':
183 urls = raw_post_data.split('\n')
185 elif format == 'opml':
186 begin = raw_post_data.find('<?xml')
187 end = raw_post_data.find('</opml>') + 7
188 i = Importer(content=raw_post_data[begin:end])
189 urls = [p['url'] for p in i.items]
191 elif format == 'json':
192 begin = raw_post_data.find('[')
193 end = raw_post_data.find(']') + 1
194 urls = json.loads(raw_post_data[begin:end])
196 else:
197 return []
199 urls = filter(None, urls)
200 urls = list(map(normalize_feed_url, urls))
201 return urls
204 def set_subscriptions(urls, user, device_uid, user_agent):
206 # remove empty urls
207 urls = list(filter(None, (u.strip() for u in urls)))
209 device = get_device(user, device_uid, user_agent, undelete=True)
211 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
212 new = [p for p in urls if p not in subscriptions.keys()]
213 rem = [p for p in subscriptions.keys() if p not in urls]
215 remove_podcasts = Podcast.objects.filter(urls__url__in=rem)
216 for podcast in remove_podcasts:
217 unsubscribe(podcast, user, device)
219 for url in new:
220 podcast = Podcast.objects.get_or_create_for_url(url)
221 subscribe(podcast, user, device, url)
223 # Only an empty response is a successful response
224 return HttpResponse('', content_type='text/plain')
227 @check_format
228 @allowed_methods(['GET'])
229 @cache_page(60 * 60)
230 @cors_origin()
231 def toplist(request, count, format):
232 count = parse_range(count, 1, 100, 100)
234 entries = Podcast.objects.all().toplist()[:count]
235 domain = RequestSite(request).domain
237 try:
238 scale = int(request.GET.get('scale_logo', 64))
239 except (TypeError, ValueError):
240 return HttpResponseBadRequest('scale_logo has to be a numeric value')
242 if scale not in range(1, 257):
243 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
246 def get_podcast(t):
247 return t
249 def json_map(t):
250 podcast = t
251 p = podcast_data(podcast, domain, scale)
252 return p
254 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
255 return format_podcast_list(entries,
256 format,
257 title,
258 get_podcast=get_podcast,
259 json_map=json_map,
260 jsonp_padding=request.GET.get('jsonp', ''),
261 xml_template='podcasts.xml',
262 request=request,
266 @check_format
267 @cache_page(60 * 60)
268 @allowed_methods(['GET'])
269 @cors_origin()
270 def search(request, format):
272 NUM_RESULTS = 20
274 query = request.GET.get('q', '')
276 try:
277 scale = int(request.GET.get('scale_logo', 64))
278 except (TypeError, ValueError):
279 return HttpResponseBadRequest('scale_logo has to be a numeric value')
281 if scale not in range(1, 257):
282 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
284 if not query:
285 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
287 results = search_podcasts(query)[:NUM_RESULTS]
289 title = _('gpodder.net - Search')
290 domain = RequestSite(request).domain
291 p_data = lambda p: podcast_data(p, domain, scale)
292 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
295 @require_valid_user
296 @check_format
297 @never_cache
298 @allowed_methods(['GET'])
299 @cors_origin()
300 def suggestions(request, count, format):
301 count = parse_range(count, 1, 100, 100)
303 user = request.user
304 suggestions = Podcast.objects.filter(podcastsuggestion__suggested_to=user,
305 podcastsuggestion__deleted=False)
306 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
307 domain = RequestSite(request).domain
308 p_data = lambda p: podcast_data(p, domain)
309 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
312 @check_format
313 @allowed_methods(['GET'])
314 @cache_page(60 * 60)
315 @cors_origin()
316 def example_podcasts(request, format):
318 podcasts = cache.get('example-podcasts', None)
320 try:
321 scale = int(request.GET.get('scale_logo', 64))
322 except (TypeError, ValueError):
323 return HttpResponseBadRequest('scale_logo has to be a numeric value')
325 if scale not in range(1, 257):
326 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
328 if not podcasts:
329 podcasts = ExamplePodcast.objects.get_podcasts()
330 cache.set('example-podcasts', podcasts)
332 title = 'gPodder Podcast Directory'
333 domain = RequestSite(request).domain
334 p_data = lambda p: podcast_data(p, domain, scale)
335 return format_podcast_list(
336 podcasts,
337 format,
338 title,
339 json_map=p_data,
340 xml_template='podcasts.xml',
341 request=request,