1 from __future__
import division
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):
28 class MergedIdException(Exception):
29 """ raised when an object is accessed through one of its merged_ids """
31 def __init__(self
, obj
, current_id
):
33 self
.current_id
= current_id
36 class Episode(Document
, SlugMixin
, OldIdMixin
):
38 Represents an Episode. Can only be part of a Podcast
41 __metaclass__
= DocumentABCMeta
43 title
= StringProperty()
44 guid
= StringProperty()
45 description
= StringProperty(default
="")
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()
71 return 'Episode %s' % self
._id
75 def get_short_title(self
, common_title
):
76 if not self
.title
or not common_title
:
79 title
= self
.title
.replace(common_title
, '').strip()
80 title
= re
.sub(r
'^[\W\d]+', '', title
)
84 def get_episode_number(self
, common_title
):
85 if not self
.title
or not common_title
:
88 title
= self
.title
.replace(common_title
, '').strip()
89 match
= re
.search(r
'^\W*(\d+)', title
)
93 return int(match
.group(1))
97 return set([self
._id
] + self
.merged_ids
)
101 def needs_update(self
):
102 """ Indicates if the object requires an updated from its feed """
103 return not self
.title
and not self
.outdated
106 def __eq__(self
, other
):
109 return self
._id
== other
._id
113 return hash(self
._id
)
116 def __unicode__(self
):
117 return u
'<{cls} {title} ({id})>'.format(cls
=self
.__class
__.__name
__,
118 title
=self
.title
, id=self
._id
)
122 class SubscriberData(DocumentSchema
):
123 timestamp
= DateTimeProperty()
124 subscriber_count
= IntegerProperty()
126 def __eq__(self
, other
):
127 if not isinstance(other
, SubscriberData
):
130 return (self
.timestamp
== other
.timestamp
) and \
131 (self
.subscriber_count
== other
.subscriber_count
)
134 return hash(frozenset([self
.timestamp
, self
.subscriber_count
]))
137 class PodcastSubscriberData(Document
):
138 podcast
= StringProperty()
139 subscribers
= SchemaListProperty(SubscriberData
)
143 return 'PodcastSubscriberData for Podcast %s (%s)' % (self
.podcast
, self
._id
)
146 class Podcast(Document
, SlugMixin
, OldIdMixin
):
148 __metaclass__
= DocumentABCMeta
150 id = StringProperty()
151 title
= StringProperty()
152 urls
= StringListProperty()
153 description
= StringProperty()
154 link
= StringProperty()
155 last_update
= DateTimeProperty()
156 logo_url
= StringProperty()
157 author
= StringProperty()
158 merged_ids
= StringListProperty()
159 group
= StringProperty()
160 group_member_name
= StringProperty()
161 related_podcasts
= StringListProperty()
162 subscribers
= SchemaListProperty(SubscriberData
)
163 language
= StringProperty()
164 content_types
= StringListProperty()
165 tags
= DictProperty()
166 restrictions
= StringListProperty()
167 common_episode_title
= StringProperty()
168 new_location
= StringProperty()
169 latest_episode_timestamp
= DateTimeProperty()
170 episode_count
= IntegerProperty()
171 random_key
= FloatProperty(default
=random
)
172 flattr_url
= StringProperty()
173 outdated
= BooleanProperty(default
=False)
174 created_timestamp
= IntegerProperty()
178 def get_podcast_by_id(self
, id, current_id
=False):
179 if current_id
and id != self
.get_id():
180 raise MergedIdException(self
, self
.get_id())
185 get_podcast_by_oldid
= get_podcast_by_id
186 get_podcast_by_url
= get_podcast_by_id
190 return self
.id or self
._id
193 return set([self
.get_id()] + self
.merged_ids
)
196 def display_title(self
):
197 return self
.title
or self
.url
200 def group_with(self
, other
, grouptitle
, myname
, othername
):
202 if self
.group
and (self
.group
== other
.group
):
203 # they are already grouped
206 group1
= PodcastGroup
.get(self
.group
) if self
.group
else None
207 group2
= PodcastGroup
.get(other
.group
) if other
.group
else None
209 if group1
and group2
:
210 raise ValueError('both podcasts already are in different groups')
212 elif not (group1
or group2
):
213 group
= PodcastGroup(title
=grouptitle
)
215 group
.add_podcast(self
, myname
)
216 group
.add_podcast(other
, othername
)
220 group1
.add_podcast(other
, othername
)
224 group2
.add_podcast(self
, myname
)
229 def get_common_episode_title(self
, num_episodes
=100):
231 if self
.common_episode_title
:
232 return self
.common_episode_title
234 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
235 episodes
= episodes_for_podcast(self
, descending
=True, limit
=num_episodes
)
237 # We take all non-empty titles
238 titles
= filter(None, (e
.title
for e
in episodes
))
239 # get the longest common substring
240 common_title
= utils
.longest_substr(titles
)
242 # but consider only the part up to the first number. Otherwise we risk
243 # removing part of the number (eg if a feed contains episodes 100-199)
244 common_title
= re
.search(r
'^\D*', common_title
).group(0)
246 if len(common_title
.strip()) < 2:
252 def get_episode_before(self
, episode
):
253 if not episode
.released
:
256 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
257 prevs
= episodes_for_podcast(self
, until
=episode
.released
,
258 descending
=True, limit
=1)
260 return next(iter(prevs
), None)
263 def get_episode_after(self
, episode
):
264 if not episode
.released
:
267 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
268 from datetime
import timedelta
269 nexts
= episodes_for_podcast(self
,
270 since
=episode
.released
+ timedelta(seconds
=1), limit
=1)
272 return next(iter(nexts
), None)
280 def get_podcast(self
):
284 def get_logo_url(self
, size
):
286 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
288 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
290 prefix
= CoverArt
.get_prefix(filename
)
292 return reverse('logo', args
=[size
, prefix
, filename
])
295 def subscriber_change(self
):
296 prev
= self
.prev_subscriber_count()
300 return self
.subscriber_count() / prev
303 def subscriber_count(self
):
304 if not self
.subscribers
:
306 return self
.subscribers
[-1].subscriber_count
309 def prev_subscriber_count(self
):
310 if len(self
.subscribers
) < 2:
312 return self
.subscribers
[-2].subscriber_count
316 @repeat_on_conflict()
317 def subscribe(self
, user
, device
):
318 from mygpo
.db
.couchdb
.podcast_state
import podcast_state_for_user_podcast
319 state
= podcast_state_for_user_podcast(user
, self
)
320 state
.subscribe(device
)
323 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
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
)
336 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
338 except Unauthorized
as ex
:
339 raise SubscriptionException(ex
)
342 def subscribe_targets(self
, user
):
344 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
345 devices/syncgroups on which the podcast is already subscribed
349 subscriptions_by_devices
= user
.get_subscriptions_by_device()
351 for group
in user
.get_grouped_devices():
355 dev
= group
.devices
[0]
357 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
358 targets
.append(group
.devices
)
361 for device
in group
.devices
:
362 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
363 targets
.append(device
)
369 def needs_update(self
):
370 """ Indicates if the object requires an updated from its feed """
371 return not self
.title
and not self
.outdated
375 return hash(self
.get_id())
380 return super(Podcast
, self
).__repr
__()
382 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
384 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
388 group
= getattr(self
, 'group', None)
389 if group
: # we are part of a PodcastGroup
390 group
= PodcastGroup
.get(group
)
391 podcasts
= list(group
.podcasts
)
393 if not self
in podcasts
:
394 # the podcast has not been added to the group correctly
395 group
.add_podcast(self
)
398 i
= podcasts
.index(self
)
400 group
.podcasts
= podcasts
403 i
= podcasts
.index(self
)
405 group
.podcasts
= podcasts
409 super(Podcast
, self
).save()
413 group
= getattr(self
, 'group', None)
415 group
= PodcastGroup
.get(group
)
416 podcasts
= list(group
.podcasts
)
419 i
= podcasts
.index(self
)
421 group
.podcasts
= podcasts
425 super(Podcast
, self
).delete()
428 def __eq__(self
, other
):
429 if not self
.get_id():
435 return self
.get_id() == other
.get_id()
439 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
440 title
= StringProperty()
441 podcasts
= SchemaListProperty(Podcast
)
447 def get_podcast_by_id(self
, id, current_id
=False):
448 for podcast
in self
.podcasts
:
449 if podcast
.get_id() == id:
452 if id in podcast
.merged_ids
:
454 raise MergedIdException(podcast
, podcast
.get_id())
459 def get_podcast_by_oldid(self
, oldid
):
460 for podcast
in list(self
.podcasts
):
461 if podcast
.oldid
== oldid
:
465 def get_podcast_by_url(self
, url
):
466 for podcast
in self
.podcasts
:
467 if url
in list(podcast
.urls
):
471 def subscriber_change(self
):
472 prev
= self
.prev_subscriber_count()
476 return self
.subscriber_count() / prev
479 def subscriber_count(self
):
480 return sum([p
.subscriber_count() for p
in self
.podcasts
])
483 def prev_subscriber_count(self
):
484 return sum([p
.prev_subscriber_count() for p
in self
.podcasts
])
487 def display_title(self
):
492 def needs_update(self
):
493 """ Indicates if the object requires an updated from its feed """
494 # A PodcastGroup has been manually created and therefore never
498 def get_podcast(self
):
499 # return podcast with most subscribers (bug 1390)
500 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
506 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
509 def logo_url(self
, value
):
510 self
.podcasts
[0].logo_url
= value
513 def get_logo_url(self
, size
):
515 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
517 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
519 prefix
= CoverArt
.get_prefix(filename
)
521 return reverse('logo', args
=[size
, prefix
, filename
])
524 def add_podcast(self
, podcast
, member_name
):
527 raise ValueError('group has to have an _id first')
530 raise ValueError('podcast needs to have an _id first')
533 podcast
.id = podcast
._id
536 podcast
.group
= self
._id
537 podcast
.group_member_name
= member_name
538 self
.podcasts
= sorted(self
.podcasts
+ [podcast
],
539 key
=Podcast
.subscriber_count
, reverse
=True)
545 return super(PodcastGroup
, self
).__repr
__()
547 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
549 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])
553 class SanitizingRule(Document
):
554 slug
= StringProperty()
555 applies_to
= StringListProperty()
556 search
= StringProperty()
557 replace
= StringProperty()
558 priority
= IntegerProperty()
559 description
= StringProperty()
563 return 'SanitizingRule %s' % self
._id