require at least two episodes for a "common" title
[mygpo.git] / mygpo / core / models.py
blobe920de4dff39376cc2610fe069a4c86d62e8dd51
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))
256 # there can not be a "common" title of a single title
257 if len(titles) < 2:
258 return None
260 # get the longest common substring
261 common_title = utils.longest_substr(titles)
263 # but consider only the part up to the first number. Otherwise we risk
264 # removing part of the number (eg if a feed contains episodes 100-199)
265 common_title = re.search(r'^\D*', common_title).group(0)
267 if len(common_title.strip()) < 2:
268 return None
270 return common_title
273 def get_episode_before(self, episode):
274 if not episode.released:
275 return None
277 from mygpo.db.couchdb.episode import episodes_for_podcast
278 prevs = episodes_for_podcast(self, until=episode.released,
279 descending=True, limit=1)
281 return next(iter(prevs), None)
284 def get_episode_after(self, episode):
285 if not episode.released:
286 return None
288 from mygpo.db.couchdb.episode import episodes_for_podcast
289 nexts = episodes_for_podcast(self,
290 since=episode.released + timedelta(seconds=1), limit=1)
292 return next(iter(nexts), None)
295 @property
296 def url(self):
297 return self.urls[0]
300 def get_podcast(self):
301 return self
304 def get_logo_url(self, size):
305 if self.logo_url:
306 filename = hashlib.sha1(self.logo_url).hexdigest()
307 else:
308 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
310 prefix = CoverArt.get_prefix(filename)
312 return reverse('logo', args=[size, prefix, filename])
315 def subscriber_change(self):
316 prev = self.prev_subscriber_count()
317 if prev <= 0:
318 return 0
320 return self.subscriber_count() / prev
323 def subscriber_count(self):
324 if not self.subscribers:
325 return 0
326 return self.subscribers[-1].subscriber_count
329 def prev_subscriber_count(self):
330 if len(self.subscribers) < 2:
331 return 0
332 return self.subscribers[-2].subscriber_count
336 @repeat_on_conflict()
337 def subscribe(self, user, device):
338 """ subscribes user to the current podcast on one or more devices """
339 from mygpo.db.couchdb.podcast_state import subscribe_on_device, \
340 podcast_state_for_user_podcast
341 state = podcast_state_for_user_podcast(user, self)
343 # accept devices, and also lists and tuples of devices
344 devices = device if isinstance(device, (list, tuple)) else [device]
346 for device in devices:
348 try:
349 subscribe_on_device(state, device)
350 subscription_changed.send(sender=self, user=user,
351 device=device, subscribed=True)
352 except Unauthorized as ex:
353 raise SubscriptionException(ex)
356 @repeat_on_conflict()
357 def unsubscribe(self, user, device):
358 """ unsubscribes user from the current podcast on one or more devices """
359 from mygpo.db.couchdb.podcast_state import unsubscribe_on_device, \
360 podcast_state_for_user_podcast
361 state = podcast_state_for_user_podcast(user, self)
363 # accept devices, and also lists and tuples of devices
364 devices = device if isinstance(device, (list, tuple)) else [device]
366 for device in devices:
368 try:
369 unsubscribe_on_device(state, device)
370 subscription_changed.send(sender=self, user=user, device=device,
371 subscribed=False)
372 except Unauthorized as ex:
373 raise SubscriptionException(ex)
376 def subscribe_targets(self, user):
378 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
379 devices/syncgroups on which the podcast is already subscribed
381 targets = []
383 subscriptions_by_devices = user.get_subscriptions_by_device()
385 for group in user.get_grouped_devices():
387 if group.is_synced:
389 dev = group.devices[0]
391 if not self.get_id() in subscriptions_by_devices[dev.id]:
392 targets.append(group.devices)
394 else:
395 for device in group.devices:
396 if not self.get_id() in subscriptions_by_devices[device.id]:
397 targets.append(device)
399 return targets
402 @property
403 def needs_update(self):
404 """ Indicates if the object requires an updated from its feed """
405 return not self.title and not self.outdated
407 @property
408 def next_update(self):
409 return self.last_update + timedelta(hours=self.update_interval)
411 def __hash__(self):
412 return hash(self.get_id())
415 def __repr__(self):
416 if not self._id:
417 return super(Podcast, self).__repr__()
418 elif self.oldid:
419 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
420 else:
421 return '%s %s' % (self.__class__.__name__, self.get_id())
424 def save(self):
425 group = getattr(self, 'group', None)
426 if group: # we are part of a PodcastGroup
427 group = PodcastGroup.get(group)
428 podcasts = list(group.podcasts)
430 if not self in podcasts:
431 # the podcast has not been added to the group correctly
432 group.add_podcast(self)
434 else:
435 i = podcasts.index(self)
436 podcasts[i] = self
437 group.podcasts = podcasts
438 group.save()
440 i = podcasts.index(self)
441 podcasts[i] = self
442 group.podcasts = podcasts
443 group.save()
445 else:
446 super(Podcast, self).save()
449 def delete(self):
450 group = getattr(self, 'group', None)
451 if group:
452 group = PodcastGroup.get(group)
453 podcasts = list(group.podcasts)
455 if self in podcasts:
456 i = podcasts.index(self)
457 del podcasts[i]
458 group.podcasts = podcasts
459 group.save()
461 else:
462 super(Podcast, self).delete()
465 def __eq__(self, other):
466 if not self.get_id():
467 return self == other
469 if other is None:
470 return False
472 return self.get_id() == other.get_id()
476 class PodcastGroup(Document, SlugMixin, OldIdMixin):
477 title = StringProperty()
478 podcasts = SchemaListProperty(Podcast)
480 def get_id(self):
481 return self._id
484 def get_podcast_by_id(self, id, current_id=False):
485 for podcast in self.podcasts:
486 if podcast.get_id() == id:
487 return podcast
489 if id in podcast.merged_ids:
490 if current_id:
491 raise MergedIdException(podcast, podcast.get_id())
493 return podcast
496 def get_podcast_by_oldid(self, oldid):
497 for podcast in list(self.podcasts):
498 if podcast.oldid == oldid or oldid in podcast.merged_oldids:
499 return podcast
502 def get_podcast_by_url(self, url):
503 for podcast in self.podcasts:
504 if url in list(podcast.urls):
505 return podcast
508 def subscriber_change(self):
509 prev = self.prev_subscriber_count()
510 if not prev:
511 return 0
513 return self.subscriber_count() / prev
516 def subscriber_count(self):
517 return sum([p.subscriber_count() for p in self.podcasts])
520 def prev_subscriber_count(self):
521 return sum([p.prev_subscriber_count() for p in self.podcasts])
523 @property
524 def display_title(self):
525 return self.title
527 @property
528 def license(self):
529 return utils.first(p.license for p in self.podcasts)
532 @property
533 def needs_update(self):
534 """ Indicates if the object requires an updated from its feed """
535 # A PodcastGroup has been manually created and therefore never
536 # requires an update
537 return False
539 def get_podcast(self):
540 # return podcast with most subscribers (bug 1390)
541 return sorted(self.podcasts, key=Podcast.subscriber_count,
542 reverse=True)[0]
545 @property
546 def logo_url(self):
547 return utils.first(p.logo_url for p in self.podcasts)
549 @logo_url.setter
550 def logo_url(self, value):
551 self.podcasts[0].logo_url = value
554 def get_logo_url(self, size):
555 if self.logo_url:
556 filename = hashlib.sha1(self.logo_url).hexdigest()
557 else:
558 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
560 prefix = CoverArt.get_prefix(filename)
562 return reverse('logo', args=[size, prefix, filename])
565 def add_podcast(self, podcast, member_name):
567 if not self._id:
568 raise ValueError('group has to have an _id first')
570 if not podcast._id:
571 raise ValueError('podcast needs to have an _id first')
573 if not podcast.id:
574 podcast.id = podcast._id
576 podcast.delete()
577 podcast.group = self._id
578 podcast.group_member_name = member_name
579 self.podcasts = sorted(self.podcasts + [podcast],
580 key=Podcast.subscriber_count, reverse=True)
581 self.save()
584 def __repr__(self):
585 if not self._id:
586 return super(PodcastGroup, self).__repr__()
587 elif self.oldid:
588 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
589 else:
590 return '%s %s' % (self.__class__.__name__, self._id[:10])