update suggestions when subscriptions change
[mygpo.git] / mygpo / core / models.py
blob6539a8c16e33f29c2b7f263ca4b9ee77f3c09847
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
20 # make sure this code is executed at startup
21 from mygpo.core.signals import *
24 class SubscriptionException(Exception):
25 pass
28 class MergedIdException(Exception):
29 """ raised when an object is accessed through one of its merged_ids """
31 def __init__(self, obj, current_id):
32 self.obj = obj
33 self.current_id = current_id
36 class Episode(Document, SlugMixin, OldIdMixin):
37 """
38 Represents an Episode. Can only be part of a Podcast
39 """
41 __metaclass__ = DocumentABCMeta
43 title = StringProperty()
44 guid = StringProperty()
45 description = StringProperty(default="")
46 content = StringProperty(default="")
47 link = StringProperty()
48 released = DateTimeProperty()
49 author = StringProperty()
50 duration = IntegerProperty()
51 filesize = IntegerProperty()
52 language = StringProperty()
53 last_update = DateTimeProperty()
54 outdated = BooleanProperty(default=False)
55 mimetypes = StringListProperty()
56 merged_ids = StringListProperty()
57 urls = StringListProperty()
58 podcast = StringProperty(required=True)
59 listeners = IntegerProperty()
60 content_types = StringListProperty()
61 flattr_url = StringProperty()
62 created_timestamp = IntegerProperty()
66 @property
67 def url(self):
68 return self.urls[0]
70 def __repr__(self):
71 return 'Episode %s' % self._id
75 def get_short_title(self, common_title):
76 if not self.title or not common_title:
77 return None
79 title = self.title.replace(common_title, '').strip()
80 title = re.sub(r'^[\W\d]+', '', title)
81 return title
84 def get_episode_number(self, common_title):
85 if not self.title or not common_title:
86 return None
88 title = self.title.replace(common_title, '').strip()
89 match = re.search(r'^\W*(\d+)', title)
90 if not match:
91 return None
93 return int(match.group(1))
96 def get_ids(self):
97 return set([self._id] + self.merged_ids)
100 @property
101 def needs_update(self):
102 """ Indicates if the object requires an updated from its feed """
103 return not self.title and not self.outdated
106 def __eq__(self, other):
107 if other is None:
108 return False
109 return self._id == other._id
112 def __hash__(self):
113 return hash(self._id)
116 def __unicode__(self):
117 return u'<{cls} {title} ({id})>'.format(cls=self.__class__.__name__,
118 title=self.title, id=self._id)
122 class SubscriberData(DocumentSchema):
123 timestamp = DateTimeProperty()
124 subscriber_count = IntegerProperty()
126 def __eq__(self, other):
127 if not isinstance(other, SubscriberData):
128 return False
130 return (self.timestamp == other.timestamp) and \
131 (self.subscriber_count == other.subscriber_count)
133 def __hash__(self):
134 return hash(frozenset([self.timestamp, self.subscriber_count]))
137 class PodcastSubscriberData(Document):
138 podcast = StringProperty()
139 subscribers = SchemaListProperty(SubscriberData)
142 def __repr__(self):
143 return 'PodcastSubscriberData for Podcast %s (%s)' % (self.podcast, self._id)
146 class Podcast(Document, SlugMixin, OldIdMixin):
148 __metaclass__ = DocumentABCMeta
150 id = StringProperty()
151 title = StringProperty()
152 urls = StringListProperty()
153 description = StringProperty()
154 link = StringProperty()
155 last_update = DateTimeProperty()
156 logo_url = StringProperty()
157 author = StringProperty()
158 merged_ids = StringListProperty()
159 group = StringProperty()
160 group_member_name = StringProperty()
161 related_podcasts = StringListProperty()
162 subscribers = SchemaListProperty(SubscriberData)
163 language = StringProperty()
164 content_types = StringListProperty()
165 tags = DictProperty()
166 restrictions = StringListProperty()
167 common_episode_title = StringProperty()
168 new_location = StringProperty()
169 latest_episode_timestamp = DateTimeProperty()
170 episode_count = IntegerProperty()
171 random_key = FloatProperty(default=random)
172 flattr_url = StringProperty()
173 outdated = BooleanProperty(default=False)
174 created_timestamp = IntegerProperty()
178 def get_podcast_by_id(self, id, current_id=False):
179 if current_id and id != self.get_id():
180 raise MergedIdException(self, self.get_id())
182 return self
185 get_podcast_by_oldid = get_podcast_by_id
186 get_podcast_by_url = get_podcast_by_id
189 def get_id(self):
190 return self.id or self._id
192 def get_ids(self):
193 return set([self.get_id()] + self.merged_ids)
195 @property
196 def display_title(self):
197 return self.title or self.url
200 def group_with(self, other, grouptitle, myname, othername):
202 if self.group and (self.group == other.group):
203 # they are already grouped
204 return
206 group1 = PodcastGroup.get(self.group) if self.group else None
207 group2 = PodcastGroup.get(other.group) if other.group else None
209 if group1 and group2:
210 raise ValueError('both podcasts already are in different groups')
212 elif not (group1 or group2):
213 group = PodcastGroup(title=grouptitle)
214 group.save()
215 group.add_podcast(self, myname)
216 group.add_podcast(other, othername)
217 return group
219 elif group1:
220 group1.add_podcast(other, othername)
221 return group1
223 else:
224 group2.add_podcast(self, myname)
225 return group2
229 def get_common_episode_title(self, num_episodes=100):
231 if self.common_episode_title:
232 return self.common_episode_title
234 from mygpo.db.couchdb.episode import episodes_for_podcast
235 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
237 # We take all non-empty titles
238 titles = filter(None, (e.title for e in episodes))
239 # get the longest common substring
240 common_title = utils.longest_substr(titles)
242 # but consider only the part up to the first number. Otherwise we risk
243 # removing part of the number (eg if a feed contains episodes 100-199)
244 common_title = re.search(r'^\D*', common_title).group(0)
246 if len(common_title.strip()) < 2:
247 return None
249 return common_title
252 def get_episode_before(self, episode):
253 if not episode.released:
254 return None
256 from mygpo.db.couchdb.episode import episodes_for_podcast
257 prevs = episodes_for_podcast(self, until=episode.released,
258 descending=True, limit=1)
260 return next(iter(prevs), None)
263 def get_episode_after(self, episode):
264 if not episode.released:
265 return None
267 from mygpo.db.couchdb.episode import episodes_for_podcast
268 from datetime import timedelta
269 nexts = episodes_for_podcast(self,
270 since=episode.released + timedelta(seconds=1), limit=1)
272 return next(iter(nexts), None)
275 @property
276 def url(self):
277 return self.urls[0]
280 def get_podcast(self):
281 return self
284 def get_logo_url(self, size):
285 if self.logo_url:
286 filename = hashlib.sha1(self.logo_url).hexdigest()
287 else:
288 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
290 prefix = CoverArt.get_prefix(filename)
292 return reverse('logo', args=[size, prefix, filename])
295 def subscriber_change(self):
296 prev = self.prev_subscriber_count()
297 if prev <= 0:
298 return 0
300 return self.subscriber_count() / prev
303 def subscriber_count(self):
304 if not self.subscribers:
305 return 0
306 return self.subscribers[-1].subscriber_count
309 def prev_subscriber_count(self):
310 if len(self.subscribers) < 2:
311 return 0
312 return self.subscribers[-2].subscriber_count
316 @repeat_on_conflict()
317 def subscribe(self, user, device):
318 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
319 state = podcast_state_for_user_podcast(user, self)
320 state.subscribe(device)
321 try:
322 state.save()
323 subscription_changed.send(sender=self, user=user, device=device,
324 subscribed=True)
325 except Unauthorized as ex:
326 raise SubscriptionException(ex)
329 @repeat_on_conflict()
330 def unsubscribe(self, user, device):
331 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
332 state = podcast_state_for_user_podcast(user, self)
333 state.unsubscribe(device)
334 try:
335 state.save()
336 subscription_changed.send(sender=self, user=user, device=device,
337 subscribed=False)
338 except Unauthorized as ex:
339 raise SubscriptionException(ex)
342 def subscribe_targets(self, user):
344 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
345 devices/syncgroups on which the podcast is already subscribed
347 targets = []
349 subscriptions_by_devices = user.get_subscriptions_by_device()
351 for group in user.get_grouped_devices():
353 if group.is_synced:
355 dev = group.devices[0]
357 if not self.get_id() in subscriptions_by_devices[dev.id]:
358 targets.append(group.devices)
360 else:
361 for device in group.devices:
362 if not self.get_id() in subscriptions_by_devices[device.id]:
363 targets.append(device)
365 return targets
368 @property
369 def needs_update(self):
370 """ Indicates if the object requires an updated from its feed """
371 return not self.title and not self.outdated
374 def __hash__(self):
375 return hash(self.get_id())
378 def __repr__(self):
379 if not self._id:
380 return super(Podcast, self).__repr__()
381 elif self.oldid:
382 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
383 else:
384 return '%s %s' % (self.__class__.__name__, self.get_id())
387 def save(self):
388 group = getattr(self, 'group', None)
389 if group: # we are part of a PodcastGroup
390 group = PodcastGroup.get(group)
391 podcasts = list(group.podcasts)
393 if not self in podcasts:
394 # the podcast has not been added to the group correctly
395 group.add_podcast(self)
397 else:
398 i = podcasts.index(self)
399 podcasts[i] = self
400 group.podcasts = podcasts
401 group.save()
403 i = podcasts.index(self)
404 podcasts[i] = self
405 group.podcasts = podcasts
406 group.save()
408 else:
409 super(Podcast, self).save()
412 def delete(self):
413 group = getattr(self, 'group', None)
414 if group:
415 group = PodcastGroup.get(group)
416 podcasts = list(group.podcasts)
418 if self in podcasts:
419 i = podcasts.index(self)
420 del podcasts[i]
421 group.podcasts = podcasts
422 group.save()
424 else:
425 super(Podcast, self).delete()
428 def __eq__(self, other):
429 if not self.get_id():
430 return self == other
432 if other is None:
433 return False
435 return self.get_id() == other.get_id()
439 class PodcastGroup(Document, SlugMixin, OldIdMixin):
440 title = StringProperty()
441 podcasts = SchemaListProperty(Podcast)
443 def get_id(self):
444 return self._id
447 def get_podcast_by_id(self, id, current_id=False):
448 for podcast in self.podcasts:
449 if podcast.get_id() == id:
450 return podcast
452 if id in podcast.merged_ids:
453 if current_id:
454 raise MergedIdException(podcast, podcast.get_id())
456 return podcast
459 def get_podcast_by_oldid(self, oldid):
460 for podcast in list(self.podcasts):
461 if podcast.oldid == oldid:
462 return podcast
465 def get_podcast_by_url(self, url):
466 for podcast in self.podcasts:
467 if url in list(podcast.urls):
468 return podcast
471 def subscriber_change(self):
472 prev = self.prev_subscriber_count()
473 if not prev:
474 return 0
476 return self.subscriber_count() / prev
479 def subscriber_count(self):
480 return sum([p.subscriber_count() for p in self.podcasts])
483 def prev_subscriber_count(self):
484 return sum([p.prev_subscriber_count() for p in self.podcasts])
486 @property
487 def display_title(self):
488 return self.title
491 @property
492 def needs_update(self):
493 """ Indicates if the object requires an updated from its feed """
494 # A PodcastGroup has been manually created and therefore never
495 # requires an update
496 return False
498 def get_podcast(self):
499 # return podcast with most subscribers (bug 1390)
500 return sorted(self.podcasts, key=Podcast.subscriber_count,
501 reverse=True)[0]
504 @property
505 def logo_url(self):
506 return utils.first(p.logo_url for p in self.podcasts)
508 @logo_url.setter
509 def logo_url(self, value):
510 self.podcasts[0].logo_url = value
513 def get_logo_url(self, size):
514 if self.logo_url:
515 filename = hashlib.sha1(self.logo_url).hexdigest()
516 else:
517 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
519 prefix = CoverArt.get_prefix(filename)
521 return reverse('logo', args=[size, prefix, filename])
524 def add_podcast(self, podcast, member_name):
526 if not self._id:
527 raise ValueError('group has to have an _id first')
529 if not podcast._id:
530 raise ValueError('podcast needs to have an _id first')
532 if not podcast.id:
533 podcast.id = podcast._id
535 podcast.delete()
536 podcast.group = self._id
537 podcast.group_member_name = member_name
538 self.podcasts = sorted(self.podcasts + [podcast],
539 key=Podcast.subscriber_count, reverse=True)
540 self.save()
543 def __repr__(self):
544 if not self._id:
545 return super(PodcastGroup, self).__repr__()
546 elif self.oldid:
547 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
548 else:
549 return '%s %s' % (self.__class__.__name__, self._id[:10])
553 class SanitizingRule(Document):
554 slug = StringProperty()
555 applies_to = StringListProperty()
556 search = StringProperty()
557 replace = StringProperty()
558 priority = IntegerProperty()
559 description = StringProperty()
562 def __repr__(self):
563 return 'SanitizingRule %s' % self._id