add flattr buttons to podcast, episode pages
[mygpo.git] / mygpo / core / models.py
blob2f02b7e8266083d62323024658abf5c023d220f4
1 from __future__ import division
3 import hashlib
4 import re
5 from random import random
7 from couchdbkit.ext.django.schema import *
8 from restkit.errors import Unauthorized
10 from django.core.urlresolvers import reverse
12 from mygpo.decorators import repeat_on_conflict
13 from mygpo import utils
14 from mygpo.cache import cache_result
15 from mygpo.core.proxy import DocumentABCMeta
16 from mygpo.core.slugs import SlugMixin
17 from mygpo.core.oldid import OldIdMixin
18 from mygpo.web.logo import CoverArt
21 class SubscriptionException(Exception):
22 pass
25 class MergedIdException(Exception):
26 """ raised when an object is accessed through one of its merged_ids """
28 def __init__(self, obj, current_id):
29 self.obj = obj
30 self.current_id = current_id
33 class Episode(Document, SlugMixin, OldIdMixin):
34 """
35 Represents an Episode. Can only be part of a Podcast
36 """
38 __metaclass__ = DocumentABCMeta
40 title = StringProperty()
41 guid = StringProperty()
42 description = StringProperty(default="")
43 content = StringProperty(default="")
44 link = StringProperty()
45 released = DateTimeProperty()
46 author = StringProperty()
47 duration = IntegerProperty()
48 filesize = IntegerProperty()
49 language = StringProperty()
50 last_update = DateTimeProperty()
51 outdated = BooleanProperty(default=False)
52 mimetypes = StringListProperty()
53 merged_ids = StringListProperty()
54 urls = StringListProperty()
55 podcast = StringProperty(required=True)
56 listeners = IntegerProperty()
57 content_types = StringListProperty()
58 flattr_url = StringProperty()
62 @property
63 def url(self):
64 return self.urls[0]
66 def __repr__(self):
67 return 'Episode %s' % self._id
71 def get_short_title(self, common_title):
72 if not self.title or not common_title:
73 return None
75 title = self.title.replace(common_title, '').strip()
76 title = re.sub(r'^[\W\d]+', '', title)
77 return title
80 def get_episode_number(self, common_title):
81 if not self.title or not common_title:
82 return None
84 title = self.title.replace(common_title, '').strip()
85 match = re.search(r'^\W*(\d+)', title)
86 if not match:
87 return None
89 return int(match.group(1))
92 def get_ids(self):
93 return set([self._id] + self.merged_ids)
96 def __eq__(self, other):
97 if other == None:
98 return False
99 return self._id == other._id
102 def __hash__(self):
103 return hash(self._id)
106 def __str__(self):
107 return '<{cls} {title} ({id})>'.format(cls=self.__class__.__name__,
108 title=self.title, id=self._id)
110 __repr__ = __str__
113 class SubscriberData(DocumentSchema):
114 timestamp = DateTimeProperty()
115 subscriber_count = IntegerProperty()
117 def __eq__(self, other):
118 if not isinstance(other, SubscriberData):
119 return False
121 return (self.timestamp == other.timestamp) and \
122 (self.subscriber_count == other.subscriber_count)
124 def __hash__(self):
125 return hash(frozenset([self.timestamp, self.subscriber_count]))
128 class PodcastSubscriberData(Document):
129 podcast = StringProperty()
130 subscribers = SchemaListProperty(SubscriberData)
133 def __repr__(self):
134 return 'PodcastSubscriberData for Podcast %s (%s)' % (self.podcast, self._id)
137 class Podcast(Document, SlugMixin, OldIdMixin):
139 __metaclass__ = DocumentABCMeta
141 id = StringProperty()
142 title = StringProperty()
143 urls = StringListProperty()
144 description = StringProperty()
145 link = StringProperty()
146 last_update = DateTimeProperty()
147 logo_url = StringProperty()
148 author = StringProperty()
149 merged_ids = StringListProperty()
150 group = StringProperty()
151 group_member_name = StringProperty()
152 related_podcasts = StringListProperty()
153 subscribers = SchemaListProperty(SubscriberData)
154 language = StringProperty()
155 content_types = StringListProperty()
156 tags = DictProperty()
157 restrictions = StringListProperty()
158 common_episode_title = StringProperty()
159 new_location = StringProperty()
160 latest_episode_timestamp = DateTimeProperty()
161 episode_count = IntegerProperty()
162 random_key = FloatProperty(default=random)
163 flattr_url = StringProperty()
167 def get_podcast_by_id(self, id, current_id=False):
168 if current_id and id != self.get_id():
169 raise MergedIdException(self, self.get_id())
171 return self
174 get_podcast_by_oldid = get_podcast_by_id
175 get_podcast_by_url = get_podcast_by_id
178 def get_id(self):
179 return self.id or self._id
181 def get_ids(self):
182 return set([self.get_id()] + self.merged_ids)
184 @property
185 def display_title(self):
186 return self.title or self.url
189 def group_with(self, other, grouptitle, myname, othername):
191 if self.group and (self.group == other.group):
192 # they are already grouped
193 return
195 group1 = PodcastGroup.get(self.group) if self.group else None
196 group2 = PodcastGroup.get(other.group) if other.group else None
198 if group1 and group2:
199 raise ValueError('both podcasts already are in different groups')
201 elif not (group1 or group2):
202 group = PodcastGroup(title=grouptitle)
203 group.save()
204 group.add_podcast(self, myname)
205 group.add_podcast(other, othername)
206 return group
208 elif group1:
209 group1.add_podcast(other, othername)
210 return group1
212 else:
213 group2.add_podcast(self, myname)
214 return group2
218 def get_common_episode_title(self, num_episodes=100):
220 if self.common_episode_title:
221 return self.common_episode_title
223 from mygpo.db.couchdb.episode import episodes_for_podcast
224 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
226 # We take all non-empty titles
227 titles = filter(None, (e.title for e in episodes))
228 # get the longest common substring
229 common_title = utils.longest_substr(titles)
231 # but consider only the part up to the first number. Otherwise we risk
232 # removing part of the number (eg if a feed contains episodes 100-199)
233 common_title = re.search(r'^\D*', common_title).group(0)
235 if len(common_title.strip()) < 2:
236 return None
238 return common_title
241 @cache_result(timeout=60*60)
242 def get_latest_episode(self):
243 # since = 1 ==> has a timestamp
245 from mygpo.db.couchdb.episode import episodes_for_podcast
246 episodes = episodes_for_podcast(self, since=1, descending=True, limit=1)
247 return next(iter(episodes), None)
250 def get_episode_before(self, episode):
251 if not episode.released:
252 return None
254 from mygpo.db.couchdb.episode import episodes_for_podcast
255 prevs = episodes_for_podcast(self, until=episode.released,
256 descending=True, limit=1)
258 return next(iter(prevs), None)
261 def get_episode_after(self, episode):
262 if not episode.released:
263 return None
265 from mygpo.db.couchdb.episode import episodes_for_podcast
266 nexts = episodes_for_podcast(self, since=episode.released, limit=1)
268 return next(iter(nexts), None)
271 @property
272 def url(self):
273 return self.urls[0]
276 def get_podcast(self):
277 return self
280 def get_logo_url(self, size):
281 if self.logo_url:
282 filename = hashlib.sha1(self.logo_url).hexdigest()
283 else:
284 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
286 prefix = CoverArt.get_prefix(filename)
288 return reverse('logo', args=[size, prefix, filename])
291 def subscriber_change(self):
292 prev = self.prev_subscriber_count()
293 if prev <= 0:
294 return 0
296 return self.subscriber_count() / prev
299 def subscriber_count(self):
300 if not self.subscribers:
301 return 0
302 return self.subscribers[-1].subscriber_count
305 def prev_subscriber_count(self):
306 if len(self.subscribers) < 2:
307 return 0
308 return self.subscribers[-2].subscriber_count
312 @repeat_on_conflict()
313 def subscribe(self, user, device):
314 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
315 state = podcast_state_for_user_podcast(user, self)
316 state.subscribe(device)
317 try:
318 state.save()
319 user.sync_all()
320 except Unauthorized as ex:
321 raise SubscriptionException(ex)
324 @repeat_on_conflict()
325 def unsubscribe(self, user, device):
326 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
327 state = podcast_state_for_user_podcast(user, self)
328 state.unsubscribe(device)
329 try:
330 state.save()
331 user.sync_all()
332 except Unauthorized as ex:
333 raise SubscriptionException(ex)
336 def subscribe_targets(self, user):
338 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
339 devices/syncgroups on which the podcast is already subscribed
341 targets = []
343 subscriptions_by_devices = user.get_subscriptions_by_device()
345 for group in user.get_grouped_devices():
347 if group.is_synced:
349 dev = group.devices[0]
351 if not self.get_id() in subscriptions_by_devices[dev.id]:
352 targets.append(group.devices)
354 else:
355 for device in group.devices:
356 if not self.get_id() in subscriptions_by_devices[device.id]:
357 targets.append(device)
359 return targets
362 def __hash__(self):
363 return hash(self.get_id())
366 def __repr__(self):
367 if not self._id:
368 return super(Podcast, self).__repr__()
369 elif self.oldid:
370 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
371 else:
372 return '%s %s' % (self.__class__.__name__, self.get_id())
375 def save(self):
376 group = getattr(self, 'group', None)
377 if group: #we are part of a PodcastGroup
378 group = PodcastGroup.get(group)
379 podcasts = list(group.podcasts)
381 if not self in podcasts:
382 # the podcast has not been added to the group correctly
383 group.add_podcast(self)
385 else:
386 i = podcasts.index(self)
387 podcasts[i] = self
388 group.podcasts = podcasts
389 group.save()
391 i = podcasts.index(self)
392 podcasts[i] = self
393 group.podcasts = podcasts
394 group.save()
396 else:
397 super(Podcast, self).save()
400 def delete(self):
401 group = getattr(self, 'group', None)
402 if group:
403 group = PodcastGroup.get(group)
404 podcasts = list(group.podcasts)
406 if self in podcasts:
407 i = podcasts.index(self)
408 del podcasts[i]
409 group.podcasts = podcasts
410 group.save()
412 else:
413 super(Podcast, self).delete()
416 def __eq__(self, other):
417 if not self.get_id():
418 return self == other
420 if other == None:
421 return False
423 return self.get_id() == other.get_id()
427 class PodcastGroup(Document, SlugMixin, OldIdMixin):
428 title = StringProperty()
429 podcasts = SchemaListProperty(Podcast)
431 def get_id(self):
432 return self._id
435 def get_podcast_by_id(self, id, current_id=False):
436 for podcast in self.podcasts:
437 if podcast.get_id() == id:
438 return podcast
440 if id in podcast.merged_ids:
441 if current_id:
442 raise MergedIdException(podcast, podcast.get_id())
444 return podcast
447 def get_podcast_by_oldid(self, oldid):
448 for podcast in list(self.podcasts):
449 if podcast.oldid == oldid:
450 return podcast
453 def get_podcast_by_url(self, url):
454 for podcast in self.podcasts:
455 if url in list(podcast.urls):
456 return podcast
459 def subscriber_change(self):
460 prev = self.prev_subscriber_count()
461 if not prev:
462 return 0
464 return self.subscriber_count() / prev
467 def subscriber_count(self):
468 return sum([p.subscriber_count() for p in self.podcasts])
471 def prev_subscriber_count(self):
472 return sum([p.prev_subscriber_count() for p in self.podcasts])
474 @property
475 def display_title(self):
476 return self.title
479 def get_podcast(self):
480 # return podcast with most subscribers (bug 1390)
481 return sorted(self.podcasts, key=Podcast.subscriber_count,
482 reverse=True)[0]
485 @property
486 def logo_url(self):
487 return utils.first(p.logo_url for p in self.podcasts)
490 def get_logo_url(self, size):
491 if self.logo_url:
492 filename = hashlib.sha1(self.logo_url).hexdigest()
493 else:
494 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
496 prefix = CoverArt.get_prefix(filename)
498 return reverse('logo', args=[size, prefix, filename])
501 def add_podcast(self, podcast, member_name):
503 if not self._id:
504 raise ValueError('group has to have an _id first')
506 if not podcast._id:
507 raise ValueError('podcast needs to have an _id first')
509 if not podcast.id:
510 podcast.id = podcast._id
512 podcast.delete()
513 podcast.group = self._id
514 podcast.group_member_name = member_name
515 self.podcasts = sorted(self.podcasts + [podcast],
516 key=Podcast.subscriber_count, reverse=True)
517 self.save()
520 def __repr__(self):
521 if not self._id:
522 return super(PodcastGroup, self).__repr__()
523 elif self.oldid:
524 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
525 else:
526 return '%s %s' % (self.__class__.__name__, self._id[:10])
530 class SanitizingRule(Document):
531 slug = StringProperty()
532 applies_to = StringListProperty()
533 search = StringProperty()
534 replace = StringProperty()
535 priority = IntegerProperty()
536 description = StringProperty()
539 def __repr__(self):
540 return 'SanitizingRule %s' % self._id