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
.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):
27 class MergedIdException(Exception):
28 """ raised when an object is accessed through one of its merged_ids """
30 def __init__(self
, obj
, current_id
):
32 self
.current_id
= current_id
35 class Episode(Document
, SlugMixin
, OldIdMixin
):
37 Represents an Episode. Can only be part of a Podcast
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()
72 return 'Episode %s' % self
._id
76 def get_short_title(self
, common_title
):
77 if not self
.title
or not common_title
:
80 title
= self
.title
.replace(common_title
, '').strip()
81 title
= re
.sub(r
'^[\W\d]+', '', title
)
85 def get_episode_number(self
, common_title
):
86 if not self
.title
or not common_title
:
89 title
= self
.title
.replace(common_title
, '').strip()
90 match
= re
.search(r
'^\W*(\d+)', title
)
94 return int(match
.group(1))
98 return set([self
._id
] + self
.merged_ids
)
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
):
110 return self
._id
== other
._id
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
):
131 return (self
.timestamp
== other
.timestamp
) and \
132 (self
.subscriber_count
== other
.subscriber_count
)
135 return hash(frozenset([self
.timestamp
, self
.subscriber_count
]))
138 class PodcastSubscriberData(Document
):
139 podcast
= StringProperty()
140 subscribers
= SchemaListProperty(SubscriberData
)
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())
189 get_podcast_by_oldid
= get_podcast_by_id
190 get_podcast_by_url
= get_podcast_by_id
194 return self
.id or self
._id
197 return set([self
.get_id()] + self
.merged_ids
)
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
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
)
219 group
.add_podcast(self
, myname
)
220 group
.add_podcast(other
, othername
)
224 group1
.add_podcast(other
, othername
)
228 group2
.add_podcast(self
, myname
)
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:
256 def get_episode_before(self
, episode
):
257 if not episode
.released
:
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
:
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)
284 def get_podcast(self
):
288 def get_logo_url(self
, size
):
290 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
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()
304 return self
.subscriber_count() / prev
307 def subscriber_count(self
):
308 if not self
.subscribers
:
310 return self
.subscribers
[-1].subscriber_count
313 def prev_subscriber_count(self
):
314 if len(self
.subscribers
) < 2:
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 subscribe_on_device
, \
323 podcast_state_for_user_podcast
324 state
= podcast_state_for_user_podcast(user
, self
)
326 subscribe_on_device(state
, device
)
327 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
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 unsubscribe_on_device
, \
336 podcast_state_for_user_podcast
337 state
= podcast_state_for_user_podcast(user
, self
)
339 unsubscribe_on_device(state
, device
)
340 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
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
353 subscriptions_by_devices
= user
.get_subscriptions_by_device()
355 for group
in user
.get_grouped_devices():
359 dev
= group
.devices
[0]
361 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
362 targets
.append(group
.devices
)
365 for device
in group
.devices
:
366 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
367 targets
.append(device
)
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
379 return hash(self
.get_id())
384 return super(Podcast
, self
).__repr
__()
386 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
388 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
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
)
402 i
= podcasts
.index(self
)
404 group
.podcasts
= podcasts
407 i
= podcasts
.index(self
)
409 group
.podcasts
= podcasts
413 super(Podcast
, self
).save()
417 group
= getattr(self
, 'group', None)
419 group
= PodcastGroup
.get(group
)
420 podcasts
= list(group
.podcasts
)
423 i
= podcasts
.index(self
)
425 group
.podcasts
= podcasts
429 super(Podcast
, self
).delete()
432 def __eq__(self
, other
):
433 if not self
.get_id():
439 return self
.get_id() == other
.get_id()
443 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
444 title
= StringProperty()
445 podcasts
= SchemaListProperty(Podcast
)
451 def get_podcast_by_id(self
, id, current_id
=False):
452 for podcast
in self
.podcasts
:
453 if podcast
.get_id() == id:
456 if id in podcast
.merged_ids
:
458 raise MergedIdException(podcast
, podcast
.get_id())
463 def get_podcast_by_oldid(self
, oldid
):
464 for podcast
in list(self
.podcasts
):
465 if podcast
.oldid
== oldid
or oldid
in podcast
.merged_oldids
:
469 def get_podcast_by_url(self
, url
):
470 for podcast
in self
.podcasts
:
471 if url
in list(podcast
.urls
):
475 def subscriber_change(self
):
476 prev
= self
.prev_subscriber_count()
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
])
491 def display_title(self
):
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
502 def get_podcast(self
):
503 # return podcast with most subscribers (bug 1390)
504 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
510 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
513 def logo_url(self
, value
):
514 self
.podcasts
[0].logo_url
= value
517 def get_logo_url(self
, size
):
519 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
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
):
531 raise ValueError('group has to have an _id first')
534 raise ValueError('podcast needs to have an _id first')
537 podcast
.id = podcast
._id
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)
549 return super(PodcastGroup
, self
).__repr
__()
551 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
553 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])