815978044c41c53ad3d384c63da5aed57d110b0b
[mygpo.git] / mygpo / core / models.py
blob815978044c41c53ad3d384c63da5aed57d110b0b
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
19 from mygpo.users.tasks import sync_user
21 # make sure this code is executed at startup
22 from mygpo.core.signals import *
25 class SubscriptionException(Exception):
26 pass
29 class MergedIdException(Exception):
30 """ raised when an object is accessed through one of its merged_ids """
32 def __init__(self, obj, current_id):
33 self.obj = obj
34 self.current_id = current_id
37 class Episode(Document, SlugMixin, OldIdMixin):
38 """
39 Represents an Episode. Can only be part of a Podcast
40 """
42 __metaclass__ = DocumentABCMeta
44 title = StringProperty()
45 guid = StringProperty()
46 description = StringProperty(default="")
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()
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 link = StringProperty()
156 last_update = DateTimeProperty()
157 logo_url = StringProperty()
158 author = StringProperty()
159 merged_ids = StringListProperty()
160 group = StringProperty()
161 group_member_name = StringProperty()
162 related_podcasts = StringListProperty()
163 subscribers = SchemaListProperty(SubscriberData)
164 language = StringProperty()
165 content_types = StringListProperty()
166 tags = DictProperty()
167 restrictions = StringListProperty()
168 common_episode_title = StringProperty()
169 new_location = StringProperty()
170 latest_episode_timestamp = DateTimeProperty()
171 episode_count = IntegerProperty()
172 random_key = FloatProperty(default=random)
173 flattr_url = StringProperty()
174 outdated = BooleanProperty(default=False)
175 created_timestamp = IntegerProperty()
179 def get_podcast_by_id(self, id, current_id=False):
180 if current_id and id != self.get_id():
181 raise MergedIdException(self, self.get_id())
183 return self
186 get_podcast_by_oldid = get_podcast_by_id
187 get_podcast_by_url = get_podcast_by_id
190 def get_id(self):
191 return self.id or self._id
193 def get_ids(self):
194 return set([self.get_id()] + self.merged_ids)
196 @property
197 def display_title(self):
198 return self.title or self.url
201 def group_with(self, other, grouptitle, myname, othername):
203 if self.group and (self.group == other.group):
204 # they are already grouped
205 return
207 group1 = PodcastGroup.get(self.group) if self.group else None
208 group2 = PodcastGroup.get(other.group) if other.group else None
210 if group1 and group2:
211 raise ValueError('both podcasts already are in different groups')
213 elif not (group1 or group2):
214 group = PodcastGroup(title=grouptitle)
215 group.save()
216 group.add_podcast(self, myname)
217 group.add_podcast(other, othername)
218 return group
220 elif group1:
221 group1.add_podcast(other, othername)
222 return group1
224 else:
225 group2.add_podcast(self, myname)
226 return group2
230 def get_common_episode_title(self, num_episodes=100):
232 if self.common_episode_title:
233 return self.common_episode_title
235 from mygpo.db.couchdb.episode import episodes_for_podcast
236 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
238 # We take all non-empty titles
239 titles = filter(None, (e.title for e in episodes))
240 # get the longest common substring
241 common_title = utils.longest_substr(titles)
243 # but consider only the part up to the first number. Otherwise we risk
244 # removing part of the number (eg if a feed contains episodes 100-199)
245 common_title = re.search(r'^\D*', common_title).group(0)
247 if len(common_title.strip()) < 2:
248 return None
250 return common_title
253 def get_episode_before(self, episode):
254 if not episode.released:
255 return None
257 from mygpo.db.couchdb.episode import episodes_for_podcast
258 prevs = episodes_for_podcast(self, until=episode.released,
259 descending=True, limit=1)
261 return next(iter(prevs), None)
264 def get_episode_after(self, episode):
265 if not episode.released:
266 return None
268 from mygpo.db.couchdb.episode import episodes_for_podcast
269 from datetime import timedelta
270 nexts = episodes_for_podcast(self,
271 since=episode.released + timedelta(seconds=1), limit=1)
273 return next(iter(nexts), None)
276 @property
277 def url(self):
278 return self.urls[0]
281 def get_podcast(self):
282 return self
285 def get_logo_url(self, size):
286 if self.logo_url:
287 filename = hashlib.sha1(self.logo_url).hexdigest()
288 else:
289 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
291 prefix = CoverArt.get_prefix(filename)
293 return reverse('logo', args=[size, prefix, filename])
296 def subscriber_change(self):
297 prev = self.prev_subscriber_count()
298 if prev <= 0:
299 return 0
301 return self.subscriber_count() / prev
304 def subscriber_count(self):
305 if not self.subscribers:
306 return 0
307 return self.subscribers[-1].subscriber_count
310 def prev_subscriber_count(self):
311 if len(self.subscribers) < 2:
312 return 0
313 return self.subscribers[-2].subscriber_count
317 @repeat_on_conflict()
318 def subscribe(self, user, device):
319 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
320 state = podcast_state_for_user_podcast(user, self)
321 state.subscribe(device)
322 try:
323 state.save()
324 sync_user.delay(user)
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 sync_user.delay(user)
337 except Unauthorized as ex:
338 raise SubscriptionException(ex)
341 def subscribe_targets(self, user):
343 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
344 devices/syncgroups on which the podcast is already subscribed
346 targets = []
348 subscriptions_by_devices = user.get_subscriptions_by_device()
350 for group in user.get_grouped_devices():
352 if group.is_synced:
354 dev = group.devices[0]
356 if not self.get_id() in subscriptions_by_devices[dev.id]:
357 targets.append(group.devices)
359 else:
360 for device in group.devices:
361 if not self.get_id() in subscriptions_by_devices[device.id]:
362 targets.append(device)
364 return targets
367 @property
368 def needs_update(self):
369 """ Indicates if the object requires an updated from its feed """
370 return not self.title and not self.outdated
373 def __hash__(self):
374 return hash(self.get_id())
377 def __repr__(self):
378 if not self._id:
379 return super(Podcast, self).__repr__()
380 elif self.oldid:
381 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
382 else:
383 return '%s %s' % (self.__class__.__name__, self.get_id())
386 def save(self):
387 group = getattr(self, 'group', None)
388 if group: # we are part of a PodcastGroup
389 group = PodcastGroup.get(group)
390 podcasts = list(group.podcasts)
392 if not self in podcasts:
393 # the podcast has not been added to the group correctly
394 group.add_podcast(self)
396 else:
397 i = podcasts.index(self)
398 podcasts[i] = self
399 group.podcasts = podcasts
400 group.save()
402 i = podcasts.index(self)
403 podcasts[i] = self
404 group.podcasts = podcasts
405 group.save()
407 else:
408 super(Podcast, self).save()
411 def delete(self):
412 group = getattr(self, 'group', None)
413 if group:
414 group = PodcastGroup.get(group)
415 podcasts = list(group.podcasts)
417 if self in podcasts:
418 i = podcasts.index(self)
419 del podcasts[i]
420 group.podcasts = podcasts
421 group.save()
423 else:
424 super(Podcast, self).delete()
427 def __eq__(self, other):
428 if not self.get_id():
429 return self == other
431 if other is None:
432 return False
434 return self.get_id() == other.get_id()
438 class PodcastGroup(Document, SlugMixin, OldIdMixin):
439 title = StringProperty()
440 podcasts = SchemaListProperty(Podcast)
442 def get_id(self):
443 return self._id
446 def get_podcast_by_id(self, id, current_id=False):
447 for podcast in self.podcasts:
448 if podcast.get_id() == id:
449 return podcast
451 if id in podcast.merged_ids:
452 if current_id:
453 raise MergedIdException(podcast, podcast.get_id())
455 return podcast
458 def get_podcast_by_oldid(self, oldid):
459 for podcast in list(self.podcasts):
460 if podcast.oldid == oldid:
461 return podcast
464 def get_podcast_by_url(self, url):
465 for podcast in self.podcasts:
466 if url in list(podcast.urls):
467 return podcast
470 def subscriber_change(self):
471 prev = self.prev_subscriber_count()
472 if not prev:
473 return 0
475 return self.subscriber_count() / prev
478 def subscriber_count(self):
479 return sum([p.subscriber_count() for p in self.podcasts])
482 def prev_subscriber_count(self):
483 return sum([p.prev_subscriber_count() for p in self.podcasts])
485 @property
486 def display_title(self):
487 return self.title
490 @property
491 def needs_update(self):
492 """ Indicates if the object requires an updated from its feed """
493 # A PodcastGroup has been manually created and therefore never
494 # requires an update
495 return False
497 def get_podcast(self):
498 # return podcast with most subscribers (bug 1390)
499 return sorted(self.podcasts, key=Podcast.subscriber_count,
500 reverse=True)[0]
503 @property
504 def logo_url(self):
505 return utils.first(p.logo_url for p in self.podcasts)
507 @logo_url.setter
508 def logo_url(self, value):
509 self.podcasts[0].logo_url = value
512 def get_logo_url(self, size):
513 if self.logo_url:
514 filename = hashlib.sha1(self.logo_url).hexdigest()
515 else:
516 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
518 prefix = CoverArt.get_prefix(filename)
520 return reverse('logo', args=[size, prefix, filename])
523 def add_podcast(self, podcast, member_name):
525 if not self._id:
526 raise ValueError('group has to have an _id first')
528 if not podcast._id:
529 raise ValueError('podcast needs to have an _id first')
531 if not podcast.id:
532 podcast.id = podcast._id
534 podcast.delete()
535 podcast.group = self._id
536 podcast.group_member_name = member_name
537 self.podcasts = sorted(self.podcasts + [podcast],
538 key=Podcast.subscriber_count, reverse=True)
539 self.save()
542 def __repr__(self):
543 if not self._id:
544 return super(PodcastGroup, self).__repr__()
545 elif self.oldid:
546 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
547 else:
548 return '%s %s' % (self.__class__.__name__, self._id[:10])
552 class SanitizingRule(Document):
553 slug = StringProperty()
554 applies_to = StringListProperty()
555 search = StringProperty()
556 replace = StringProperty()
557 priority = IntegerProperty()
558 description = StringProperty()
561 def __repr__(self):
562 return 'SanitizingRule %s' % self._id