store subtitle, license for podcasts, episodes
[mygpo.git] / mygpo / core / models.py
blob3ef8287f9c453ab0191187c52788bd55437d6915
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 subtitle = StringProperty()
47 content = StringProperty(default="")
48 link = StringProperty()
49 released = DateTimeProperty()
50 author = StringProperty()
51 duration = IntegerProperty()
52 filesize = IntegerProperty()
53 language = StringProperty()
54 last_update = DateTimeProperty()
55 outdated = BooleanProperty(default=False)
56 mimetypes = StringListProperty()
57 merged_ids = StringListProperty()
58 urls = StringListProperty()
59 podcast = StringProperty(required=True)
60 listeners = IntegerProperty()
61 content_types = StringListProperty()
62 flattr_url = StringProperty()
63 created_timestamp = IntegerProperty()
64 license = StringProperty()
68 @property
69 def url(self):
70 return self.urls[0]
72 def __repr__(self):
73 return 'Episode %s' % self._id
77 def get_short_title(self, common_title):
78 if not self.title or not common_title:
79 return None
81 title = self.title.replace(common_title, '').strip()
82 title = re.sub(r'^[\W\d]+', '', title)
83 return title
86 def get_episode_number(self, common_title):
87 if not self.title or not common_title:
88 return None
90 title = self.title.replace(common_title, '').strip()
91 match = re.search(r'^\W*(\d+)', title)
92 if not match:
93 return None
95 return int(match.group(1))
98 def get_ids(self):
99 return set([self._id] + self.merged_ids)
102 @property
103 def needs_update(self):
104 """ Indicates if the object requires an updated from its feed """
105 return not self.title and not self.outdated
108 def __eq__(self, other):
109 if other is None:
110 return False
111 return self._id == other._id
114 def __hash__(self):
115 return hash(self._id)
118 def __unicode__(self):
119 return u'<{cls} {title} ({id})>'.format(cls=self.__class__.__name__,
120 title=self.title, id=self._id)
124 class SubscriberData(DocumentSchema):
125 timestamp = DateTimeProperty()
126 subscriber_count = IntegerProperty()
128 def __eq__(self, other):
129 if not isinstance(other, SubscriberData):
130 return False
132 return (self.timestamp == other.timestamp) and \
133 (self.subscriber_count == other.subscriber_count)
135 def __hash__(self):
136 return hash(frozenset([self.timestamp, self.subscriber_count]))
139 class PodcastSubscriberData(Document):
140 podcast = StringProperty()
141 subscribers = SchemaListProperty(SubscriberData)
144 def __repr__(self):
145 return 'PodcastSubscriberData for Podcast %s (%s)' % (self.podcast, self._id)
148 class Podcast(Document, SlugMixin, OldIdMixin):
150 __metaclass__ = DocumentABCMeta
152 id = StringProperty()
153 title = StringProperty()
154 urls = StringListProperty()
155 description = StringProperty()
156 subtitle = StringProperty()
157 link = StringProperty()
158 last_update = DateTimeProperty()
159 logo_url = StringProperty()
160 author = StringProperty()
161 merged_ids = StringListProperty()
162 group = StringProperty()
163 group_member_name = StringProperty()
164 related_podcasts = StringListProperty()
165 subscribers = SchemaListProperty(SubscriberData)
166 language = StringProperty()
167 content_types = StringListProperty()
168 tags = DictProperty()
169 restrictions = StringListProperty()
170 common_episode_title = StringProperty()
171 new_location = StringProperty()
172 latest_episode_timestamp = DateTimeProperty()
173 episode_count = IntegerProperty()
174 random_key = FloatProperty(default=random)
175 flattr_url = StringProperty()
176 outdated = BooleanProperty(default=False)
177 created_timestamp = IntegerProperty()
178 hub = StringProperty()
179 license = StringProperty()
183 def get_podcast_by_id(self, id, current_id=False):
184 if current_id and id != self.get_id():
185 raise MergedIdException(self, self.get_id())
187 return self
190 get_podcast_by_oldid = get_podcast_by_id
191 get_podcast_by_url = get_podcast_by_id
194 def get_id(self):
195 return self.id or self._id
197 def get_ids(self):
198 return set([self.get_id()] + self.merged_ids)
200 @property
201 def display_title(self):
202 return self.title or self.url
205 def group_with(self, other, grouptitle, myname, othername):
207 if self.group and (self.group == other.group):
208 # they are already grouped
209 return
211 group1 = PodcastGroup.get(self.group) if self.group else None
212 group2 = PodcastGroup.get(other.group) if other.group else None
214 if group1 and group2:
215 raise ValueError('both podcasts already are in different groups')
217 elif not (group1 or group2):
218 group = PodcastGroup(title=grouptitle)
219 group.save()
220 group.add_podcast(self, myname)
221 group.add_podcast(other, othername)
222 return group
224 elif group1:
225 group1.add_podcast(other, othername)
226 return group1
228 else:
229 group2.add_podcast(self, myname)
230 return group2
234 def get_common_episode_title(self, num_episodes=100):
236 if self.common_episode_title:
237 return self.common_episode_title
239 from mygpo.db.couchdb.episode import episodes_for_podcast
240 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
242 # We take all non-empty titles
243 titles = filter(None, (e.title for e in episodes))
244 # get the longest common substring
245 common_title = utils.longest_substr(titles)
247 # but consider only the part up to the first number. Otherwise we risk
248 # removing part of the number (eg if a feed contains episodes 100-199)
249 common_title = re.search(r'^\D*', common_title).group(0)
251 if len(common_title.strip()) < 2:
252 return None
254 return common_title
257 def get_episode_before(self, episode):
258 if not episode.released:
259 return None
261 from mygpo.db.couchdb.episode import episodes_for_podcast
262 prevs = episodes_for_podcast(self, until=episode.released,
263 descending=True, limit=1)
265 return next(iter(prevs), None)
268 def get_episode_after(self, episode):
269 if not episode.released:
270 return None
272 from mygpo.db.couchdb.episode import episodes_for_podcast
273 from datetime import timedelta
274 nexts = episodes_for_podcast(self,
275 since=episode.released + timedelta(seconds=1), limit=1)
277 return next(iter(nexts), None)
280 @property
281 def url(self):
282 return self.urls[0]
285 def get_podcast(self):
286 return self
289 def get_logo_url(self, size):
290 if self.logo_url:
291 filename = hashlib.sha1(self.logo_url).hexdigest()
292 else:
293 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
295 prefix = CoverArt.get_prefix(filename)
297 return reverse('logo', args=[size, prefix, filename])
300 def subscriber_change(self):
301 prev = self.prev_subscriber_count()
302 if prev <= 0:
303 return 0
305 return self.subscriber_count() / prev
308 def subscriber_count(self):
309 if not self.subscribers:
310 return 0
311 return self.subscribers[-1].subscriber_count
314 def prev_subscriber_count(self):
315 if len(self.subscribers) < 2:
316 return 0
317 return self.subscribers[-2].subscriber_count
321 @repeat_on_conflict()
322 def subscribe(self, user, device):
323 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
324 state = podcast_state_for_user_podcast(user, self)
325 state.subscribe(device)
326 try:
327 state.save()
328 subscription_changed.send(sender=self, user=user, device=device,
329 subscribed=True)
330 except Unauthorized as ex:
331 raise SubscriptionException(ex)
334 @repeat_on_conflict()
335 def unsubscribe(self, user, device):
336 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
337 state = podcast_state_for_user_podcast(user, self)
338 state.unsubscribe(device)
339 try:
340 state.save()
341 subscription_changed.send(sender=self, user=user, device=device,
342 subscribed=False)
343 except Unauthorized as ex:
344 raise SubscriptionException(ex)
347 def subscribe_targets(self, user):
349 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
350 devices/syncgroups on which the podcast is already subscribed
352 targets = []
354 subscriptions_by_devices = user.get_subscriptions_by_device()
356 for group in user.get_grouped_devices():
358 if group.is_synced:
360 dev = group.devices[0]
362 if not self.get_id() in subscriptions_by_devices[dev.id]:
363 targets.append(group.devices)
365 else:
366 for device in group.devices:
367 if not self.get_id() in subscriptions_by_devices[device.id]:
368 targets.append(device)
370 return targets
373 @property
374 def needs_update(self):
375 """ Indicates if the object requires an updated from its feed """
376 return not self.title and not self.outdated
379 def __hash__(self):
380 return hash(self.get_id())
383 def __repr__(self):
384 if not self._id:
385 return super(Podcast, self).__repr__()
386 elif self.oldid:
387 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
388 else:
389 return '%s %s' % (self.__class__.__name__, self.get_id())
392 def save(self):
393 group = getattr(self, 'group', None)
394 if group: # we are part of a PodcastGroup
395 group = PodcastGroup.get(group)
396 podcasts = list(group.podcasts)
398 if not self in podcasts:
399 # the podcast has not been added to the group correctly
400 group.add_podcast(self)
402 else:
403 i = podcasts.index(self)
404 podcasts[i] = self
405 group.podcasts = podcasts
406 group.save()
408 i = podcasts.index(self)
409 podcasts[i] = self
410 group.podcasts = podcasts
411 group.save()
413 else:
414 super(Podcast, self).save()
417 def delete(self):
418 group = getattr(self, 'group', None)
419 if group:
420 group = PodcastGroup.get(group)
421 podcasts = list(group.podcasts)
423 if self in podcasts:
424 i = podcasts.index(self)
425 del podcasts[i]
426 group.podcasts = podcasts
427 group.save()
429 else:
430 super(Podcast, self).delete()
433 def __eq__(self, other):
434 if not self.get_id():
435 return self == other
437 if other is None:
438 return False
440 return self.get_id() == other.get_id()
444 class PodcastGroup(Document, SlugMixin, OldIdMixin):
445 title = StringProperty()
446 podcasts = SchemaListProperty(Podcast)
448 def get_id(self):
449 return self._id
452 def get_podcast_by_id(self, id, current_id=False):
453 for podcast in self.podcasts:
454 if podcast.get_id() == id:
455 return podcast
457 if id in podcast.merged_ids:
458 if current_id:
459 raise MergedIdException(podcast, podcast.get_id())
461 return podcast
464 def get_podcast_by_oldid(self, oldid):
465 for podcast in list(self.podcasts):
466 if podcast.oldid == oldid:
467 return podcast
470 def get_podcast_by_url(self, url):
471 for podcast in self.podcasts:
472 if url in list(podcast.urls):
473 return podcast
476 def subscriber_change(self):
477 prev = self.prev_subscriber_count()
478 if not prev:
479 return 0
481 return self.subscriber_count() / prev
484 def subscriber_count(self):
485 return sum([p.subscriber_count() for p in self.podcasts])
488 def prev_subscriber_count(self):
489 return sum([p.prev_subscriber_count() for p in self.podcasts])
491 @property
492 def display_title(self):
493 return self.title
496 @property
497 def needs_update(self):
498 """ Indicates if the object requires an updated from its feed """
499 # A PodcastGroup has been manually created and therefore never
500 # requires an update
501 return False
503 def get_podcast(self):
504 # return podcast with most subscribers (bug 1390)
505 return sorted(self.podcasts, key=Podcast.subscriber_count,
506 reverse=True)[0]
509 @property
510 def logo_url(self):
511 return utils.first(p.logo_url for p in self.podcasts)
513 @logo_url.setter
514 def logo_url(self, value):
515 self.podcasts[0].logo_url = value
518 def get_logo_url(self, size):
519 if self.logo_url:
520 filename = hashlib.sha1(self.logo_url).hexdigest()
521 else:
522 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
524 prefix = CoverArt.get_prefix(filename)
526 return reverse('logo', args=[size, prefix, filename])
529 def add_podcast(self, podcast, member_name):
531 if not self._id:
532 raise ValueError('group has to have an _id first')
534 if not podcast._id:
535 raise ValueError('podcast needs to have an _id first')
537 if not podcast.id:
538 podcast.id = podcast._id
540 podcast.delete()
541 podcast.group = self._id
542 podcast.group_member_name = member_name
543 self.podcasts = sorted(self.podcasts + [podcast],
544 key=Podcast.subscriber_count, reverse=True)
545 self.save()
548 def __repr__(self):
549 if not self._id:
550 return super(PodcastGroup, self).__repr__()
551 elif self.oldid:
552 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
553 else:
554 return '%s %s' % (self.__class__.__name__, self._id[:10])