remove unused imports of @cache_result
[mygpo.git] / mygpo / core / models.py
blob8c331c3485645e97487007084b73d1907a6ad962
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.core.proxy import DocumentABCMeta
15 from mygpo.core.slugs import SlugMixin
16 from mygpo.core.oldid import OldIdMixin
17 from mygpo.web.logo import CoverArt
19 # make sure this code is executed at startup
20 from mygpo.core.signals import *
23 class SubscriptionException(Exception):
24 pass
27 class MergedIdException(Exception):
28 """ raised when an object is accessed through one of its merged_ids """
30 def __init__(self, obj, current_id):
31 self.obj = obj
32 self.current_id = current_id
35 class Episode(Document, SlugMixin, OldIdMixin):
36 """
37 Represents an Episode. Can only be part of a Podcast
38 """
40 __metaclass__ = DocumentABCMeta
42 title = StringProperty()
43 guid = StringProperty()
44 description = StringProperty(default="")
45 subtitle = StringProperty()
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()
63 license = StringProperty()
67 @property
68 def url(self):
69 return self.urls[0]
71 def __repr__(self):
72 return 'Episode %s' % self._id
76 def get_short_title(self, common_title):
77 if not self.title or not common_title:
78 return None
80 title = self.title.replace(common_title, '').strip()
81 title = re.sub(r'^[\W\d]+', '', title)
82 return title
85 def get_episode_number(self, common_title):
86 if not self.title or not common_title:
87 return None
89 title = self.title.replace(common_title, '').strip()
90 match = re.search(r'^\W*(\d+)', title)
91 if not match:
92 return None
94 return int(match.group(1))
97 def get_ids(self):
98 return set([self._id] + self.merged_ids)
101 @property
102 def needs_update(self):
103 """ Indicates if the object requires an updated from its feed """
104 return not self.title and not self.outdated
107 def __eq__(self, other):
108 if other is None:
109 return False
110 return self._id == other._id
113 def __hash__(self):
114 return hash(self._id)
117 def __unicode__(self):
118 return u'<{cls} {title} ({id})>'.format(cls=self.__class__.__name__,
119 title=self.title, id=self._id)
123 class SubscriberData(DocumentSchema):
124 timestamp = DateTimeProperty()
125 subscriber_count = IntegerProperty()
127 def __eq__(self, other):
128 if not isinstance(other, SubscriberData):
129 return False
131 return (self.timestamp == other.timestamp) and \
132 (self.subscriber_count == other.subscriber_count)
134 def __hash__(self):
135 return hash(frozenset([self.timestamp, self.subscriber_count]))
138 class PodcastSubscriberData(Document):
139 podcast = StringProperty()
140 subscribers = SchemaListProperty(SubscriberData)
143 def __repr__(self):
144 return 'PodcastSubscriberData for Podcast %s (%s)' % (self.podcast, self._id)
147 class Podcast(Document, SlugMixin, OldIdMixin):
149 __metaclass__ = DocumentABCMeta
151 id = StringProperty()
152 title = StringProperty()
153 urls = StringListProperty()
154 description = StringProperty()
155 subtitle = StringProperty()
156 link = StringProperty()
157 last_update = DateTimeProperty()
158 logo_url = StringProperty()
159 author = StringProperty()
160 merged_ids = StringListProperty()
161 group = StringProperty()
162 group_member_name = StringProperty()
163 related_podcasts = StringListProperty()
164 subscribers = SchemaListProperty(SubscriberData)
165 language = StringProperty()
166 content_types = StringListProperty()
167 tags = DictProperty()
168 restrictions = StringListProperty()
169 common_episode_title = StringProperty()
170 new_location = StringProperty()
171 latest_episode_timestamp = DateTimeProperty()
172 episode_count = IntegerProperty()
173 random_key = FloatProperty(default=random)
174 flattr_url = StringProperty()
175 outdated = BooleanProperty(default=False)
176 created_timestamp = IntegerProperty()
177 hub = StringProperty()
178 license = StringProperty()
182 def get_podcast_by_id(self, id, current_id=False):
183 if current_id and id != self.get_id():
184 raise MergedIdException(self, self.get_id())
186 return self
189 get_podcast_by_oldid = get_podcast_by_id
190 get_podcast_by_url = get_podcast_by_id
193 def get_id(self):
194 return self.id or self._id
196 def get_ids(self):
197 return set([self.get_id()] + self.merged_ids)
199 @property
200 def display_title(self):
201 return self.title or self.url
204 def group_with(self, other, grouptitle, myname, othername):
206 if self.group and (self.group == other.group):
207 # they are already grouped
208 return
210 group1 = PodcastGroup.get(self.group) if self.group else None
211 group2 = PodcastGroup.get(other.group) if other.group else None
213 if group1 and group2:
214 raise ValueError('both podcasts already are in different groups')
216 elif not (group1 or group2):
217 group = PodcastGroup(title=grouptitle)
218 group.save()
219 group.add_podcast(self, myname)
220 group.add_podcast(other, othername)
221 return group
223 elif group1:
224 group1.add_podcast(other, othername)
225 return group1
227 else:
228 group2.add_podcast(self, myname)
229 return group2
233 def get_common_episode_title(self, num_episodes=100):
235 if self.common_episode_title:
236 return self.common_episode_title
238 from mygpo.db.couchdb.episode import episodes_for_podcast
239 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
241 # We take all non-empty titles
242 titles = filter(None, (e.title for e in episodes))
243 # get the longest common substring
244 common_title = utils.longest_substr(titles)
246 # but consider only the part up to the first number. Otherwise we risk
247 # removing part of the number (eg if a feed contains episodes 100-199)
248 common_title = re.search(r'^\D*', common_title).group(0)
250 if len(common_title.strip()) < 2:
251 return None
253 return common_title
256 def get_episode_before(self, episode):
257 if not episode.released:
258 return None
260 from mygpo.db.couchdb.episode import episodes_for_podcast
261 prevs = episodes_for_podcast(self, until=episode.released,
262 descending=True, limit=1)
264 return next(iter(prevs), None)
267 def get_episode_after(self, episode):
268 if not episode.released:
269 return None
271 from mygpo.db.couchdb.episode import episodes_for_podcast
272 from datetime import timedelta
273 nexts = episodes_for_podcast(self,
274 since=episode.released + timedelta(seconds=1), limit=1)
276 return next(iter(nexts), None)
279 @property
280 def url(self):
281 return self.urls[0]
284 def get_podcast(self):
285 return self
288 def get_logo_url(self, size):
289 if self.logo_url:
290 filename = hashlib.sha1(self.logo_url).hexdigest()
291 else:
292 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
294 prefix = CoverArt.get_prefix(filename)
296 return reverse('logo', args=[size, prefix, filename])
299 def subscriber_change(self):
300 prev = self.prev_subscriber_count()
301 if prev <= 0:
302 return 0
304 return self.subscriber_count() / prev
307 def subscriber_count(self):
308 if not self.subscribers:
309 return 0
310 return self.subscribers[-1].subscriber_count
313 def prev_subscriber_count(self):
314 if len(self.subscribers) < 2:
315 return 0
316 return self.subscribers[-2].subscriber_count
320 @repeat_on_conflict()
321 def subscribe(self, user, device):
322 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
323 state = podcast_state_for_user_podcast(user, self)
324 state.subscribe(device)
325 try:
326 state.save()
327 subscription_changed.send(sender=self, user=user, device=device,
328 subscribed=True)
329 except Unauthorized as ex:
330 raise SubscriptionException(ex)
333 @repeat_on_conflict()
334 def unsubscribe(self, user, device):
335 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
336 state = podcast_state_for_user_podcast(user, self)
337 state.unsubscribe(device)
338 try:
339 state.save()
340 subscription_changed.send(sender=self, user=user, device=device,
341 subscribed=False)
342 except Unauthorized as ex:
343 raise SubscriptionException(ex)
346 def subscribe_targets(self, user):
348 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
349 devices/syncgroups on which the podcast is already subscribed
351 targets = []
353 subscriptions_by_devices = user.get_subscriptions_by_device()
355 for group in user.get_grouped_devices():
357 if group.is_synced:
359 dev = group.devices[0]
361 if not self.get_id() in subscriptions_by_devices[dev.id]:
362 targets.append(group.devices)
364 else:
365 for device in group.devices:
366 if not self.get_id() in subscriptions_by_devices[device.id]:
367 targets.append(device)
369 return targets
372 @property
373 def needs_update(self):
374 """ Indicates if the object requires an updated from its feed """
375 return not self.title and not self.outdated
378 def __hash__(self):
379 return hash(self.get_id())
382 def __repr__(self):
383 if not self._id:
384 return super(Podcast, self).__repr__()
385 elif self.oldid:
386 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
387 else:
388 return '%s %s' % (self.__class__.__name__, self.get_id())
391 def save(self):
392 group = getattr(self, 'group', None)
393 if group: # we are part of a PodcastGroup
394 group = PodcastGroup.get(group)
395 podcasts = list(group.podcasts)
397 if not self in podcasts:
398 # the podcast has not been added to the group correctly
399 group.add_podcast(self)
401 else:
402 i = podcasts.index(self)
403 podcasts[i] = self
404 group.podcasts = podcasts
405 group.save()
407 i = podcasts.index(self)
408 podcasts[i] = self
409 group.podcasts = podcasts
410 group.save()
412 else:
413 super(Podcast, self).save()
416 def delete(self):
417 group = getattr(self, 'group', None)
418 if group:
419 group = PodcastGroup.get(group)
420 podcasts = list(group.podcasts)
422 if self in podcasts:
423 i = podcasts.index(self)
424 del podcasts[i]
425 group.podcasts = podcasts
426 group.save()
428 else:
429 super(Podcast, self).delete()
432 def __eq__(self, other):
433 if not self.get_id():
434 return self == other
436 if other is None:
437 return False
439 return self.get_id() == other.get_id()
443 class PodcastGroup(Document, SlugMixin, OldIdMixin):
444 title = StringProperty()
445 podcasts = SchemaListProperty(Podcast)
447 def get_id(self):
448 return self._id
451 def get_podcast_by_id(self, id, current_id=False):
452 for podcast in self.podcasts:
453 if podcast.get_id() == id:
454 return podcast
456 if id in podcast.merged_ids:
457 if current_id:
458 raise MergedIdException(podcast, podcast.get_id())
460 return podcast
463 def get_podcast_by_oldid(self, oldid):
464 for podcast in list(self.podcasts):
465 if podcast.oldid == oldid:
466 return podcast
469 def get_podcast_by_url(self, url):
470 for podcast in self.podcasts:
471 if url in list(podcast.urls):
472 return podcast
475 def subscriber_change(self):
476 prev = self.prev_subscriber_count()
477 if not prev:
478 return 0
480 return self.subscriber_count() / prev
483 def subscriber_count(self):
484 return sum([p.subscriber_count() for p in self.podcasts])
487 def prev_subscriber_count(self):
488 return sum([p.prev_subscriber_count() for p in self.podcasts])
490 @property
491 def display_title(self):
492 return self.title
495 @property
496 def needs_update(self):
497 """ Indicates if the object requires an updated from its feed """
498 # A PodcastGroup has been manually created and therefore never
499 # requires an update
500 return False
502 def get_podcast(self):
503 # return podcast with most subscribers (bug 1390)
504 return sorted(self.podcasts, key=Podcast.subscriber_count,
505 reverse=True)[0]
508 @property
509 def logo_url(self):
510 return utils.first(p.logo_url for p in self.podcasts)
512 @logo_url.setter
513 def logo_url(self, value):
514 self.podcasts[0].logo_url = value
517 def get_logo_url(self, size):
518 if self.logo_url:
519 filename = hashlib.sha1(self.logo_url).hexdigest()
520 else:
521 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
523 prefix = CoverArt.get_prefix(filename)
525 return reverse('logo', args=[size, prefix, filename])
528 def add_podcast(self, podcast, member_name):
530 if not self._id:
531 raise ValueError('group has to have an _id first')
533 if not podcast._id:
534 raise ValueError('podcast needs to have an _id first')
536 if not podcast.id:
537 podcast.id = podcast._id
539 podcast.delete()
540 podcast.group = self._id
541 podcast.group_member_name = member_name
542 self.podcasts = sorted(self.podcasts + [podcast],
543 key=Podcast.subscriber_count, reverse=True)
544 self.save()
547 def __repr__(self):
548 if not self._id:
549 return super(PodcastGroup, self).__repr__()
550 elif self.oldid:
551 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
552 else:
553 return '%s %s' % (self.__class__.__name__, self._id[:10])