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/>.
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
45 logger
= logging
.getLogger(__name__
)
48 ALLOWED_FORMATS
= ('txt', 'opml', 'json', 'jsonp', 'xml')
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
)
65 @allowed_methods(['GET', 'PUT', 'POST'])
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'):
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
,
93 @allowed_methods(['GET'])
95 def all_subscriptions(request
, username
, format
):
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
):
132 get_podcast
= get_podcast
or default_get_podcast
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')
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 """
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
])
199 urls
= filter(None, urls
)
200 urls
= list(map(normalize_feed_url
, urls
))
204 def set_subscriptions(urls
, user
, device_uid
, user_agent
):
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
)
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', '')
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)
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'))
313 @allowed_methods(['GET'])
316 def example_podcasts(request
, format
):
318 podcasts
= cache
.get('example-podcasts', None)
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')
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(
340 xml_template
='podcasts.xml',