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()
175 hub
= StringProperty()
179 def get_podcast_by_id(self
, id, current_id
=False):
180 if current_id
and id != self
.get_id():
181 raise MergedIdException(self
, self
.get_id())
186 get_podcast_by_oldid
= get_podcast_by_id
187 get_podcast_by_url
= get_podcast_by_id
191 return self
.id or self
._id
194 return set([self
.get_id()] + self
.merged_ids
)
197 def display_title(self
):
198 return self
.title
or self
.url
201 def group_with(self
, other
, grouptitle
, myname
, othername
):
203 if self
.group
and (self
.group
== other
.group
):
204 # they are already grouped
207 group1
= PodcastGroup
.get(self
.group
) if self
.group
else None
208 group2
= PodcastGroup
.get(other
.group
) if other
.group
else None
210 if group1
and group2
:
211 raise ValueError('both podcasts already are in different groups')
213 elif not (group1
or group2
):
214 group
= PodcastGroup(title
=grouptitle
)
216 group
.add_podcast(self
, myname
)
217 group
.add_podcast(other
, othername
)
221 group1
.add_podcast(other
, othername
)
225 group2
.add_podcast(self
, myname
)
230 def get_common_episode_title(self
, num_episodes
=100):
232 if self
.common_episode_title
:
233 return self
.common_episode_title
235 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
236 episodes
= episodes_for_podcast(self
, descending
=True, limit
=num_episodes
)
238 # We take all non-empty titles
239 titles
= filter(None, (e
.title
for e
in episodes
))
240 # get the longest common substring
241 common_title
= utils
.longest_substr(titles
)
243 # but consider only the part up to the first number. Otherwise we risk
244 # removing part of the number (eg if a feed contains episodes 100-199)
245 common_title
= re
.search(r
'^\D*', common_title
).group(0)
247 if len(common_title
.strip()) < 2:
253 def get_episode_before(self
, episode
):
254 if not episode
.released
:
257 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
258 prevs
= episodes_for_podcast(self
, until
=episode
.released
,
259 descending
=True, limit
=1)
261 return next(iter(prevs
), None)
264 def get_episode_after(self
, episode
):
265 if not episode
.released
:
268 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
269 from datetime
import timedelta
270 nexts
= episodes_for_podcast(self
,
271 since
=episode
.released
+ timedelta(seconds
=1), limit
=1)
273 return next(iter(nexts
), None)
281 def get_podcast(self
):
285 def get_logo_url(self
, size
):
287 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
289 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
291 prefix
= CoverArt
.get_prefix(filename
)
293 return reverse('logo', args
=[size
, prefix
, filename
])
296 def subscriber_change(self
):
297 prev
= self
.prev_subscriber_count()
301 return self
.subscriber_count() / prev
304 def subscriber_count(self
):
305 if not self
.subscribers
:
307 return self
.subscribers
[-1].subscriber_count
310 def prev_subscriber_count(self
):
311 if len(self
.subscribers
) < 2:
313 return self
.subscribers
[-2].subscriber_count
317 @repeat_on_conflict()
318 def subscribe(self
, user
, device
):
319 from mygpo
.db
.couchdb
.podcast_state
import podcast_state_for_user_podcast
320 state
= podcast_state_for_user_podcast(user
, self
)
321 state
.subscribe(device
)
324 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
326 except Unauthorized
as ex
:
327 raise SubscriptionException(ex
)
330 @repeat_on_conflict()
331 def unsubscribe(self
, user
, device
):
332 from mygpo
.db
.couchdb
.podcast_state
import podcast_state_for_user_podcast
333 state
= podcast_state_for_user_podcast(user
, self
)
334 state
.unsubscribe(device
)
337 subscription_changed
.send(sender
=self
, user
=user
, device
=device
,
339 except Unauthorized
as ex
:
340 raise SubscriptionException(ex
)
343 def subscribe_targets(self
, user
):
345 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
346 devices/syncgroups on which the podcast is already subscribed
350 subscriptions_by_devices
= user
.get_subscriptions_by_device()
352 for group
in user
.get_grouped_devices():
356 dev
= group
.devices
[0]
358 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
359 targets
.append(group
.devices
)
362 for device
in group
.devices
:
363 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
364 targets
.append(device
)
370 def needs_update(self
):
371 """ Indicates if the object requires an updated from its feed """
372 return not self
.title
and not self
.outdated
376 return hash(self
.get_id())
381 return super(Podcast
, self
).__repr
__()
383 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
385 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
389 group
= getattr(self
, 'group', None)
390 if group
: # we are part of a PodcastGroup
391 group
= PodcastGroup
.get(group
)
392 podcasts
= list(group
.podcasts
)
394 if not self
in podcasts
:
395 # the podcast has not been added to the group correctly
396 group
.add_podcast(self
)
399 i
= podcasts
.index(self
)
401 group
.podcasts
= podcasts
404 i
= podcasts
.index(self
)
406 group
.podcasts
= podcasts
410 super(Podcast
, self
).save()
414 group
= getattr(self
, 'group', None)
416 group
= PodcastGroup
.get(group
)
417 podcasts
= list(group
.podcasts
)
420 i
= podcasts
.index(self
)
422 group
.podcasts
= podcasts
426 super(Podcast
, self
).delete()
429 def __eq__(self
, other
):
430 if not self
.get_id():
436 return self
.get_id() == other
.get_id()
440 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
441 title
= StringProperty()
442 podcasts
= SchemaListProperty(Podcast
)
448 def get_podcast_by_id(self
, id, current_id
=False):
449 for podcast
in self
.podcasts
:
450 if podcast
.get_id() == id:
453 if id in podcast
.merged_ids
:
455 raise MergedIdException(podcast
, podcast
.get_id())
460 def get_podcast_by_oldid(self
, oldid
):
461 for podcast
in list(self
.podcasts
):
462 if podcast
.oldid
== oldid
:
466 def get_podcast_by_url(self
, url
):
467 for podcast
in self
.podcasts
:
468 if url
in list(podcast
.urls
):
472 def subscriber_change(self
):
473 prev
= self
.prev_subscriber_count()
477 return self
.subscriber_count() / prev
480 def subscriber_count(self
):
481 return sum([p
.subscriber_count() for p
in self
.podcasts
])
484 def prev_subscriber_count(self
):
485 return sum([p
.prev_subscriber_count() for p
in self
.podcasts
])
488 def display_title(self
):
493 def needs_update(self
):
494 """ Indicates if the object requires an updated from its feed """
495 # A PodcastGroup has been manually created and therefore never
499 def get_podcast(self
):
500 # return podcast with most subscribers (bug 1390)
501 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
507 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
510 def logo_url(self
, value
):
511 self
.podcasts
[0].logo_url
= value
514 def get_logo_url(self
, size
):
516 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
518 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
520 prefix
= CoverArt
.get_prefix(filename
)
522 return reverse('logo', args
=[size
, prefix
, filename
])
525 def add_podcast(self
, podcast
, member_name
):
528 raise ValueError('group has to have an _id first')
531 raise ValueError('podcast needs to have an _id first')
534 podcast
.id = podcast
._id
537 podcast
.group
= self
._id
538 podcast
.group_member_name
= member_name
539 self
.podcasts
= sorted(self
.podcasts
+ [podcast
],
540 key
=Podcast
.subscriber_count
, reverse
=True)
546 return super(PodcastGroup
, self
).__repr
__()
548 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
550 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])
554 class SanitizingRule(Document
):
555 slug
= StringProperty()
556 applies_to
= StringListProperty()
557 search
= StringProperty()
558 replace
= StringProperty()
559 priority
= IntegerProperty()
560 description
= StringProperty()
564 return 'SanitizingRule %s' % self
._id