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/>.
19 from itertools
import islice
20 from functools
import wraps
22 from couchdbkit
.exceptions
import ResourceNotFound
24 from django
.shortcuts
import render
25 from django
.core
.cache
import cache
26 from django
.http
import HttpResponse
, HttpResponseBadRequest
27 from django
.views
.decorators
.cache
import cache_page
28 from django
.views
.decorators
.csrf
import csrf_exempt
29 from django
.views
.decorators
.cache
import never_cache
30 from django
.contrib
.sites
.models
import RequestSite
31 from django
.utils
.translation
import ugettext
as _
33 from mygpo
.api
.basic_auth
import require_valid_user
, check_username
34 from mygpo
.api
.backend
import get_device
, BulkSubscribe
35 from mygpo
.podcasts
.models
import Podcast
36 from mygpo
.api
.opml
import Exporter
, Importer
37 from mygpo
.api
.httpresponse
import JsonResponse
38 from mygpo
.directory
.models
import ExamplePodcasts
39 from mygpo
.api
.advanced
.directory
import podcast_data
40 from mygpo
.users
.subscriptions
import get_subscribed_podcasts
41 from mygpo
.directory
.search
import search_podcasts
42 from mygpo
.decorators
import allowed_methods
, cors_origin
43 from mygpo
.utils
import parse_range
, normalize_feed_url
44 from mygpo
.core
.json
import json
, JSONDecodeError
45 from mygpo
.db
.couchdb
import BulkException
46 from mygpo
.db
.couchdb
.user
import suggestions_for_user
49 logger
= logging
.getLogger(__name__
)
52 ALLOWED_FORMATS
= ('txt', 'opml', 'json', 'jsonp', 'xml')
56 def tmp(request
, format
, *args
, **kwargs
):
57 if not format
in ALLOWED_FORMATS
:
58 return HttpResponseBadRequest('Invalid format')
60 return fn(request
, *args
, format
=format
, **kwargs
)
69 @allowed_methods(['GET', 'PUT', 'POST'])
71 def subscriptions(request
, username
, device_uid
, format
):
73 user_agent
= request
.META
.get('HTTP_USER_AGENT', '')
75 if request
.method
== 'GET':
76 title
= _('%(username)s\'s Subscription List') % {'username': username
}
77 subscriptions
= get_subscriptions(request
.user
, device_uid
, user_agent
)
78 return format_podcast_list(subscriptions
, format
, title
, jsonp_padding
=request
.GET
.get('jsonp'))
80 elif request
.method
in ('PUT', 'POST'):
82 subscriptions
= parse_subscription(request
.body
, format
)
84 except JSONDecodeError
as e
:
85 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e
))
87 return set_subscriptions(subscriptions
, request
.user
, device_uid
,
96 @allowed_methods(['GET'])
98 def all_subscriptions(request
, username
, format
):
101 scale
= int(request
.GET
.get('scale_logo', 64))
102 except (TypeError, ValueError):
103 return HttpResponseBadRequest('scale_logo has to be a numeric value')
105 if scale
not in range(1, 257):
106 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
109 subscriptions
= get_subscribed_podcasts(request
.user
)
110 title
= _('%(username)s\'s Subscription List') % {'username': username
}
111 domain
= RequestSite(request
).domain
112 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
113 return format_podcast_list(subscriptions
, format
, title
,
114 json_map
=p_data
, xml_template
='podcasts.xml', request
=request
)
117 def format_podcast_list(obj_list
, format
, title
, get_podcast
=None,
118 json_map
=lambda x
: x
.url
, jsonp_padding
=None,
119 xml_template
=None, request
=None, template_args
={}):
121 Formats a list of podcasts for use in a API response
123 obj_list is a list of podcasts or objects that contain podcasts
124 format is one if txt, opml or json
125 title is a label of the list
126 if obj_list is a list of objects containing podcasts, get_podcast is the
127 function used to get the podcast out of the each of these objects
128 json_map is a function returning the contents of an object (from obj_list)
129 that should be contained in the result (only used for format='json')
132 def default_get_podcast(p
):
135 get_podcast
= get_podcast
or default_get_podcast
138 podcasts
= map(get_podcast
, obj_list
)
139 s
= '\n'.join([p
.url
for p
in podcasts
] + [''])
140 return HttpResponse(s
, content_type
='text/plain')
142 elif format
== 'opml':
143 podcasts
= map(get_podcast
, obj_list
)
144 exporter
= Exporter(title
)
145 opml
= exporter
.generate(podcasts
)
146 return HttpResponse(opml
, content_type
='text/xml')
148 elif format
== 'json':
149 objs
= map(json_map
, obj_list
)
150 return JsonResponse(objs
)
152 elif format
== 'jsonp':
153 ALLOWED_FUNCNAME
= string
.letters
+ string
.digits
+ '_'
155 if not jsonp_padding
:
156 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
158 if any(x
not in ALLOWED_FUNCNAME
for x
in jsonp_padding
):
159 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME
})
161 objs
= map(json_map
, obj_list
)
162 return JsonResponse(objs
, jsonp_padding
=jsonp_padding
)
164 elif format
== 'xml':
165 if None in (xml_template
, request
):
166 return HttpResponseBadRequest('XML is not a valid format for this request')
168 podcasts
= map(json_map
, obj_list
)
169 template_args
.update({'podcasts': podcasts
})
171 return render(request
, xml_template
, template_args
,
172 content_type
='application/xml')
178 def get_subscriptions(user
, device_uid
, user_agent
=None):
179 device
= get_device(user
, device_uid
, user_agent
)
180 return device
.get_subscribed_podcasts()
183 def parse_subscription(raw_post_data
, format
):
185 urls
= raw_post_data
.split('\n')
187 elif format
== 'opml':
188 begin
= raw_post_data
.find('<?xml')
189 end
= raw_post_data
.find('</opml>') + 7
190 i
= Importer(content
=raw_post_data
[begin
:end
])
191 urls
= [p
['url'] for p
in i
.items
]
193 elif format
== 'json':
194 begin
= raw_post_data
.find('[')
195 end
= raw_post_data
.find(']') + 1
196 urls
= json
.loads(raw_post_data
[begin
:end
])
202 urls
= map(normalize_feed_url
, urls
)
203 urls
= filter(None, urls
)
208 def set_subscriptions(urls
, user
, device_uid
, user_agent
):
210 device
= get_device(user
, device_uid
, user_agent
, undelete
=True)
212 subscriptions
= dict( (p
.url
, p
) for p
in device
.get_subscribed_podcasts())
213 new
= [p
for p
in urls
if p
not in subscriptions
.keys()]
214 rem
= [p
for p
in subscriptions
.keys() if p
not in urls
]
216 subscriber
= BulkSubscribe(user
, device
, podcasts
=subscriptions
)
219 subscriber
.add_action(r
, 'unsubscribe')
222 subscriber
.add_action(n
, 'subscribe')
225 errors
= subscriber
.execute()
226 except BulkException
as be
:
227 for err
in be
.errors
:
228 logger
.warn('Simple API: %(username)s: Updating subscription for '
229 '%(podcast_url)s on %(device_uid)s failed: '
230 '%(error)s (%(reason)s)'.format(username
=user
.username
,
231 podcast_url
=err
.doc
, device_uid
=device
.uid
,
232 error
=err
.error
, reason
=err
.reason
)
235 # Only an empty response is a successful response
236 return HttpResponse('', content_type
='text/plain')
240 @allowed_methods(['GET'])
243 def toplist(request
, count
, format
):
244 count
= parse_range(count
, 1, 100, 100)
246 entries
= Podcast
.objects
.all().toplist()[:count
]
247 domain
= RequestSite(request
).domain
250 scale
= int(request
.GET
.get('scale_logo', 64))
251 except (TypeError, ValueError):
252 return HttpResponseBadRequest('scale_logo has to be a numeric value')
254 if scale
not in range(1, 257):
255 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
263 p
= podcast_data(podcast
, domain
, scale
)
266 title
= _('gpodder.net - Top %(count)d') % {'count': len(entries
)}
267 return format_podcast_list(entries
,
270 get_podcast
=get_podcast
,
272 jsonp_padding
=request
.GET
.get('jsonp', ''),
273 xml_template
='podcasts.xml',
280 @allowed_methods(['GET'])
282 def search(request
, format
):
286 query
= request
.GET
.get('q', '').encode('utf-8')
289 scale
= int(request
.GET
.get('scale_logo', 64))
290 except (TypeError, ValueError):
291 return HttpResponseBadRequest('scale_logo has to be a numeric value')
293 if scale
not in range(1, 257):
294 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
297 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
299 results
= search_podcasts(query
)[:NUM_RESULTS
]
301 title
= _('gpodder.net - Search')
302 domain
= RequestSite(request
).domain
303 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
304 return format_podcast_list(results
, format
, title
, json_map
=p_data
, jsonp_padding
=request
.GET
.get('jsonp', ''), xml_template
='podcasts.xml', request
=request
)
310 @allowed_methods(['GET'])
312 def suggestions(request
, count
, format
):
313 count
= parse_range(count
, 1, 100, 100)
315 suggestion_obj
= suggestions_for_user(request
.user
)
316 suggestions
= suggestion_obj
.get_podcasts(count
)
317 title
= _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions
)}
318 domain
= RequestSite(request
).domain
319 p_data
= lambda p
: podcast_data(p
, domain
)
320 return format_podcast_list(suggestions
, format
, title
, json_map
=p_data
, jsonp_padding
=request
.GET
.get('jsonp'))
324 @allowed_methods(['GET'])
327 def example_podcasts(request
, format
):
329 podcasts
= cache
.get('example-podcasts', None)
332 scale
= int(request
.GET
.get('scale_logo', 64))
333 except (TypeError, ValueError):
334 return HttpResponseBadRequest('scale_logo has to be a numeric value')
336 if scale
not in range(1, 257):
337 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
343 examples
= ExamplePodcasts
.get('example_podcasts')
344 ids
= examples
.podcast_ids
345 podcasts
= Podcasts
.objects
.filter(id__in
=ids
)
346 cache
.set('example-podcasts', podcasts
)
348 except ResourceNotFound
:
351 title
= 'gPodder Podcast Directory'
352 domain
= RequestSite(request
).domain
353 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
354 return format_podcast_list(
359 xml_template
='podcasts.xml',