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
19 from mygpo
.users
.tasks
import sync_user
21 # make sure this code is executed at startup
22 from mygpo
.core
.signals
import *
25 class SubscriptionException(Exception):
29 class MergedIdException(Exception):
30 """ raised when an object is accessed through one of its merged_ids """
32 def __init__(self
, obj
, current_id
):
34 self
.current_id
= current_id
37 class Episode(Document
, SlugMixin
, OldIdMixin
):
39 Represents an Episode. Can only be part of a Podcast
42 __metaclass__
= DocumentABCMeta
44 title
= StringProperty()
45 guid
= StringProperty()
46 description
= StringProperty(default
="")
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()
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 link
= StringProperty()
156 last_update
= DateTimeProperty()
157 logo_url
= StringProperty()
158 author
= StringProperty()
159 merged_ids
= StringListProperty()
160 group
= StringProperty()
161 group_member_name
= StringProperty()
162 related_podcasts
= StringListProperty()
163 subscribers
= SchemaListProperty(SubscriberData
)
164 language
= StringProperty()
165 content_types
= StringListProperty()
166 tags
= DictProperty()
167 restrictions
= StringListProperty()
168 common_episode_title
= StringProperty()
169 new_location
= StringProperty()
170 latest_episode_timestamp
= DateTimeProperty()
171 episode_count
= IntegerProperty()
172 random_key
= FloatProperty(default
=random
)
173 flattr_url
= StringProperty()
174 outdated
= BooleanProperty(default
=False)
175 created_timestamp
= IntegerProperty()
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 sync_user
.delay(user
)
325 except Unauthorized
as ex
:
326 raise SubscriptionException(ex
)
329 @repeat_on_conflict()
330 def unsubscribe(self
, user
, device
):
331 from mygpo
.db
.couchdb
.podcast_state
import podcast_state_for_user_podcast
332 state
= podcast_state_for_user_podcast(user
, self
)
333 state
.unsubscribe(device
)
336 sync_user
.delay(user
)
337 except Unauthorized
as ex
:
338 raise SubscriptionException(ex
)
341 def subscribe_targets(self
, user
):
343 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
344 devices/syncgroups on which the podcast is already subscribed
348 subscriptions_by_devices
= user
.get_subscriptions_by_device()
350 for group
in user
.get_grouped_devices():
354 dev
= group
.devices
[0]
356 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
357 targets
.append(group
.devices
)
360 for device
in group
.devices
:
361 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
362 targets
.append(device
)
368 def needs_update(self
):
369 """ Indicates if the object requires an updated from its feed """
370 return not self
.title
and not self
.outdated
374 return hash(self
.get_id())
379 return super(Podcast
, self
).__repr
__()
381 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
383 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
387 group
= getattr(self
, 'group', None)
388 if group
: # we are part of a PodcastGroup
389 group
= PodcastGroup
.get(group
)
390 podcasts
= list(group
.podcasts
)
392 if not self
in podcasts
:
393 # the podcast has not been added to the group correctly
394 group
.add_podcast(self
)
397 i
= podcasts
.index(self
)
399 group
.podcasts
= podcasts
402 i
= podcasts
.index(self
)
404 group
.podcasts
= podcasts
408 super(Podcast
, self
).save()
412 group
= getattr(self
, 'group', None)
414 group
= PodcastGroup
.get(group
)
415 podcasts
= list(group
.podcasts
)
418 i
= podcasts
.index(self
)
420 group
.podcasts
= podcasts
424 super(Podcast
, self
).delete()
427 def __eq__(self
, other
):
428 if not self
.get_id():
434 return self
.get_id() == other
.get_id()
438 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
439 title
= StringProperty()
440 podcasts
= SchemaListProperty(Podcast
)
446 def get_podcast_by_id(self
, id, current_id
=False):
447 for podcast
in self
.podcasts
:
448 if podcast
.get_id() == id:
451 if id in podcast
.merged_ids
:
453 raise MergedIdException(podcast
, podcast
.get_id())
458 def get_podcast_by_oldid(self
, oldid
):
459 for podcast
in list(self
.podcasts
):
460 if podcast
.oldid
== oldid
:
464 def get_podcast_by_url(self
, url
):
465 for podcast
in self
.podcasts
:
466 if url
in list(podcast
.urls
):
470 def subscriber_change(self
):
471 prev
= self
.prev_subscriber_count()
475 return self
.subscriber_count() / prev
478 def subscriber_count(self
):
479 return sum([p
.subscriber_count() for p
in self
.podcasts
])
482 def prev_subscriber_count(self
):
483 return sum([p
.prev_subscriber_count() for p
in self
.podcasts
])
486 def display_title(self
):
491 def needs_update(self
):
492 """ Indicates if the object requires an updated from its feed """
493 # A PodcastGroup has been manually created and therefore never
497 def get_podcast(self
):
498 # return podcast with most subscribers (bug 1390)
499 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
505 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
508 def logo_url(self
, value
):
509 self
.podcasts
[0].logo_url
= value
512 def get_logo_url(self
, size
):
514 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
516 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
518 prefix
= CoverArt
.get_prefix(filename
)
520 return reverse('logo', args
=[size
, prefix
, filename
])
523 def add_podcast(self
, podcast
, member_name
):
526 raise ValueError('group has to have an _id first')
529 raise ValueError('podcast needs to have an _id first')
532 podcast
.id = podcast
._id
535 podcast
.group
= self
._id
536 podcast
.group_member_name
= member_name
537 self
.podcasts
= sorted(self
.podcasts
+ [podcast
],
538 key
=Podcast
.subscriber_count
, reverse
=True)
544 return super(PodcastGroup
, self
).__repr
__()
546 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
548 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])
552 class SanitizingRule(Document
):
553 slug
= StringProperty()
554 applies_to
= StringListProperty()
555 search
= StringProperty()
556 replace
= StringProperty()
557 priority
= IntegerProperty()
558 description
= StringProperty()
562 return 'SanitizingRule %s' % self
._id