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
.couch
import BulkException
36 from mygpo
.core
import models
37 from mygpo
.core
.models
import Podcast
38 from mygpo
.users
.models
import Suggestions
39 from mygpo
.api
.opml
import Exporter
, Importer
40 from mygpo
.api
.httpresponse
import JsonResponse
41 from mygpo
.api
.sanitizing
import sanitize_urls
42 from mygpo
.directory
.toplist
import PodcastToplist
43 from mygpo
.directory
.models
import ExamplePodcasts
44 from mygpo
.api
.advanced
.directory
import podcast_data
45 from mygpo
.directory
.search
import search_podcasts
46 from mygpo
.log
import log
47 from mygpo
.decorators
import allowed_methods
48 from mygpo
.utils
import parse_range
49 from mygpo
.json
import json
50 from mygpo
.db
.couchdb
.podcast
import podcasts_by_id
51 from mygpo
.db
.couchdb
.user
import suggestions_for_user
53 ALLOWED_FORMATS
= ('txt', 'opml', 'json', 'jsonp', 'xml')
57 def tmp(request
, format
, *args
, **kwargs
):
58 if not format
in ALLOWED_FORMATS
:
59 return HttpResponseBadRequest('Invalid format')
61 return fn(request
, *args
, format
=format
, **kwargs
)
70 @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'):
81 subscriptions
= parse_subscription(request
.raw_post_data
, format
)
82 return set_subscriptions(subscriptions
, request
.user
, device_uid
,
91 @allowed_methods(['GET'])
92 def all_subscriptions(request
, username
, format
):
95 scale
= int(request
.GET
.get('scale_logo', 64))
96 except (TypeError, ValueError):
97 return HttpResponseBadRequest('scale_logo has to be a numeric value')
99 if scale
not in range(1, 257):
100 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
103 subscriptions
= request
.user
.get_subscribed_podcasts()
104 title
= _('%(username)s\'s Subscription List') % {'username': username
}
105 domain
= RequestSite(request
).domain
106 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
107 return format_podcast_list(subscriptions
, format
, title
,
108 json_map
=p_data
, xml_template
='podcasts.xml', request
=request
)
111 def format_podcast_list(obj_list
, format
, title
, get_podcast
=None,
112 json_map
=lambda x
: x
.url
, jsonp_padding
=None,
113 xml_template
=None, request
=None, template_args
={}):
115 Formats a list of podcasts for use in a API response
117 obj_list is a list of podcasts or objects that contain podcasts
118 format is one if txt, opml or json
119 title is a label of the list
120 if obj_list is a list of objects containing podcasts, get_podcast is the
121 function used to get the podcast out of the each of these objects
122 json_map is a function returning the contents of an object (from obj_list)
123 that should be contained in the result (only used for format='json')
126 def default_get_podcast(p
):
127 return p
.get_podcast()
129 get_podcast
= get_podcast
or default_get_podcast
132 podcasts
= map(get_podcast
, obj_list
)
133 s
= '\n'.join([p
.url
for p
in podcasts
] + [''])
134 return HttpResponse(s
, mimetype
='text/plain')
136 elif format
== 'opml':
137 podcasts
= map(get_podcast
, obj_list
)
138 exporter
= Exporter(title
)
139 opml
= exporter
.generate(podcasts
)
140 return HttpResponse(opml
, mimetype
='text/xml')
142 elif format
== 'json':
143 objs
= map(json_map
, obj_list
)
144 return JsonResponse(objs
)
146 elif format
== 'jsonp':
147 ALLOWED_FUNCNAME
= string
.letters
+ string
.digits
+ '_'
149 if not jsonp_padding
:
150 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
152 if any(x
not in ALLOWED_FUNCNAME
for x
in jsonp_padding
):
153 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME
})
155 objs
= map(json_map
, obj_list
)
156 return JsonResponse(objs
, jsonp_padding
=jsonp_padding
)
158 elif format
== 'xml':
159 if None in (xml_template
, request
):
160 return HttpResponseBadRequest('XML is not a valid format for this request')
162 podcasts
= map(json_map
, obj_list
)
163 template_args
.update({'podcasts': podcasts
})
165 return render(request
, xml_template
, template_args
,
166 content_type
='application/xml')
172 def get_subscriptions(user
, device_uid
, user_agent
=None):
173 device
= get_device(user
, device_uid
, user_agent
)
174 return device
.get_subscribed_podcasts()
177 def parse_subscription(raw_post_data
, format
):
179 urls
= raw_post_data
.split('\n')
181 elif format
== 'opml':
182 begin
= raw_post_data
.find('<?xml')
183 end
= raw_post_data
.find('</opml>') + 7
184 i
= Importer(content
=raw_post_data
[begin
:end
])
185 urls
= [p
['url'] for p
in i
.items
]
187 elif format
== 'json':
188 begin
= raw_post_data
.find('[')
189 end
= raw_post_data
.find(']') + 1
190 urls
= json
.loads(raw_post_data
[begin
:end
])
196 urls
= sanitize_urls(urls
)
197 urls
= filter(None, urls
)
202 def set_subscriptions(urls
, user
, device_uid
, user_agent
):
204 device
= get_device(user
, device_uid
, user_agent
, undelete
=True)
206 subscriptions
= dict( (p
.url
, p
) for p
in device
.get_subscribed_podcasts())
207 new
= [p
for p
in urls
if p
not in subscriptions
.keys()]
208 rem
= [p
for p
in subscriptions
.keys() if p
not in urls
]
210 subscriber
= BulkSubscribe(user
, device
, podcasts
=subscriptions
)
213 subscriber
.add_action(r
, 'unsubscribe')
216 subscriber
.add_action(n
, 'subscribe')
219 errors
= subscriber
.execute()
220 except BulkException
as be
:
221 for err
in be
.errors
:
222 log('Simple API: %(username)s: Updating subscription for '
223 '%(podcast_url)s on %(device_uid)s failed: '
224 '%(error)s (%(reason)s)'.format(username
=user
.username
,
225 podcast_url
=err
.doc
, device_uid
=device
.uid
,
226 error
=err
.error
, reason
=err
.reason
)
229 # Only an empty response is a successful response
230 return HttpResponse('', mimetype
='text/plain')
234 @allowed_methods(['GET'])
236 def toplist(request
, count
, format
):
237 count
= parse_range(count
, 1, 100, 100)
239 toplist
= PodcastToplist()
240 entries
= toplist
[:count
]
241 domain
= RequestSite(request
).domain
244 scale
= int(request
.GET
.get('scale_logo', 64))
245 except (TypeError, ValueError):
246 return HttpResponseBadRequest('scale_logo has to be a numeric value')
248 if scale
not in range(1, 257):
249 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
254 return podcast
.get_podcast()
258 podcast
.old_pos
= old_pos
260 p
= podcast_data(podcast
, domain
, scale
)
262 subscribers
= podcast
.subscriber_count(),
263 subscribers_last_week
= podcast
.prev_subscriber_count(),
264 position_last_week
= podcast
.old_pos
,
268 title
= _('gpodder.net - Top %(count)d') % {'count': len(entries
)}
269 return format_podcast_list(entries
,
272 get_podcast
=get_podcast
,
274 jsonp_padding
=request
.GET
.get('jsonp', ''),
275 xml_template
='podcasts.xml',
282 @allowed_methods(['GET'])
283 def search(request
, format
):
287 query
= request
.GET
.get('q', '').encode('utf-8')
290 scale
= int(request
.GET
.get('scale_logo', 64))
291 except (TypeError, ValueError):
292 return HttpResponseBadRequest('scale_logo has to be a numeric value')
294 if scale
not in range(1, 257):
295 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
298 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
300 results
, total
= search_podcasts(q
=query
, limit
=NUM_RESULTS
)
302 title
= _('gpodder.net - Search')
303 domain
= RequestSite(request
).domain
304 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
305 return format_podcast_list(results
, format
, title
, json_map
=p_data
, jsonp_padding
=request
.GET
.get('jsonp', ''), xml_template
='podcasts.xml', request
=request
)
311 @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'])
326 def example_podcasts(request
, format
):
328 podcasts
= cache
.get('example-podcasts', None)
331 scale
= int(request
.GET
.get('scale_logo', 64))
332 except (TypeError, ValueError):
333 return HttpResponseBadRequest('scale_logo has to be a numeric value')
335 if scale
not in range(1, 257):
336 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
342 examples
= ExamplePodcasts
.get('example_podcasts')
343 ids
= examples
.podcast_ids
344 podcasts
= podcasts_by_id(ids
)
345 cache
.set('example-podcasts', podcasts
)
347 except ResourceNotFound
:
350 title
= 'gPodder Podcast Directory'
351 domain
= RequestSite(request
).domain
352 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
353 return format_podcast_list(
358 xml_template
='podcasts.xml',