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
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
.subscriptions
import get_subscribed_podcasts
, subscribe
, unsubscribe
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
.user
import suggestions_for_user
48 logger
= logging
.getLogger(__name__
)
51 ALLOWED_FORMATS
= ('txt', 'opml', 'json', 'jsonp', 'xml')
55 def tmp(request
, format
, *args
, **kwargs
):
56 if not format
in ALLOWED_FORMATS
:
57 return HttpResponseBadRequest('Invalid format')
59 return fn(request
, *args
, format
=format
, **kwargs
)
68 @allowed_methods(['GET', 'PUT', 'POST'])
70 def subscriptions(request
, username
, device_uid
, format
):
72 user_agent
= request
.META
.get('HTTP_USER_AGENT', '')
74 if request
.method
== 'GET':
75 title
= _('%(username)s\'s Subscription List') % {'username': username
}
76 subscriptions
= get_subscriptions(request
.user
, device_uid
, user_agent
)
77 return format_podcast_list(subscriptions
, format
, title
, jsonp_padding
=request
.GET
.get('jsonp'))
79 elif request
.method
in ('PUT', 'POST'):
81 subscriptions
= parse_subscription(request
.body
, format
)
83 except JSONDecodeError
as e
:
84 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e
))
86 return set_subscriptions(subscriptions
, request
.user
, device_uid
,
95 @allowed_methods(['GET'])
97 def all_subscriptions(request
, username
, format
):
100 scale
= int(request
.GET
.get('scale_logo', 64))
101 except (TypeError, ValueError):
102 return HttpResponseBadRequest('scale_logo has to be a numeric value')
104 if scale
not in range(1, 257):
105 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
108 subscriptions
= get_subscribed_podcasts(request
.user
)
109 title
= _('%(username)s\'s Subscription List') % {'username': username
}
110 domain
= RequestSite(request
).domain
111 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
112 return format_podcast_list(subscriptions
, format
, title
,
113 json_map
=p_data
, xml_template
='podcasts.xml', request
=request
)
116 def format_podcast_list(obj_list
, format
, title
, get_podcast
=None,
117 json_map
=lambda x
: x
.url
, jsonp_padding
=None,
118 xml_template
=None, request
=None, template_args
={}):
120 Formats a list of podcasts for use in a API response
122 obj_list is a list of podcasts or objects that contain podcasts
123 format is one if txt, opml or json
124 title is a label of the list
125 if obj_list is a list of objects containing podcasts, get_podcast is the
126 function used to get the podcast out of the each of these objects
127 json_map is a function returning the contents of an object (from obj_list)
128 that should be contained in the result (only used for format='json')
131 def default_get_podcast(p
):
134 get_podcast
= get_podcast
or default_get_podcast
137 podcasts
= map(get_podcast
, obj_list
)
138 s
= '\n'.join([p
.url
for p
in podcasts
] + [''])
139 return HttpResponse(s
, content_type
='text/plain')
141 elif format
== 'opml':
142 podcasts
= map(get_podcast
, obj_list
)
143 exporter
= Exporter(title
)
144 opml
= exporter
.generate(podcasts
)
145 return HttpResponse(opml
, content_type
='text/xml')
147 elif format
== 'json':
148 objs
= map(json_map
, obj_list
)
149 return JsonResponse(objs
)
151 elif format
== 'jsonp':
152 ALLOWED_FUNCNAME
= string
.letters
+ string
.digits
+ '_'
154 if not jsonp_padding
:
155 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
157 if any(x
not in ALLOWED_FUNCNAME
for x
in jsonp_padding
):
158 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME
})
160 objs
= map(json_map
, obj_list
)
161 return JsonResponse(objs
, jsonp_padding
=jsonp_padding
)
163 elif format
== 'xml':
164 if None in (xml_template
, request
):
165 return HttpResponseBadRequest('XML is not a valid format for this request')
167 podcasts
= map(json_map
, obj_list
)
168 template_args
.update({'podcasts': podcasts
})
170 return render(request
, xml_template
, template_args
,
171 content_type
='application/xml')
177 def get_subscriptions(user
, device_uid
, user_agent
=None):
178 device
= get_device(user
, device_uid
, user_agent
)
179 return device
.get_subscribed_podcasts()
182 def parse_subscription(raw_post_data
, format
):
184 urls
= raw_post_data
.split('\n')
186 elif format
== 'opml':
187 begin
= raw_post_data
.find('<?xml')
188 end
= raw_post_data
.find('</opml>') + 7
189 i
= Importer(content
=raw_post_data
[begin
:end
])
190 urls
= [p
['url'] for p
in i
.items
]
192 elif format
== 'json':
193 begin
= raw_post_data
.find('[')
194 end
= raw_post_data
.find(']') + 1
195 urls
= json
.loads(raw_post_data
[begin
:end
])
201 urls
= map(normalize_feed_url
, urls
)
202 urls
= filter(None, urls
)
207 def set_subscriptions(urls
, user
, device_uid
, user_agent
):
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
)
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')
228 @allowed_methods(['GET'])
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
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')
251 p
= podcast_data(podcast
, domain
, scale
)
254 title
= _('gpodder.net - Top %(count)d') % {'count': len(entries
)}
255 return format_podcast_list(entries
,
258 get_podcast
=get_podcast
,
260 jsonp_padding
=request
.GET
.get('jsonp', ''),
261 xml_template
='podcasts.xml',
268 @allowed_methods(['GET'])
270 def search(request
, format
):
274 query
= request
.GET
.get('q', '').encode('utf-8')
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')
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
)
298 @allowed_methods(['GET'])
300 def suggestions(request
, count
, format
):
301 count
= parse_range(count
, 1, 100, 100)
303 suggestion_obj
= suggestions_for_user(request
.user
)
304 suggestions
= suggestion_obj
.get_podcasts(count
)
305 title
= _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions
)}
306 domain
= RequestSite(request
).domain
307 p_data
= lambda p
: podcast_data(p
, domain
)
308 return format_podcast_list(suggestions
, format
, title
, json_map
=p_data
, jsonp_padding
=request
.GET
.get('jsonp'))
312 @allowed_methods(['GET'])
315 def example_podcasts(request
, format
):
317 podcasts
= cache
.get('example-podcasts', None)
320 scale
= int(request
.GET
.get('scale_logo', 64))
321 except (TypeError, ValueError):
322 return HttpResponseBadRequest('scale_logo has to be a numeric value')
324 if scale
not in range(1, 257):
325 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
331 examples
= ExamplePodcasts
.get('example_podcasts')
332 ids
= examples
.podcast_ids
333 podcasts
= Podcasts
.objects
.filter(id__in
=ids
)
334 cache
.set('example-podcasts', podcasts
)
336 except ResourceNotFound
:
339 title
= 'gPodder Podcast Directory'
340 domain
= RequestSite(request
).domain
341 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
342 return format_podcast_list(
347 xml_template
='podcasts.xml',