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 django
.shortcuts
import render
23 from django
.core
.cache
import cache
24 from django
.http
import HttpResponse
, HttpResponseBadRequest
25 from django
.views
.decorators
.cache
import cache_page
26 from django
.views
.decorators
.csrf
import csrf_exempt
27 from django
.views
.decorators
.cache
import never_cache
28 from django
.contrib
.sites
.models
import RequestSite
29 from django
.utils
.translation
import ugettext
as _
31 from mygpo
.api
.basic_auth
import require_valid_user
, check_username
32 from mygpo
.api
.backend
import get_device
33 from mygpo
.podcasts
.models
import Podcast
34 from mygpo
.api
.opml
import Exporter
, Importer
35 from mygpo
.api
.httpresponse
import JsonResponse
36 from mygpo
.directory
.models
import ExamplePodcast
37 from mygpo
.api
.advanced
.directory
import podcast_data
38 from mygpo
.subscriptions
import get_subscribed_podcasts
, subscribe
, unsubscribe
39 from mygpo
.directory
.search
import search_podcasts
40 from mygpo
.decorators
import allowed_methods
, cors_origin
41 from mygpo
.utils
import parse_range
, normalize_feed_url
42 from mygpo
.core
.json
import json
, JSONDecodeError
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 subscriptions
= parse_subscription(request
.body
, format
)
80 except JSONDecodeError
as e
:
81 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e
))
83 return set_subscriptions(subscriptions
, request
.user
, device_uid
,
92 @allowed_methods(['GET'])
94 def all_subscriptions(request
, username
, format
):
97 scale
= int(request
.GET
.get('scale_logo', 64))
98 except (TypeError, ValueError):
99 return HttpResponseBadRequest('scale_logo has to be a numeric value')
101 if scale
not in range(1, 257):
102 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
105 subscriptions
= get_subscribed_podcasts(request
.user
)
106 title
= _('%(username)s\'s Subscription List') % {'username': username
}
107 domain
= RequestSite(request
).domain
108 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
109 return format_podcast_list(subscriptions
, format
, title
,
110 json_map
=p_data
, xml_template
='podcasts.xml', request
=request
)
113 def format_podcast_list(obj_list
, format
, title
, get_podcast
=None,
114 json_map
=lambda x
: x
.url
, jsonp_padding
=None,
115 xml_template
=None, request
=None, template_args
={}):
117 Formats a list of podcasts for use in a API response
119 obj_list is a list of podcasts or objects that contain podcasts
120 format is one if txt, opml or json
121 title is a label of the list
122 if obj_list is a list of objects containing podcasts, get_podcast is the
123 function used to get the podcast out of the each of these objects
124 json_map is a function returning the contents of an object (from obj_list)
125 that should be contained in the result (only used for format='json')
128 def default_get_podcast(p
):
131 get_podcast
= get_podcast
or default_get_podcast
134 podcasts
= map(get_podcast
, obj_list
)
135 s
= '\n'.join([p
.url
for p
in podcasts
] + [''])
136 return HttpResponse(s
, content_type
='text/plain')
138 elif format
== 'opml':
139 podcasts
= map(get_podcast
, obj_list
)
140 exporter
= Exporter(title
)
141 opml
= exporter
.generate(podcasts
)
142 return HttpResponse(opml
, content_type
='text/xml')
144 elif format
== 'json':
145 objs
= map(json_map
, obj_list
)
146 return JsonResponse(objs
)
148 elif format
== 'jsonp':
149 ALLOWED_FUNCNAME
= string
.letters
+ string
.digits
+ '_'
151 if not jsonp_padding
:
152 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
154 if any(x
not in ALLOWED_FUNCNAME
for x
in jsonp_padding
):
155 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME
})
157 objs
= map(json_map
, obj_list
)
158 return JsonResponse(objs
, jsonp_padding
=jsonp_padding
)
160 elif format
== 'xml':
161 if None in (xml_template
, request
):
162 return HttpResponseBadRequest('XML is not a valid format for this request')
164 podcasts
= map(json_map
, obj_list
)
165 template_args
.update({'podcasts': podcasts
})
167 return render(request
, xml_template
, template_args
,
168 content_type
='application/xml')
174 def get_subscriptions(user
, device_uid
, user_agent
=None):
175 device
= get_device(user
, device_uid
, user_agent
)
176 return device
.get_subscribed_podcasts()
179 def parse_subscription(raw_post_data
, format
):
180 """ Parses the data according to the format """
182 urls
= raw_post_data
.split('\n')
184 elif format
== 'opml':
185 begin
= raw_post_data
.find('<?xml')
186 end
= raw_post_data
.find('</opml>') + 7
187 i
= Importer(content
=raw_post_data
[begin
:end
])
188 urls
= [p
['url'] for p
in i
.items
]
190 elif format
== 'json':
191 begin
= raw_post_data
.find('[')
192 end
= raw_post_data
.find(']') + 1
193 urls
= json
.loads(raw_post_data
[begin
:end
])
198 urls
= filter(None, urls
)
199 urls
= map(normalize_feed_url
, urls
)
203 def set_subscriptions(urls
, user
, device_uid
, user_agent
):
205 device
= get_device(user
, device_uid
, user_agent
, undelete
=True)
207 subscriptions
= dict( (p
.url
, p
) for p
in device
.get_subscribed_podcasts())
208 new
= [p
for p
in urls
if p
not in subscriptions
.keys()]
209 rem
= [p
for p
in subscriptions
.keys() if p
not in urls
]
211 remove_podcasts
= Podcast
.objects
.filter(urls__url__in
=rem
)
212 for podcast
in remove_podcasts
:
213 unsubscribe(podcast
, user
, device
)
216 podcast
= Podcast
.objects
.get_or_create_for_url(url
)
217 subscribe(podcast
, user
, device
, url
)
219 # Only an empty response is a successful response
220 return HttpResponse('', content_type
='text/plain')
224 @allowed_methods(['GET'])
227 def toplist(request
, count
, format
):
228 count
= parse_range(count
, 1, 100, 100)
230 entries
= Podcast
.objects
.all().toplist()[:count
]
231 domain
= RequestSite(request
).domain
234 scale
= int(request
.GET
.get('scale_logo', 64))
235 except (TypeError, ValueError):
236 return HttpResponseBadRequest('scale_logo has to be a numeric value')
238 if scale
not in range(1, 257):
239 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
247 p
= podcast_data(podcast
, domain
, scale
)
250 title
= _('gpodder.net - Top %(count)d') % {'count': len(entries
)}
251 return format_podcast_list(entries
,
254 get_podcast
=get_podcast
,
256 jsonp_padding
=request
.GET
.get('jsonp', ''),
257 xml_template
='podcasts.xml',
264 @allowed_methods(['GET'])
266 def search(request
, format
):
270 query
= request
.GET
.get('q', '').encode('utf-8')
273 scale
= int(request
.GET
.get('scale_logo', 64))
274 except (TypeError, ValueError):
275 return HttpResponseBadRequest('scale_logo has to be a numeric value')
277 if scale
not in range(1, 257):
278 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
281 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
283 results
= search_podcasts(query
)[:NUM_RESULTS
]
285 title
= _('gpodder.net - Search')
286 domain
= RequestSite(request
).domain
287 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
288 return format_podcast_list(results
, format
, title
, json_map
=p_data
, jsonp_padding
=request
.GET
.get('jsonp', ''), xml_template
='podcasts.xml', request
=request
)
294 @allowed_methods(['GET'])
296 def suggestions(request
, count
, format
):
297 count
= parse_range(count
, 1, 100, 100)
300 suggestions
= Podcast
.objects
.filter(podcastsuggestion__suggested_to
=user
,
301 podcastsuggestion__deleted
=False)
302 title
= _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions
)}
303 domain
= RequestSite(request
).domain
304 p_data
= lambda p
: podcast_data(p
, domain
)
305 return format_podcast_list(suggestions
, format
, title
, json_map
=p_data
, jsonp_padding
=request
.GET
.get('jsonp'))
309 @allowed_methods(['GET'])
312 def example_podcasts(request
, format
):
314 podcasts
= cache
.get('example-podcasts', None)
317 scale
= int(request
.GET
.get('scale_logo', 64))
318 except (TypeError, ValueError):
319 return HttpResponseBadRequest('scale_logo has to be a numeric value')
321 if scale
not in range(1, 257):
322 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
325 podcasts
= ExamplePodcast
.objects
.get_podcasts()
326 cache
.set('example-podcasts', podcasts
)
328 title
= 'gPodder Podcast Directory'
329 domain
= RequestSite(request
).domain
330 p_data
= lambda p
: podcast_data(p
, domain
, scale
)
331 return format_podcast_list(
336 xml_template
='podcasts.xml',