1 from __future__
import division
5 from random
import random
6 from datetime
import timedelta
8 from couchdbkit
.ext
.django
.schema
import *
9 from restkit
.errors
import Unauthorized
11 from django
.core
.urlresolvers
import reverse
13 from mygpo
.decorators
import repeat_on_conflict
14 from mygpo
import utils
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 # default podcast update interval in hours
25 DEFAULT_UPDATE_INTERVAL
= 7 * 24
27 # minium podcast update interval in hours
28 MIN_UPDATE_INTERVAL
= 5
30 # every podcast should be updated at least once a month
31 MAX_UPDATE_INTERVAL
= 24 * 30
34 class SubscriptionException(Exception):
38 class MergedIdException(Exception):
39 """ raised when an object is accessed through one of its merged_ids """
41 def __init__(self
, obj
, current_id
):
43 self
.current_id
= current_id
46 class Episode(Document
, SlugMixin
, OldIdMixin
):
48 Represents an Episode. Can only be part of a Podcast
51 __metaclass__
= DocumentABCMeta
53 title
= StringProperty()
54 guid
= StringProperty()
55 description
= StringProperty(default
="")
56 subtitle
= StringProperty()
57 content
= StringProperty(default
="")
58 link
= StringProperty()
59 released
= DateTimeProperty()
60 author
= StringProperty()
61 duration
= IntegerProperty()
62 filesize
= IntegerProperty()
63 language
= StringProperty()
64 last_update
= DateTimeProperty()
65 outdated
= BooleanProperty(default
=False)
66 mimetypes
= StringListProperty()
67 merged_ids
= StringListProperty()
68 urls
= StringListProperty()
69 podcast
= StringProperty(required
=True)
70 listeners
= IntegerProperty()
71 content_types
= StringListProperty()
72 flattr_url
= StringProperty()
73 created_timestamp
= IntegerProperty()
74 license
= StringProperty()
83 return 'Episode %s' % self
._id
87 def get_short_title(self
, common_title
):
88 if not self
.title
or not common_title
:
91 title
= self
.title
.replace(common_title
, '').strip()
92 title
= re
.sub(r
'^[\W\d]+', '', title
)
96 def get_episode_number(self
, common_title
):
97 if not self
.title
or not common_title
:
100 title
= self
.title
.replace(common_title
, '').strip()
101 match
= re
.search(r
'^\W*(\d+)', title
)
105 return int(match
.group(1))
109 return set([self
._id
] + self
.merged_ids
)
113 def needs_update(self
):
114 """ Indicates if the object requires an updated from its feed """
115 return not self
.title
and not self
.outdated
117 def __eq__(self
, other
):
120 return self
._id
== other
._id
124 return hash(self
._id
)
127 def __unicode__(self
):
128 return u
'<{cls} {title} ({id})>'.format(cls
=self
.__class
__.__name
__,
129 title
=self
.title
, id=self
._id
)
133 class SubscriberData(DocumentSchema
):
134 timestamp
= DateTimeProperty()
135 subscriber_count
= IntegerProperty()
137 def __eq__(self
, other
):
138 if not isinstance(other
, SubscriberData
):
141 return (self
.timestamp
== other
.timestamp
) and \
142 (self
.subscriber_count
== other
.subscriber_count
)
145 return hash(frozenset([self
.timestamp
, self
.subscriber_count
]))
148 class PodcastSubscriberData(Document
):
149 podcast
= StringProperty()
150 subscribers
= SchemaListProperty(SubscriberData
)
154 return 'PodcastSubscriberData for Podcast %s (%s)' % (self
.podcast
, self
._id
)
157 class Podcast(Document
, SlugMixin
, OldIdMixin
):
159 __metaclass__
= DocumentABCMeta
161 id = StringProperty()
162 title
= StringProperty()
163 urls
= StringListProperty()
164 description
= StringProperty()
165 subtitle
= StringProperty()
166 link
= StringProperty()
167 last_update
= DateTimeProperty()
168 logo_url
= StringProperty()
169 author
= StringProperty()
170 merged_ids
= StringListProperty()
171 group
= StringProperty()
172 group_member_name
= StringProperty()
173 related_podcasts
= StringListProperty()
174 subscribers
= SchemaListProperty(SubscriberData
)
175 language
= StringProperty()
176 content_types
= StringListProperty()
177 tags
= DictProperty()
178 restrictions
= StringListProperty()
179 common_episode_title
= StringProperty()
180 new_location
= StringProperty()
181 latest_episode_timestamp
= DateTimeProperty()
182 episode_count
= IntegerProperty()
183 random_key
= FloatProperty(default
=random
)
184 flattr_url
= StringProperty()
185 outdated
= BooleanProperty(default
=False)
186 created_timestamp
= IntegerProperty()
187 hub
= StringProperty()
188 license
= StringProperty()
190 # avg time between podcast updates (eg new episodes) in hours
191 update_interval
= IntegerProperty(default
=DEFAULT_UPDATE_INTERVAL
)
194 def get_podcast_by_id(self
, id, current_id
=False):
195 if current_id
and id != self
.get_id():
196 raise MergedIdException(self
, self
.get_id())
201 get_podcast_by_oldid
= get_podcast_by_id
202 get_podcast_by_url
= get_podcast_by_id
206 return self
.id or self
._id
209 return set([self
.get_id()] + self
.merged_ids
)
212 def display_title(self
):
213 return self
.title
or self
.url
216 def group_with(self
, other
, grouptitle
, myname
, othername
):
218 if self
.group
and (self
.group
== other
.group
):
219 # they are already grouped
222 group1
= PodcastGroup
.get(self
.group
) if self
.group
else None
223 group2
= PodcastGroup
.get(other
.group
) if other
.group
else None
225 if group1
and group2
:
226 raise ValueError('both podcasts already are in different groups')
228 elif not (group1
or group2
):
229 group
= PodcastGroup(title
=grouptitle
)
231 group
.add_podcast(self
, myname
)
232 group
.add_podcast(other
, othername
)
236 group1
.add_podcast(other
, othername
)
240 group2
.add_podcast(self
, myname
)
245 def get_common_episode_title(self
, num_episodes
=100):
247 if self
.common_episode_title
:
248 return self
.common_episode_title
250 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
251 episodes
= episodes_for_podcast(self
, descending
=True, limit
=num_episodes
)
253 # We take all non-empty titles
254 titles
= filter(None, (e
.title
for e
in episodes
))
256 # there can not be a "common" title of a single title
260 # get the longest common substring
261 common_title
= utils
.longest_substr(titles
)
263 # but consider only the part up to the first number. Otherwise we risk
264 # removing part of the number (eg if a feed contains episodes 100-199)
265 common_title
= re
.search(r
'^\D*', common_title
).group(0)
267 if len(common_title
.strip()) < 2:
273 def get_episode_before(self
, episode
):
274 if not episode
.released
:
277 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
278 prevs
= episodes_for_podcast(self
, until
=episode
.released
,
279 descending
=True, limit
=1)
281 return next(iter(prevs
), None)
284 def get_episode_after(self
, episode
):
285 if not episode
.released
:
288 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
289 nexts
= episodes_for_podcast(self
,
290 since
=episode
.released
+ timedelta(seconds
=1), limit
=1)
292 return next(iter(nexts
), None)
300 def get_podcast(self
):
304 def get_logo_url(self
, size
):
306 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
308 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
310 prefix
= CoverArt
.get_prefix(filename
)
312 return reverse('logo', args
=[size
, prefix
, filename
])
315 def subscriber_change(self
):
316 prev
= self
.prev_subscriber_count()
320 return self
.subscriber_count() / prev
323 def subscriber_count(self
):
324 if not self
.subscribers
:
326 return self
.subscribers
[-1].subscriber_count
329 def prev_subscriber_count(self
):
330 if len(self
.subscribers
) < 2:
332 return self
.subscribers
[-2].subscriber_count
336 @repeat_on_conflict()
337 def subscribe(self
, user
, device
):
338 """ subscribes user to the current podcast on one or more devices """
339 from mygpo
.db
.couchdb
.podcast_state
import subscribe_on_device
, \
340 podcast_state_for_user_podcast
341 state
= podcast_state_for_user_podcast(user
, self
)
343 # accept devices, and also lists and tuples of devices
344 devices
= device
if isinstance(device
, (list, tuple)) else [device
]
346 for device
in devices
:
349 subscribe_on_device(state
, device
)
350 subscription_changed
.send(sender
=self
, user
=user
,
351 device
=device
, subscribed
=True)
352 except Unauthorized
as ex
:
353 raise SubscriptionException(ex
)
356 @repeat_on_conflict()
357 def unsubscribe(self
, user
, device
):
358 """ unsubscribes user from the current podcast on one or more devices """
359 from mygpo
.db
.couchdb
.podcast_state
import unsubscribe_on_device
, \
360 podcast_state_for_user_podcast
361 state
= podcast_state_for_user_podcast(user
, self
)
363 # accept devices, and also lists and tuples of devices
364 devices
= device
if isinstance(device
, (list, tuple)) else [device
]
366 for device
in devices
:
369 unsubscribe_on_device(state
, device
)
370 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
372 except Unauthorized
as ex
:
373 raise SubscriptionException(ex
)
376 def subscribe_targets(self
, user
):
378 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
379 devices/syncgroups on which the podcast is already subscribed
383 subscriptions_by_devices
= user
.get_subscriptions_by_device()
385 for group
in user
.get_grouped_devices():
389 dev
= group
.devices
[0]
391 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
392 targets
.append(group
.devices
)
395 for device
in group
.devices
:
396 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
397 targets
.append(device
)
403 def needs_update(self
):
404 """ Indicates if the object requires an updated from its feed """
405 return not self
.title
and not self
.outdated
408 def next_update(self
):
409 return self
.last_update
+ timedelta(hours
=self
.update_interval
)
412 return hash(self
.get_id())
417 return super(Podcast
, self
).__repr
__()
419 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
421 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
425 group
= getattr(self
, 'group', None)
426 if group
: # we are part of a PodcastGroup
427 group
= PodcastGroup
.get(group
)
428 podcasts
= list(group
.podcasts
)
430 if not self
in podcasts
:
431 # the podcast has not been added to the group correctly
432 group
.add_podcast(self
)
435 i
= podcasts
.index(self
)
437 group
.podcasts
= podcasts
440 i
= podcasts
.index(self
)
442 group
.podcasts
= podcasts
446 super(Podcast
, self
).save()
450 group
= getattr(self
, 'group', None)
452 group
= PodcastGroup
.get(group
)
453 podcasts
= list(group
.podcasts
)
456 i
= podcasts
.index(self
)
458 group
.podcasts
= podcasts
462 super(Podcast
, self
).delete()
465 def __eq__(self
, other
):
466 if not self
.get_id():
472 return self
.get_id() == other
.get_id()
476 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
477 title
= StringProperty()
478 podcasts
= SchemaListProperty(Podcast
)
484 def get_podcast_by_id(self
, id, current_id
=False):
485 for podcast
in self
.podcasts
:
486 if podcast
.get_id() == id:
489 if id in podcast
.merged_ids
:
491 raise MergedIdException(podcast
, podcast
.get_id())
496 def get_podcast_by_oldid(self
, oldid
):
497 for podcast
in list(self
.podcasts
):
498 if podcast
.oldid
== oldid
or oldid
in podcast
.merged_oldids
:
502 def get_podcast_by_url(self
, url
):
503 for podcast
in self
.podcasts
:
504 if url
in list(podcast
.urls
):
508 def subscriber_change(self
):
509 prev
= self
.prev_subscriber_count()
513 return self
.subscriber_count() / prev
516 def subscriber_count(self
):
517 return sum([p
.subscriber_count() for p
in self
.podcasts
])
520 def prev_subscriber_count(self
):
521 return sum([p
.prev_subscriber_count() for p
in self
.podcasts
])
524 def display_title(self
):
529 return utils
.first(p
.license
for p
in self
.podcasts
)
533 def needs_update(self
):
534 """ Indicates if the object requires an updated from its feed """
535 # A PodcastGroup has been manually created and therefore never
539 def get_podcast(self
):
540 # return podcast with most subscribers (bug 1390)
541 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
547 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
550 def logo_url(self
, value
):
551 self
.podcasts
[0].logo_url
= value
554 def get_logo_url(self
, size
):
556 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
558 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
560 prefix
= CoverArt
.get_prefix(filename
)
562 return reverse('logo', args
=[size
, prefix
, filename
])
565 def add_podcast(self
, podcast
, member_name
):
568 raise ValueError('group has to have an _id first')
571 raise ValueError('podcast needs to have an _id first')
574 podcast
.id = podcast
._id
577 podcast
.group
= self
._id
578 podcast
.group_member_name
= member_name
579 self
.podcasts
= sorted(self
.podcasts
+ [podcast
],
580 key
=Podcast
.subscriber_count
, reverse
=True)
586 return super(PodcastGroup
, self
).__repr
__()
588 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
590 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])