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
))
255 # get the longest common substring
256 common_title
= utils
.longest_substr(titles
)
258 # but consider only the part up to the first number. Otherwise we risk
259 # removing part of the number (eg if a feed contains episodes 100-199)
260 common_title
= re
.search(r
'^\D*', common_title
).group(0)
262 if len(common_title
.strip()) < 2:
268 def get_episode_before(self
, episode
):
269 if not episode
.released
:
272 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
273 prevs
= episodes_for_podcast(self
, until
=episode
.released
,
274 descending
=True, limit
=1)
276 return next(iter(prevs
), None)
279 def get_episode_after(self
, episode
):
280 if not episode
.released
:
283 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
284 nexts
= episodes_for_podcast(self
,
285 since
=episode
.released
+ timedelta(seconds
=1), limit
=1)
287 return next(iter(nexts
), None)
295 def get_podcast(self
):
299 def get_logo_url(self
, size
):
301 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
303 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
305 prefix
= CoverArt
.get_prefix(filename
)
307 return reverse('logo', args
=[size
, prefix
, filename
])
310 def subscriber_change(self
):
311 prev
= self
.prev_subscriber_count()
315 return self
.subscriber_count() / prev
318 def subscriber_count(self
):
319 if not self
.subscribers
:
321 return self
.subscribers
[-1].subscriber_count
324 def prev_subscriber_count(self
):
325 if len(self
.subscribers
) < 2:
327 return self
.subscribers
[-2].subscriber_count
331 @repeat_on_conflict()
332 def subscribe(self
, user
, device
):
333 """ subscribes user to the current podcast on one or more devices """
334 from mygpo
.db
.couchdb
.podcast_state
import subscribe_on_device
, \
335 podcast_state_for_user_podcast
336 state
= podcast_state_for_user_podcast(user
, self
)
338 # accept devices, and also lists and tuples of devices
339 devices
= device
if isinstance(device
, (list, tuple)) else [device
]
341 for device
in devices
:
344 subscribe_on_device(state
, device
)
345 subscription_changed
.send(sender
=self
, user
=user
,
346 device
=device
, subscribed
=True)
347 except Unauthorized
as ex
:
348 raise SubscriptionException(ex
)
351 @repeat_on_conflict()
352 def unsubscribe(self
, user
, device
):
353 """ unsubscribes user from the current podcast on one or more devices """
354 from mygpo
.db
.couchdb
.podcast_state
import unsubscribe_on_device
, \
355 podcast_state_for_user_podcast
356 state
= podcast_state_for_user_podcast(user
, self
)
358 # accept devices, and also lists and tuples of devices
359 devices
= device
if isinstance(device
, (list, tuple)) else [device
]
361 for device
in devices
:
364 unsubscribe_on_device(state
, device
)
365 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
367 except Unauthorized
as ex
:
368 raise SubscriptionException(ex
)
371 def subscribe_targets(self
, user
):
373 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
374 devices/syncgroups on which the podcast is already subscribed
378 subscriptions_by_devices
= user
.get_subscriptions_by_device()
380 for group
in user
.get_grouped_devices():
384 dev
= group
.devices
[0]
386 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
387 targets
.append(group
.devices
)
390 for device
in group
.devices
:
391 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
392 targets
.append(device
)
398 def needs_update(self
):
399 """ Indicates if the object requires an updated from its feed """
400 return not self
.title
and not self
.outdated
403 def next_update(self
):
404 return self
.last_update
+ timedelta(hours
=self
.update_interval
)
407 return hash(self
.get_id())
412 return super(Podcast
, self
).__repr
__()
414 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
416 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
420 group
= getattr(self
, 'group', None)
421 if group
: # we are part of a PodcastGroup
422 group
= PodcastGroup
.get(group
)
423 podcasts
= list(group
.podcasts
)
425 if not self
in podcasts
:
426 # the podcast has not been added to the group correctly
427 group
.add_podcast(self
)
430 i
= podcasts
.index(self
)
432 group
.podcasts
= podcasts
435 i
= podcasts
.index(self
)
437 group
.podcasts
= podcasts
441 super(Podcast
, self
).save()
445 group
= getattr(self
, 'group', None)
447 group
= PodcastGroup
.get(group
)
448 podcasts
= list(group
.podcasts
)
451 i
= podcasts
.index(self
)
453 group
.podcasts
= podcasts
457 super(Podcast
, self
).delete()
460 def __eq__(self
, other
):
461 if not self
.get_id():
467 return self
.get_id() == other
.get_id()
471 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
472 title
= StringProperty()
473 podcasts
= SchemaListProperty(Podcast
)
479 def get_podcast_by_id(self
, id, current_id
=False):
480 for podcast
in self
.podcasts
:
481 if podcast
.get_id() == id:
484 if id in podcast
.merged_ids
:
486 raise MergedIdException(podcast
, podcast
.get_id())
491 def get_podcast_by_oldid(self
, oldid
):
492 for podcast
in list(self
.podcasts
):
493 if podcast
.oldid
== oldid
or oldid
in podcast
.merged_oldids
:
497 def get_podcast_by_url(self
, url
):
498 for podcast
in self
.podcasts
:
499 if url
in list(podcast
.urls
):
503 def subscriber_change(self
):
504 prev
= self
.prev_subscriber_count()
508 return self
.subscriber_count() / prev
511 def subscriber_count(self
):
512 return sum([p
.subscriber_count() for p
in self
.podcasts
])
515 def prev_subscriber_count(self
):
516 return sum([p
.prev_subscriber_count() for p
in self
.podcasts
])
519 def display_title(self
):
524 return utils
.first(p
.license
for p
in self
.podcasts
)
528 def needs_update(self
):
529 """ Indicates if the object requires an updated from its feed """
530 # A PodcastGroup has been manually created and therefore never
534 def get_podcast(self
):
535 # return podcast with most subscribers (bug 1390)
536 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
542 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
545 def logo_url(self
, value
):
546 self
.podcasts
[0].logo_url
= value
549 def get_logo_url(self
, size
):
551 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
553 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
555 prefix
= CoverArt
.get_prefix(filename
)
557 return reverse('logo', args
=[size
, prefix
, filename
])
560 def add_podcast(self
, podcast
, member_name
):
563 raise ValueError('group has to have an _id first')
566 raise ValueError('podcast needs to have an _id first')
569 podcast
.id = podcast
._id
572 podcast
.group
= self
._id
573 podcast
.group_member_name
= member_name
574 self
.podcasts
= sorted(self
.podcasts
+ [podcast
],
575 key
=Podcast
.subscriber_count
, reverse
=True)
581 return super(PodcastGroup
, self
).__repr
__()
583 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
585 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])