avoid conflict when confirming pubsub sbuscription
[mygpo.git] / mygpo / core / models.py
blob19f207d9755afceb441e3b482c3c35ee05443451
1 from __future__ import division
3 import hashlib
4 import re
5 from random import random
6 from datetime import timedelta
8 from couchdbkit.ext.django.schema import *
9 from restkit.errors import Unauthorized
11 from django.core.urlresolvers import reverse
13 from mygpo.decorators import repeat_on_conflict
14 from mygpo import utils
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 # default podcast update interval in hours
25 DEFAULT_UPDATE_INTERVAL = 7 * 24
27 # minium podcast update interval in hours
28 MIN_UPDATE_INTERVAL = 5
30 # every podcast should be updated at least once a month
31 MAX_UPDATE_INTERVAL = 24 * 30
34 class SubscriptionException(Exception):
35 pass
38 class MergedIdException(Exception):
39 """ raised when an object is accessed through one of its merged_ids """
41 def __init__(self, obj, current_id):
42 self.obj = obj
43 self.current_id = current_id
46 class Episode(Document, SlugMixin, OldIdMixin):
47 """
48 Represents an Episode. Can only be part of a Podcast
49 """
51 __metaclass__ = DocumentABCMeta
53 title = StringProperty()
54 guid = StringProperty()
55 description = StringProperty(default="")
56 subtitle = StringProperty()
57 content = StringProperty(default="")
58 link = StringProperty()
59 released = DateTimeProperty()
60 author = StringProperty()
61 duration = IntegerProperty()
62 filesize = IntegerProperty()
63 language = StringProperty()
64 last_update = DateTimeProperty()
65 outdated = BooleanProperty(default=False)
66 mimetypes = StringListProperty()
67 merged_ids = StringListProperty()
68 urls = StringListProperty()
69 podcast = StringProperty(required=True)
70 listeners = IntegerProperty()
71 content_types = StringListProperty()
72 flattr_url = StringProperty()
73 created_timestamp = IntegerProperty()
74 license = StringProperty()
78 @property
79 def url(self):
80 return self.urls[0]
82 def __repr__(self):
83 return 'Episode %s' % self._id
87 def get_short_title(self, common_title):
88 if not self.title or not common_title:
89 return None
91 title = self.title.replace(common_title, '').strip()
92 title = re.sub(r'^[\W\d]+', '', title)
93 return title
96 def get_episode_number(self, common_title):
97 if not self.title or not common_title:
98 return None
100 title = self.title.replace(common_title, '').strip()
101 match = re.search(r'^\W*(\d+)', title)
102 if not match:
103 return None
105 return int(match.group(1))
108 def get_ids(self):
109 return set([self._id] + self.merged_ids)
112 @property
113 def needs_update(self):
114 """ Indicates if the object requires an updated from its feed """
115 return not self.title and not self.outdated
117 def __eq__(self, other):
118 if other is None:
119 return False
120 return self._id == other._id
123 def __hash__(self):
124 return hash(self._id)
127 def __unicode__(self):
128 return u'<{cls} {title} ({id})>'.format(cls=self.__class__.__name__,
129 title=self.title, id=self._id)
133 class SubscriberData(DocumentSchema):
134 timestamp = DateTimeProperty()
135 subscriber_count = IntegerProperty()
137 def __eq__(self, other):
138 if not isinstance(other, SubscriberData):
139 return False
141 return (self.timestamp == other.timestamp) and \
142 (self.subscriber_count == other.subscriber_count)
144 def __hash__(self):
145 return hash(frozenset([self.timestamp, self.subscriber_count]))
148 class PodcastSubscriberData(Document):
149 podcast = StringProperty()
150 subscribers = SchemaListProperty(SubscriberData)
153 def __repr__(self):
154 return 'PodcastSubscriberData for Podcast %s (%s)' % (self.podcast, self._id)
157 class Podcast(Document, SlugMixin, OldIdMixin):
159 __metaclass__ = DocumentABCMeta
161 id = StringProperty()
162 title = StringProperty()
163 urls = StringListProperty()
164 description = StringProperty()
165 subtitle = StringProperty()
166 link = StringProperty()
167 last_update = DateTimeProperty()
168 logo_url = StringProperty()
169 author = StringProperty()
170 merged_ids = StringListProperty()
171 group = StringProperty()
172 group_member_name = StringProperty()
173 related_podcasts = StringListProperty()
174 subscribers = SchemaListProperty(SubscriberData)
175 language = StringProperty()
176 content_types = StringListProperty()
177 tags = DictProperty()
178 restrictions = StringListProperty()
179 common_episode_title = StringProperty()
180 new_location = StringProperty()
181 latest_episode_timestamp = DateTimeProperty()
182 episode_count = IntegerProperty()
183 random_key = FloatProperty(default=random)
184 flattr_url = StringProperty()
185 outdated = BooleanProperty(default=False)
186 created_timestamp = IntegerProperty()
187 hub = StringProperty()
188 license = StringProperty()
190 # avg time between podcast updates (eg new episodes) in hours
191 update_interval = IntegerProperty(default=DEFAULT_UPDATE_INTERVAL)
194 def get_podcast_by_id(self, id, current_id=False):
195 if current_id and id != self.get_id():
196 raise MergedIdException(self, self.get_id())
198 return self
201 get_podcast_by_oldid = get_podcast_by_id
202 get_podcast_by_url = get_podcast_by_id
205 def get_id(self):
206 return self.id or self._id
208 def get_ids(self):
209 return set([self.get_id()] + self.merged_ids)
211 @property
212 def display_title(self):
213 return self.title or self.url
216 def group_with(self, other, grouptitle, myname, othername):
218 if self.group and (self.group == other.group):
219 # they are already grouped
220 return
222 group1 = PodcastGroup.get(self.group) if self.group else None
223 group2 = PodcastGroup.get(other.group) if other.group else None
225 if group1 and group2:
226 raise ValueError('both podcasts already are in different groups')
228 elif not (group1 or group2):
229 group = PodcastGroup(title=grouptitle)
230 group.save()
231 group.add_podcast(self, myname)
232 group.add_podcast(other, othername)
233 return group
235 elif group1:
236 group1.add_podcast(other, othername)
237 return group1
239 else:
240 group2.add_podcast(self, myname)
241 return group2
245 def get_common_episode_title(self, num_episodes=100):
247 if self.common_episode_title:
248 return self.common_episode_title
250 from mygpo.db.couchdb.episode import episodes_for_podcast
251 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
253 # We take all non-empty titles
254 titles = filter(None, (e.title for e in episodes))
255 # get the longest common substring
256 common_title = utils.longest_substr(titles)
258 # but consider only the part up to the first number. Otherwise we risk
259 # removing part of the number (eg if a feed contains episodes 100-199)
260 common_title = re.search(r'^\D*', common_title).group(0)
262 if len(common_title.strip()) < 2:
263 return None
265 return common_title
268 def get_episode_before(self, episode):
269 if not episode.released:
270 return None
272 from mygpo.db.couchdb.episode import episodes_for_podcast
273 prevs = episodes_for_podcast(self, until=episode.released,
274 descending=True, limit=1)
276 return next(iter(prevs), None)
279 def get_episode_after(self, episode):
280 if not episode.released:
281 return None
283 from mygpo.db.couchdb.episode import episodes_for_podcast
284 nexts = episodes_for_podcast(self,
285 since=episode.released + timedelta(seconds=1), limit=1)
287 return next(iter(nexts), None)
290 @property
291 def url(self):
292 return self.urls[0]
295 def get_podcast(self):
296 return self
299 def get_logo_url(self, size):
300 if self.logo_url:
301 filename = hashlib.sha1(self.logo_url).hexdigest()
302 else:
303 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
305 prefix = CoverArt.get_prefix(filename)
307 return reverse('logo', args=[size, prefix, filename])
310 def subscriber_change(self):
311 prev = self.prev_subscriber_count()
312 if prev <= 0:
313 return 0
315 return self.subscriber_count() / prev
318 def subscriber_count(self):
319 if not self.subscribers:
320 return 0
321 return self.subscribers[-1].subscriber_count
324 def prev_subscriber_count(self):
325 if len(self.subscribers) < 2:
326 return 0
327 return self.subscribers[-2].subscriber_count
331 @repeat_on_conflict()
332 def subscribe(self, user, device):
333 """ subscribes user to the current podcast on one or more devices """
334 from mygpo.db.couchdb.podcast_state import subscribe_on_device, \
335 podcast_state_for_user_podcast
336 state = podcast_state_for_user_podcast(user, self)
338 # accept devices, and also lists and tuples of devices
339 devices = device if isinstance(device, (list, tuple)) else [device]
341 for device in devices:
343 try:
344 subscribe_on_device(state, device)
345 subscription_changed.send(sender=self, user=user,
346 device=device, subscribed=True)
347 except Unauthorized as ex:
348 raise SubscriptionException(ex)
351 @repeat_on_conflict()
352 def unsubscribe(self, user, device):
353 """ unsubscribes user from the current podcast on one or more devices """
354 from mygpo.db.couchdb.podcast_state import unsubscribe_on_device, \
355 podcast_state_for_user_podcast
356 state = podcast_state_for_user_podcast(user, self)
358 # accept devices, and also lists and tuples of devices
359 devices = device if isinstance(device, (list, tuple)) else [device]
361 for device in devices:
363 try:
364 unsubscribe_on_device(state, device)
365 subscription_changed.send(sender=self, user=user, device=device,
366 subscribed=False)
367 except Unauthorized as ex:
368 raise SubscriptionException(ex)
371 def subscribe_targets(self, user):
373 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
374 devices/syncgroups on which the podcast is already subscribed
376 targets = []
378 subscriptions_by_devices = user.get_subscriptions_by_device()
380 for group in user.get_grouped_devices():
382 if group.is_synced:
384 dev = group.devices[0]
386 if not self.get_id() in subscriptions_by_devices[dev.id]:
387 targets.append(group.devices)
389 else:
390 for device in group.devices:
391 if not self.get_id() in subscriptions_by_devices[device.id]:
392 targets.append(device)
394 return targets
397 @property
398 def needs_update(self):
399 """ Indicates if the object requires an updated from its feed """
400 return not self.title and not self.outdated
402 @property
403 def next_update(self):
404 return self.last_update + timedelta(hours=self.update_interval)
406 def __hash__(self):
407 return hash(self.get_id())
410 def __repr__(self):
411 if not self._id:
412 return super(Podcast, self).__repr__()
413 elif self.oldid:
414 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
415 else:
416 return '%s %s' % (self.__class__.__name__, self.get_id())
419 def save(self):
420 group = getattr(self, 'group', None)
421 if group: # we are part of a PodcastGroup
422 group = PodcastGroup.get(group)
423 podcasts = list(group.podcasts)
425 if not self in podcasts:
426 # the podcast has not been added to the group correctly
427 group.add_podcast(self)
429 else:
430 i = podcasts.index(self)
431 podcasts[i] = self
432 group.podcasts = podcasts
433 group.save()
435 i = podcasts.index(self)
436 podcasts[i] = self
437 group.podcasts = podcasts
438 group.save()
440 else:
441 super(Podcast, self).save()
444 def delete(self):
445 group = getattr(self, 'group', None)
446 if group:
447 group = PodcastGroup.get(group)
448 podcasts = list(group.podcasts)
450 if self in podcasts:
451 i = podcasts.index(self)
452 del podcasts[i]
453 group.podcasts = podcasts
454 group.save()
456 else:
457 super(Podcast, self).delete()
460 def __eq__(self, other):
461 if not self.get_id():
462 return self == other
464 if other is None:
465 return False
467 return self.get_id() == other.get_id()
471 class PodcastGroup(Document, SlugMixin, OldIdMixin):
472 title = StringProperty()
473 podcasts = SchemaListProperty(Podcast)
475 def get_id(self):
476 return self._id
479 def get_podcast_by_id(self, id, current_id=False):
480 for podcast in self.podcasts:
481 if podcast.get_id() == id:
482 return podcast
484 if id in podcast.merged_ids:
485 if current_id:
486 raise MergedIdException(podcast, podcast.get_id())
488 return podcast
491 def get_podcast_by_oldid(self, oldid):
492 for podcast in list(self.podcasts):
493 if podcast.oldid == oldid or oldid in podcast.merged_oldids:
494 return podcast
497 def get_podcast_by_url(self, url):
498 for podcast in self.podcasts:
499 if url in list(podcast.urls):
500 return podcast
503 def subscriber_change(self):
504 prev = self.prev_subscriber_count()
505 if not prev:
506 return 0
508 return self.subscriber_count() / prev
511 def subscriber_count(self):
512 return sum([p.subscriber_count() for p in self.podcasts])
515 def prev_subscriber_count(self):
516 return sum([p.prev_subscriber_count() for p in self.podcasts])
518 @property
519 def display_title(self):
520 return self.title
522 @property
523 def license(self):
524 return utils.first(p.license for p in self.podcasts)
527 @property
528 def needs_update(self):
529 """ Indicates if the object requires an updated from its feed """
530 # A PodcastGroup has been manually created and therefore never
531 # requires an update
532 return False
534 def get_podcast(self):
535 # return podcast with most subscribers (bug 1390)
536 return sorted(self.podcasts, key=Podcast.subscriber_count,
537 reverse=True)[0]
540 @property
541 def logo_url(self):
542 return utils.first(p.logo_url for p in self.podcasts)
544 @logo_url.setter
545 def logo_url(self, value):
546 self.podcasts[0].logo_url = value
549 def get_logo_url(self, size):
550 if self.logo_url:
551 filename = hashlib.sha1(self.logo_url).hexdigest()
552 else:
553 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
555 prefix = CoverArt.get_prefix(filename)
557 return reverse('logo', args=[size, prefix, filename])
560 def add_podcast(self, podcast, member_name):
562 if not self._id:
563 raise ValueError('group has to have an _id first')
565 if not podcast._id:
566 raise ValueError('podcast needs to have an _id first')
568 if not podcast.id:
569 podcast.id = podcast._id
571 podcast.delete()
572 podcast.group = self._id
573 podcast.group_member_name = member_name
574 self.podcasts = sorted(self.podcasts + [podcast],
575 key=Podcast.subscriber_count, reverse=True)
576 self.save()
579 def __repr__(self):
580 if not self._id:
581 return super(PodcastGroup, self).__repr__()
582 elif self.oldid:
583 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
584 else:
585 return '%s %s' % (self.__class__.__name__, self._id[:10])