1 from __future__
import division
6 from datetime
import datetime
7 from dateutil
import parser
8 from random
import randint
, random
10 from couchdbkit
.ext
.django
.schema
import *
11 from restkit
.errors
import Unauthorized
13 from django
.conf
import settings
14 from django
.core
.urlresolvers
import reverse
16 from mygpo
.decorators
import repeat_on_conflict
17 from mygpo
import utils
18 from mygpo
.cache
import cache_result
19 from mygpo
.couch
import get_main_database
20 from mygpo
.core
.proxy
import DocumentABCMeta
21 from mygpo
.core
.slugs
import SlugMixin
22 from mygpo
.core
.oldid
import OldIdMixin
23 from mygpo
.web
.logo
import CoverArt
26 class SubscriptionException(Exception):
30 class MergedIdException(Exception):
31 """ raised when an object is accessed through one of its merged_ids """
33 def __init__(self
, obj
, current_id
):
35 self
.current_id
= current_id
38 class Episode(Document
, SlugMixin
, OldIdMixin
):
40 Represents an Episode. Can only be part of a Podcast
43 __metaclass__
= DocumentABCMeta
45 title
= StringProperty()
46 description
= StringProperty()
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()
69 return 'Episode %s' % self
._id
73 def get_short_title(self
, common_title
):
74 if not self
.title
or not common_title
:
77 title
= self
.title
.replace(common_title
, '').strip()
78 title
= re
.sub(r
'^[\W\d]+', '', title
)
82 def get_episode_number(self
, common_title
):
83 if not self
.title
or not common_title
:
86 title
= self
.title
.replace(common_title
, '').strip()
87 match
= re
.search(r
'^\W*(\d+)', title
)
91 return int(match
.group(1))
95 return set([self
._id
] + self
.merged_ids
)
100 return utils
.multi_request_view(cls
, 'episodes/by_podcast',
103 stale
= 'update_after',
106 def __eq__(self
, other
):
109 return self
._id
== other
._id
113 return hash(self
._id
)
117 return '<{cls} {title} ({id})>'.format(cls
=self
.__class
__.__name
__,
118 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
)
143 def for_podcast(cls
, id):
144 r
= cls
.view('podcasts/subscriber_data', key
=id, include_docs
=True)
148 data
= PodcastSubscriberData()
153 return 'PodcastSubscriberData for Podcast %s (%s)' % (self
.podcast
, self
._id
)
156 class Podcast(Document
, SlugMixin
, OldIdMixin
):
158 __metaclass__
= DocumentABCMeta
160 id = StringProperty()
161 title
= StringProperty()
162 urls
= StringListProperty()
163 description
= StringProperty()
164 link
= StringProperty()
165 last_update
= DateTimeProperty()
166 logo_url
= StringProperty()
167 author
= StringProperty()
168 merged_ids
= StringListProperty()
169 group
= StringProperty()
170 group_member_name
= StringProperty()
171 related_podcasts
= StringListProperty()
172 subscribers
= SchemaListProperty(SubscriberData
)
173 language
= StringProperty()
174 content_types
= StringListProperty()
175 tags
= DictProperty()
176 restrictions
= StringListProperty()
177 common_episode_title
= StringProperty()
178 new_location
= StringProperty()
179 latest_episode_timestamp
= DateTimeProperty()
180 episode_count
= IntegerProperty()
181 random_key
= FloatProperty(default
=random
)
185 def get_podcast_by_id(self
, id, current_id
=False):
186 if current_id
and id != self
.get_id():
187 raise MergedIdException(self
, self
.get_id())
192 get_podcast_by_oldid
= get_podcast_by_id
193 get_podcast_by_url
= get_podcast_by_id
197 return self
.id or self
._id
200 return set([self
.get_id()] + self
.merged_ids
)
203 def display_title(self
):
204 return self
.title
or self
.url
207 def group_with(self
, other
, grouptitle
, myname
, othername
):
209 if self
.group
and (self
.group
== other
.group
):
210 # they are already grouped
213 group1
= PodcastGroup
.get(self
.group
) if self
.group
else None
214 group2
= PodcastGroup
.get(other
.group
) if other
.group
else None
216 if group1
and group2
:
217 raise ValueError('both podcasts already are in different groups')
219 elif not (group1
or group2
):
220 group
= PodcastGroup(title
=grouptitle
)
222 group
.add_podcast(self
, myname
)
223 group
.add_podcast(other
, othername
)
227 group1
.add_podcast(other
, othername
)
231 group2
.add_podcast(self
, myname
)
236 def get_episode_count(self
, since
=None, until
={}, **kwargs
):
238 # use stored episode count for better performance
239 if getattr(self
, 'episode_count', None) is not None:
240 return self
.episode_count
242 from mygpo
.db
.couchdb
import episode_count_for_podcast
243 return episode_count_for_podcast(self
, since
, until
, **kwargs
)
246 def get_common_episode_title(self
, num_episodes
=100):
248 if self
.common_episode_title
:
249 return self
.common_episode_title
251 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
252 episodes
= episodes_for_podcast(self
, descending
=True, limit
=num_episodes
)
254 # We take all non-empty titles
255 titles
= filter(None, (e
.title
for e
in episodes
))
256 # get the longest common substring
257 common_title
= utils
.longest_substr(titles
)
259 # but consider only the part up to the first number. Otherwise we risk
260 # removing part of the number (eg if a feed contains episodes 100-199)
261 common_title
= re
.search(r
'^\D*', common_title
).group(0)
263 if len(common_title
.strip()) < 2:
269 @cache_result(timeout
=60*60)
270 def get_latest_episode(self
):
271 # since = 1 ==> has a timestamp
273 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
274 episodes
= episodes_for_podcast(self
, since
=1, descending
=True, limit
=1)
275 return next(iter(episodes
), None)
278 def get_episode_before(self
, episode
):
279 if not episode
.released
:
282 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
283 prevs
= episodes_for_podcast(self
, until
=episode
.released
,
284 descending
=True, limit
=1)
286 return next(iter(prevs
), None)
289 def get_episode_after(self
, episode
):
290 if not episode
.released
:
293 from mygpo
.db
.couchdb
.episode
import episodes_for_podcast
294 nexts
= episodes_for_podcast(self
, since
=episode
.released
, limit
=1)
296 return next(iter(nexts
), None)
304 def get_podcast(self
):
308 def get_logo_url(self
, size
):
310 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
312 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
314 prefix
= CoverArt
.get_prefix(filename
)
316 return reverse('logo', args
=[size
, prefix
, filename
])
319 def subscriber_change(self
):
320 prev
= self
.prev_subscriber_count()
324 return self
.subscriber_count() / prev
327 def subscriber_count(self
):
328 if not self
.subscribers
:
330 return self
.subscribers
[-1].subscriber_count
333 def prev_subscriber_count(self
):
334 if len(self
.subscribers
) < 2:
336 return self
.subscribers
[-2].subscriber_count
339 def get_all_subscriber_data(self
):
340 subdata
= PodcastSubscriberData
.for_podcast(self
.get_id())
341 return sorted(self
.subscribers
+ subdata
.subscribers
,
342 key
=lambda s
: s
.timestamp
)
345 @repeat_on_conflict()
346 def subscribe(self
, user
, device
):
347 from mygpo
.db
.couchdb
.podcast_state
import podcast_state_for_user_podcast
348 state
= podcast_state_for_user_podcast(user
, self
)
349 state
.subscribe(device
)
352 except Unauthorized
as ex
:
353 raise SubscriptionException(ex
)
356 @repeat_on_conflict()
357 def unsubscribe(self
, user
, device
):
358 from mygpo
.db
.couchdb
.podcast_state
import podcast_state_for_user_podcast
359 state
= podcast_state_for_user_podcast(user
, self
)
360 state
.unsubscribe(device
)
363 except Unauthorized
as ex
:
364 raise SubscriptionException(ex
)
367 def subscribe_targets(self
, user
):
369 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
370 devices/syncgroups on which the podcast is already subscribed
374 subscriptions_by_devices
= user
.get_subscriptions_by_device()
376 for group
in user
.get_grouped_devices():
380 dev
= group
.devices
[0]
382 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
383 targets
.append(group
.devices
)
386 for device
in group
.devices
:
387 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
388 targets
.append(device
)
394 return hash(self
.get_id())
399 return super(Podcast
, self
).__repr
__()
401 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
403 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
407 group
= getattr(self
, 'group', None)
408 if group
: #we are part of a PodcastGroup
409 group
= PodcastGroup
.get(group
)
410 podcasts
= list(group
.podcasts
)
412 if not self
in podcasts
:
413 # the podcast has not been added to the group correctly
414 group
.add_podcast(self
)
417 i
= podcasts
.index(self
)
419 group
.podcasts
= podcasts
422 i
= podcasts
.index(self
)
424 group
.podcasts
= podcasts
428 super(Podcast
, self
).save()
432 group
= getattr(self
, 'group', None)
434 group
= PodcastGroup
.get(group
)
435 podcasts
= list(group
.podcasts
)
438 i
= podcasts
.index(self
)
440 group
.podcasts
= podcasts
444 super(Podcast
, self
).delete()
447 def __eq__(self
, other
):
448 if not self
.get_id():
454 return self
.get_id() == other
.get_id()
458 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
459 title
= StringProperty()
460 podcasts
= SchemaListProperty(Podcast
)
467 def for_slug_id(cls
, slug_id
):
468 """ Returns the Podcast for either an CouchDB-ID for a Slug """
470 if utils
.is_couchdb_id(slug_id
):
471 return cls
.get(slug_id
)
474 return cls
.for_slug(slug_id
)
477 def get_podcast_by_id(self
, id, current_id
=False):
478 for podcast
in self
.podcasts
:
479 if podcast
.get_id() == id:
482 if id in podcast
.merged_ids
:
484 raise MergedIdException(podcast
, podcast
.get_id())
489 def get_podcast_by_oldid(self
, oldid
):
490 for podcast
in list(self
.podcasts
):
491 if podcast
.oldid
== oldid
:
495 def get_podcast_by_url(self
, url
):
496 for podcast
in self
.podcasts
:
497 if url
in list(podcast
.urls
):
501 def subscriber_change(self
):
502 prev
= self
.prev_subscriber_count()
506 return self
.subscriber_count() / prev
509 def subscriber_count(self
):
510 return sum([p
.subscriber_count() for p
in self
.podcasts
])
513 def prev_subscriber_count(self
):
514 return sum([p
.prev_subscriber_count() for p
in self
.podcasts
])
517 def display_title(self
):
521 def get_podcast(self
):
522 # return podcast with most subscribers (bug 1390)
523 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
529 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
532 def get_logo_url(self
, size
):
534 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
536 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
538 prefix
= CoverArt
.get_prefix(filename
)
540 return reverse('logo', args
=[size
, prefix
, filename
])
543 def add_podcast(self
, podcast
, member_name
):
546 raise ValueError('group has to have an _id first')
549 raise ValueError('podcast needs to have an _id first')
552 podcast
.id = podcast
._id
555 podcast
.group
= self
._id
556 podcast
.group_member_name
= member_name
557 self
.podcasts
= sorted(self
.podcasts
+ [podcast
],
558 key
=Podcast
.subscriber_count
, reverse
=True)
564 return super(PodcastGroup
, self
).__repr
__()
566 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
568 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])
571 class SanitizingRuleStub(object):
574 class SanitizingRule(Document
):
575 slug
= StringProperty()
576 applies_to
= StringListProperty()
577 search
= StringProperty()
578 replace
= StringProperty()
579 priority
= IntegerProperty()
580 description
= StringProperty()
584 def for_obj_type(cls
, obj_type
):
585 r
= cls
.view('sanitizing_rules/by_target', include_docs
=True,
586 startkey
=[obj_type
, None], endkey
=[obj_type
, {}])
589 obj
= SanitizingRuleStub()
591 obj
.applies_to
= list(rule
.applies_to
)
592 obj
.search
= rule
.search
593 obj
.replace
= rule
.replace
594 obj
.priority
= rule
.priority
595 obj
.description
= rule
.description
600 def for_slug(cls
, slug
):
601 r
= cls
.view('sanitizing_rules/by_slug', include_docs
=True,
603 return r
.one() if r
else None
607 return 'SanitizingRule %s' % self
._id