fix one-off error in merge-episode-states
[mygpo.git] / mygpo / core / models.py
blob76ad88d0494570561576b847056fd1274a1def0f
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
21 class SubscriptionException(Exception):
22 pass
25 class MergedIdException(Exception):
26 """ raised when an object is accessed through one of its merged_ids """
28 def __init__(self, obj, current_id):
29 self.obj = obj
30 self.current_id = current_id
33 class Episode(Document, SlugMixin, OldIdMixin):
34 """
35 Represents an Episode. Can only be part of a Podcast
36 """
38 __metaclass__ = DocumentABCMeta
40 title = StringProperty()
41 guid = StringProperty()
42 description = StringProperty(default="")
43 content = StringProperty(default="")
44 link = StringProperty()
45 released = DateTimeProperty()
46 author = StringProperty()
47 duration = IntegerProperty()
48 filesize = IntegerProperty()
49 language = StringProperty()
50 last_update = DateTimeProperty()
51 outdated = BooleanProperty(default=False)
52 mimetypes = StringListProperty()
53 merged_ids = StringListProperty()
54 urls = StringListProperty()
55 podcast = StringProperty(required=True)
56 listeners = IntegerProperty()
57 content_types = StringListProperty()
61 @property
62 def url(self):
63 return self.urls[0]
65 def __repr__(self):
66 return 'Episode %s' % self._id
70 def get_short_title(self, common_title):
71 if not self.title or not common_title:
72 return None
74 title = self.title.replace(common_title, '').strip()
75 title = re.sub(r'^[\W\d]+', '', title)
76 return title
79 def get_episode_number(self, common_title):
80 if not self.title or not common_title:
81 return None
83 title = self.title.replace(common_title, '').strip()
84 match = re.search(r'^\W*(\d+)', title)
85 if not match:
86 return None
88 return int(match.group(1))
91 def get_ids(self):
92 return set([self._id] + self.merged_ids)
95 def __eq__(self, other):
96 if other == None:
97 return False
98 return self._id == other._id
101 def __hash__(self):
102 return hash(self._id)
105 def __str__(self):
106 return '<{cls} {title} ({id})>'.format(cls=self.__class__.__name__,
107 title=self.title, id=self._id)
109 __repr__ = __str__
112 class SubscriberData(DocumentSchema):
113 timestamp = DateTimeProperty()
114 subscriber_count = IntegerProperty()
116 def __eq__(self, other):
117 if not isinstance(other, SubscriberData):
118 return False
120 return (self.timestamp == other.timestamp) and \
121 (self.subscriber_count == other.subscriber_count)
123 def __hash__(self):
124 return hash(frozenset([self.timestamp, self.subscriber_count]))
127 class PodcastSubscriberData(Document):
128 podcast = StringProperty()
129 subscribers = SchemaListProperty(SubscriberData)
132 def __repr__(self):
133 return 'PodcastSubscriberData for Podcast %s (%s)' % (self.podcast, self._id)
136 class Podcast(Document, SlugMixin, OldIdMixin):
138 __metaclass__ = DocumentABCMeta
140 id = StringProperty()
141 title = StringProperty()
142 urls = StringListProperty()
143 description = StringProperty()
144 link = StringProperty()
145 last_update = DateTimeProperty()
146 logo_url = StringProperty()
147 author = StringProperty()
148 merged_ids = StringListProperty()
149 group = StringProperty()
150 group_member_name = StringProperty()
151 related_podcasts = StringListProperty()
152 subscribers = SchemaListProperty(SubscriberData)
153 language = StringProperty()
154 content_types = StringListProperty()
155 tags = DictProperty()
156 restrictions = StringListProperty()
157 common_episode_title = StringProperty()
158 new_location = StringProperty()
159 latest_episode_timestamp = DateTimeProperty()
160 episode_count = IntegerProperty()
161 random_key = FloatProperty(default=random)
165 def get_podcast_by_id(self, id, current_id=False):
166 if current_id and id != self.get_id():
167 raise MergedIdException(self, self.get_id())
169 return self
172 get_podcast_by_oldid = get_podcast_by_id
173 get_podcast_by_url = get_podcast_by_id
176 def get_id(self):
177 return self.id or self._id
179 def get_ids(self):
180 return set([self.get_id()] + self.merged_ids)
182 @property
183 def display_title(self):
184 return self.title or self.url
187 def group_with(self, other, grouptitle, myname, othername):
189 if self.group and (self.group == other.group):
190 # they are already grouped
191 return
193 group1 = PodcastGroup.get(self.group) if self.group else None
194 group2 = PodcastGroup.get(other.group) if other.group else None
196 if group1 and group2:
197 raise ValueError('both podcasts already are in different groups')
199 elif not (group1 or group2):
200 group = PodcastGroup(title=grouptitle)
201 group.save()
202 group.add_podcast(self, myname)
203 group.add_podcast(other, othername)
204 return group
206 elif group1:
207 group1.add_podcast(other, othername)
208 return group1
210 else:
211 group2.add_podcast(self, myname)
212 return group2
216 def get_common_episode_title(self, num_episodes=100):
218 if self.common_episode_title:
219 return self.common_episode_title
221 from mygpo.db.couchdb.episode import episodes_for_podcast
222 episodes = episodes_for_podcast(self, descending=True, limit=num_episodes)
224 # We take all non-empty titles
225 titles = filter(None, (e.title for e in episodes))
226 # get the longest common substring
227 common_title = utils.longest_substr(titles)
229 # but consider only the part up to the first number. Otherwise we risk
230 # removing part of the number (eg if a feed contains episodes 100-199)
231 common_title = re.search(r'^\D*', common_title).group(0)
233 if len(common_title.strip()) < 2:
234 return None
236 return common_title
239 @cache_result(timeout=60*60)
240 def get_latest_episode(self):
241 # since = 1 ==> has a timestamp
243 from mygpo.db.couchdb.episode import episodes_for_podcast
244 episodes = episodes_for_podcast(self, since=1, descending=True, limit=1)
245 return next(iter(episodes), None)
248 def get_episode_before(self, episode):
249 if not episode.released:
250 return None
252 from mygpo.db.couchdb.episode import episodes_for_podcast
253 prevs = episodes_for_podcast(self, until=episode.released,
254 descending=True, limit=1)
256 return next(iter(prevs), None)
259 def get_episode_after(self, episode):
260 if not episode.released:
261 return None
263 from mygpo.db.couchdb.episode import episodes_for_podcast
264 nexts = episodes_for_podcast(self, since=episode.released, limit=1)
266 return next(iter(nexts), None)
269 @property
270 def url(self):
271 return self.urls[0]
274 def get_podcast(self):
275 return self
278 def get_logo_url(self, size):
279 if self.logo_url:
280 filename = hashlib.sha1(self.logo_url).hexdigest()
281 else:
282 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
284 prefix = CoverArt.get_prefix(filename)
286 return reverse('logo', args=[size, prefix, filename])
289 def subscriber_change(self):
290 prev = self.prev_subscriber_count()
291 if prev <= 0:
292 return 0
294 return self.subscriber_count() / prev
297 def subscriber_count(self):
298 if not self.subscribers:
299 return 0
300 return self.subscribers[-1].subscriber_count
303 def prev_subscriber_count(self):
304 if len(self.subscribers) < 2:
305 return 0
306 return self.subscribers[-2].subscriber_count
310 @repeat_on_conflict()
311 def subscribe(self, user, device):
312 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
313 state = podcast_state_for_user_podcast(user, self)
314 state.subscribe(device)
315 try:
316 state.save()
317 user.sync_all()
318 except Unauthorized as ex:
319 raise SubscriptionException(ex)
322 @repeat_on_conflict()
323 def unsubscribe(self, user, device):
324 from mygpo.db.couchdb.podcast_state import podcast_state_for_user_podcast
325 state = podcast_state_for_user_podcast(user, self)
326 state.unsubscribe(device)
327 try:
328 state.save()
329 user.sync_all()
330 except Unauthorized as ex:
331 raise SubscriptionException(ex)
334 def subscribe_targets(self, user):
336 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
337 devices/syncgroups on which the podcast is already subscribed
339 targets = []
341 subscriptions_by_devices = user.get_subscriptions_by_device()
343 for group in user.get_grouped_devices():
345 if group.is_synced:
347 dev = group.devices[0]
349 if not self.get_id() in subscriptions_by_devices[dev.id]:
350 targets.append(group.devices)
352 else:
353 for device in group.devices:
354 if not self.get_id() in subscriptions_by_devices[device.id]:
355 targets.append(device)
357 return targets
360 def __hash__(self):
361 return hash(self.get_id())
364 def __repr__(self):
365 if not self._id:
366 return super(Podcast, self).__repr__()
367 elif self.oldid:
368 return '%s %s (%s)' % (self.__class__.__name__, self.get_id(), self.oldid)
369 else:
370 return '%s %s' % (self.__class__.__name__, self.get_id())
373 def save(self):
374 group = getattr(self, 'group', None)
375 if group: #we are part of a PodcastGroup
376 group = PodcastGroup.get(group)
377 podcasts = list(group.podcasts)
379 if not self in podcasts:
380 # the podcast has not been added to the group correctly
381 group.add_podcast(self)
383 else:
384 i = podcasts.index(self)
385 podcasts[i] = self
386 group.podcasts = podcasts
387 group.save()
389 i = podcasts.index(self)
390 podcasts[i] = self
391 group.podcasts = podcasts
392 group.save()
394 else:
395 super(Podcast, self).save()
398 def delete(self):
399 group = getattr(self, 'group', None)
400 if group:
401 group = PodcastGroup.get(group)
402 podcasts = list(group.podcasts)
404 if self in podcasts:
405 i = podcasts.index(self)
406 del podcasts[i]
407 group.podcasts = podcasts
408 group.save()
410 else:
411 super(Podcast, self).delete()
414 def __eq__(self, other):
415 if not self.get_id():
416 return self == other
418 if other == None:
419 return False
421 return self.get_id() == other.get_id()
425 class PodcastGroup(Document, SlugMixin, OldIdMixin):
426 title = StringProperty()
427 podcasts = SchemaListProperty(Podcast)
429 def get_id(self):
430 return self._id
433 def get_podcast_by_id(self, id, current_id=False):
434 for podcast in self.podcasts:
435 if podcast.get_id() == id:
436 return podcast
438 if id in podcast.merged_ids:
439 if current_id:
440 raise MergedIdException(podcast, podcast.get_id())
442 return podcast
445 def get_podcast_by_oldid(self, oldid):
446 for podcast in list(self.podcasts):
447 if podcast.oldid == oldid:
448 return podcast
451 def get_podcast_by_url(self, url):
452 for podcast in self.podcasts:
453 if url in list(podcast.urls):
454 return podcast
457 def subscriber_change(self):
458 prev = self.prev_subscriber_count()
459 if not prev:
460 return 0
462 return self.subscriber_count() / prev
465 def subscriber_count(self):
466 return sum([p.subscriber_count() for p in self.podcasts])
469 def prev_subscriber_count(self):
470 return sum([p.prev_subscriber_count() for p in self.podcasts])
472 @property
473 def display_title(self):
474 return self.title
477 def get_podcast(self):
478 # return podcast with most subscribers (bug 1390)
479 return sorted(self.podcasts, key=Podcast.subscriber_count,
480 reverse=True)[0]
483 @property
484 def logo_url(self):
485 return utils.first(p.logo_url for p in self.podcasts)
488 def get_logo_url(self, size):
489 if self.logo_url:
490 filename = hashlib.sha1(self.logo_url).hexdigest()
491 else:
492 filename = 'podcast-%d.png' % (hash(self.title) % 5, )
494 prefix = CoverArt.get_prefix(filename)
496 return reverse('logo', args=[size, prefix, filename])
499 def add_podcast(self, podcast, member_name):
501 if not self._id:
502 raise ValueError('group has to have an _id first')
504 if not podcast._id:
505 raise ValueError('podcast needs to have an _id first')
507 if not podcast.id:
508 podcast.id = podcast._id
510 podcast.delete()
511 podcast.group = self._id
512 podcast.group_member_name = member_name
513 self.podcasts = sorted(self.podcasts + [podcast],
514 key=Podcast.subscriber_count, reverse=True)
515 self.save()
518 def __repr__(self):
519 if not self._id:
520 return super(PodcastGroup, self).__repr__()
521 elif self.oldid:
522 return '%s %s (%s)' % (self.__class__.__name__, self._id[:10], self.oldid)
523 else:
524 return '%s %s' % (self.__class__.__name__, self._id[:10])
528 class SanitizingRule(Document):
529 slug = StringProperty()
530 applies_to = StringListProperty()
531 search = StringProperty()
532 replace = StringProperty()
533 priority = IntegerProperty()
534 description = StringProperty()
537 def __repr__(self):
538 return 'SanitizingRule %s' % self._id