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 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()
73 return 'Episode %s' % self
._id
77 def get_short_title(self
, common_title
):
78 if not self
.title
or not common_title
:
81 title
= self
.title
.replace(common_title
, '').strip()
82 title
= re
.sub(r
'^[\W\d]+', '', title
)
86 def get_episode_number(self
, common_title
):
87 if not self
.title
or not common_title
:
90 title
= self
.title
.replace(common_title
, '').strip()
91 match
= re
.search(r
'^\W*(\d+)', title
)
95 return int(match
.group(1))
99 return set([self
._id
] + self
.merged_ids
)
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
):
111 return self
._id
== other
._id
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
):
132 return (self
.timestamp
== other
.timestamp
) and \
133 (self
.subscriber_count
== other
.subscriber_count
)
136 return hash(frozenset([self
.timestamp
, self
.subscriber_count
]))
139 class PodcastSubscriberData(Document
):
140 podcast
= StringProperty()
141 subscribers
= SchemaListProperty(SubscriberData
)
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())
190 get_podcast_by_oldid
= get_podcast_by_id
191 get_podcast_by_url
= get_podcast_by_id
195 return self
.id or self
._id
198 return set([self
.get_id()] + self
.merged_ids
)
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
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
)
220 group
.add_podcast(self
, myname
)
221 group
.add_podcast(other
, othername
)
225 group1
.add_podcast(other
, othername
)
229 group2
.add_podcast(self
, myname
)
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:
257 def get_episode_before(self
, episode
):
258 if not episode
.released
:
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
:
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)
285 def get_podcast(self
):
289 def get_logo_url(self
, size
):
291 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
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()
305 return self
.subscriber_count() / prev
308 def subscriber_count(self
):
309 if not self
.subscribers
:
311 return self
.subscribers
[-1].subscriber_count
314 def prev_subscriber_count(self
):
315 if len(self
.subscribers
) < 2:
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
)
328 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
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
)
341 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
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
354 subscriptions_by_devices
= user
.get_subscriptions_by_device()
356 for group
in user
.get_grouped_devices():
360 dev
= group
.devices
[0]
362 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
363 targets
.append(group
.devices
)
366 for device
in group
.devices
:
367 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
368 targets
.append(device
)
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
380 return hash(self
.get_id())
385 return super(Podcast
, self
).__repr
__()
387 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
389 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
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
)
403 i
= podcasts
.index(self
)
405 group
.podcasts
= podcasts
408 i
= podcasts
.index(self
)
410 group
.podcasts
= podcasts
414 super(Podcast
, self
).save()
418 group
= getattr(self
, 'group', None)
420 group
= PodcastGroup
.get(group
)
421 podcasts
= list(group
.podcasts
)
424 i
= podcasts
.index(self
)
426 group
.podcasts
= podcasts
430 super(Podcast
, self
).delete()
433 def __eq__(self
, other
):
434 if not self
.get_id():
440 return self
.get_id() == other
.get_id()
444 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
445 title
= StringProperty()
446 podcasts
= SchemaListProperty(Podcast
)
452 def get_podcast_by_id(self
, id, current_id
=False):
453 for podcast
in self
.podcasts
:
454 if podcast
.get_id() == id:
457 if id in podcast
.merged_ids
:
459 raise MergedIdException(podcast
, podcast
.get_id())
464 def get_podcast_by_oldid(self
, oldid
):
465 for podcast
in list(self
.podcasts
):
466 if podcast
.oldid
== oldid
:
470 def get_podcast_by_url(self
, url
):
471 for podcast
in self
.podcasts
:
472 if url
in list(podcast
.urls
):
476 def subscriber_change(self
):
477 prev
= self
.prev_subscriber_count()
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
])
492 def display_title(self
):
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
503 def get_podcast(self
):
504 # return podcast with most subscribers (bug 1390)
505 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
511 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
514 def logo_url(self
, value
):
515 self
.podcasts
[0].logo_url
= value
518 def get_logo_url(self
, size
):
520 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
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
):
532 raise ValueError('group has to have an _id first')
535 raise ValueError('podcast needs to have an _id first')
538 podcast
.id = podcast
._id
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)
550 return super(PodcastGroup
, self
).__repr
__()
552 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
554 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])