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
.couch
import get_main_database
19 from mygpo
.core
.proxy
import DocumentABCMeta
20 from mygpo
.core
.slugs
import SlugMixin
21 from mygpo
.core
.oldid
import OldIdMixin
22 from mygpo
.web
.logo
import CoverArt
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 description
= StringProperty()
46 link
= StringProperty()
47 released
= DateTimeProperty()
48 author
= StringProperty()
49 duration
= IntegerProperty()
50 filesize
= IntegerProperty()
51 language
= StringProperty()
52 last_update
= DateTimeProperty()
53 outdated
= BooleanProperty(default
=False)
54 mimetypes
= StringListProperty()
55 merged_ids
= StringListProperty()
56 urls
= StringListProperty()
57 podcast
= StringProperty(required
=True)
58 listeners
= IntegerProperty()
59 content_types
= StringListProperty()
63 def get(cls
, id, current_id
=False):
64 r
= cls
.view('episodes/by_id',
73 if current_id
and obj
._id
!= id:
74 raise MergedIdException(obj
, obj
._id
)
80 def get_multi(cls
, ids
):
81 return cls
.view('episodes/by_id',
88 def for_oldid(self
, oldid
):
90 r
= Episode
.view('episodes/by_oldid', key
=oldid
, limit
=1, include_docs
=True)
91 return r
.one() if r
else None
95 def for_slug(cls
, podcast_id
, slug
):
96 r
= cls
.view('episodes/by_slug',
97 key
= [podcast_id
, slug
],
100 return r
.first() if r
else None
104 def for_podcast_url(cls
, podcast_url
, episode_url
, create
=False):
105 podcast
= Podcast
.for_url(podcast_url
, create
=create
)
107 if not podcast
: # podcast does not exist and should not be created
110 return cls
.for_podcast_id_url(podcast
.get_id(), episode_url
, create
)
114 def for_podcast_id_url(cls
, podcast_id
, episode_url
, create
=False):
115 r
= cls
.view('episodes/by_podcast_url',
116 key
= [podcast_id
, episode_url
],
126 episode
.podcast
= podcast_id
127 episode
.urls
= [episode_url
]
135 def for_slug_id(cls
, p_slug_id
, e_slug_id
):
136 """ Returns the Episode for Podcast Slug/Id and Episode Slug/Id """
138 # The Episode-Id is unique, so take that
139 if utils
.is_couchdb_id(e_slug_id
):
140 return cls
.get(e_slug_id
)
142 # If we search using a slug, we need the Podcast's Id
143 if utils
.is_couchdb_id(p_slug_id
):
146 podcast
= Podcast
.for_slug_id(p_slug_id
)
151 p_id
= podcast
.get_id()
153 return cls
.for_slug(p_id
, e_slug_id
)
156 def get_user_state(self
, user
):
157 from mygpo
.users
.models
import EpisodeUserState
158 return EpisodeUserState
.for_user_episode(user
, self
)
161 def get_all_states(self
):
162 from mygpo
.users
.models
import EpisodeUserState
163 r
= EpisodeUserState
.view('episode_states/by_podcast_episode',
164 startkey
= [self
.podcast
, self
._id
, None],
165 endkey
= [self
.podcast
, self
._id
, {}],
175 return 'Episode %s' % self
._id
178 def listener_count(self
, start
=None, end
={}):
179 """ returns the number of users that have listened to this episode """
181 from mygpo
.users
.models
import EpisodeUserState
182 r
= EpisodeUserState
.view('listeners/by_episode',
183 startkey
= [self
._id
, start
],
184 endkey
= [self
._id
, end
],
189 return r
.first()['value'] if r
else 0
192 def listener_count_timespan(self
, start
=None, end
={}):
193 """ returns (date, listener-count) tuples for all days w/ listeners """
195 if isinstance(start
, datetime
):
196 start
= start
.isoformat()
198 if isinstance(end
, datetime
):
199 end
= end
.isoformat()
201 from mygpo
.users
.models
import EpisodeUserState
202 r
= EpisodeUserState
.view('listeners/by_episode',
203 startkey
= [self
._id
, start
],
204 endkey
= [self
._id
, end
],
211 date
= parser
.parse(res
['key'][1]).date()
212 listeners
= res
['value']
213 yield (date
, listeners
)
216 def get_short_title(self
, common_title
):
217 if not self
.title
or not common_title
:
220 title
= self
.title
.replace(common_title
, '').strip()
221 title
= re
.sub(r
'^[\W\d]+', '', title
)
225 def get_episode_number(self
, common_title
):
226 if not self
.title
or not common_title
:
229 title
= self
.title
.replace(common_title
, '').strip()
230 match
= re
.search(r
'^\W*(\d+)', title
)
234 return int(match
.group(1))
238 return set([self
._id
] + self
.merged_ids
)
243 r
= cls
.view('episodes/by_podcast',
245 stale
= 'update_after',
247 return r
.one()['value'] if r
else 0
252 return utils
.multi_request_view(cls
, 'episodes/by_podcast',
255 stale
= 'update_after',
258 def __eq__(self
, other
):
261 return self
._id
== other
._id
265 return hash(self
._id
)
269 return '<{cls} {title} ({id})>'.format(cls
=self
.__class
__.__name
__,
270 title
=self
.title
, id=self
._id
)
275 class SubscriberData(DocumentSchema
):
276 timestamp
= DateTimeProperty()
277 subscriber_count
= IntegerProperty()
279 def __eq__(self
, other
):
280 if not isinstance(other
, SubscriberData
):
283 return (self
.timestamp
== other
.timestamp
) and \
284 (self
.subscriber_count
== other
.subscriber_count
)
287 return hash(frozenset([self
.timestamp
, self
.subscriber_count
]))
290 class PodcastSubscriberData(Document
):
291 podcast
= StringProperty()
292 subscribers
= SchemaListProperty(SubscriberData
)
295 def for_podcast(cls
, id):
296 r
= cls
.view('podcasts/subscriber_data', key
=id, include_docs
=True)
300 data
= PodcastSubscriberData()
305 return 'PodcastSubscriberData for Podcast %s (%s)' % (self
.podcast
, self
._id
)
308 class Podcast(Document
, SlugMixin
, OldIdMixin
):
310 __metaclass__
= DocumentABCMeta
312 id = StringProperty()
313 title
= StringProperty()
314 urls
= StringListProperty()
315 description
= StringProperty()
316 link
= StringProperty()
317 last_update
= DateTimeProperty()
318 logo_url
= StringProperty()
319 author
= StringProperty()
320 merged_ids
= StringListProperty()
321 group
= StringProperty()
322 group_member_name
= StringProperty()
323 related_podcasts
= StringListProperty()
324 subscribers
= SchemaListProperty(SubscriberData
)
325 language
= StringProperty()
326 content_types
= StringListProperty()
327 tags
= DictProperty()
328 restrictions
= StringListProperty()
329 common_episode_title
= StringProperty()
330 new_location
= StringProperty()
331 latest_episode_timestamp
= DateTimeProperty()
332 episode_count
= IntegerProperty()
333 random_key
= FloatProperty(default
=random
)
337 def get(cls
, id, current_id
=False):
338 r
= cls
.view('podcasts/by_id',
340 classes
=[Podcast
, PodcastGroup
],
347 podcast_group
= r
.first()
348 return podcast_group
.get_podcast_by_id(id, current_id
)
352 def for_slug(cls
, slug
):
353 r
= cls
.view('podcasts/by_slug',
354 startkey
= [slug
, None],
365 if doc
['doc_type'] == 'Podcast':
366 return Podcast
.wrap(doc
)
369 pg
= PodcastGroup
.wrap(doc
)
370 return pg
.get_podcast_by_id(pid
)
374 def for_slug_id(cls
, slug_id
):
375 """ Returns the Podcast for either an CouchDB-ID for a Slug """
377 if utils
.is_couchdb_id(slug_id
):
378 return cls
.get(slug_id
)
380 return cls
.for_slug(slug_id
)
384 def get_multi(cls
, ids
):
385 r
= cls
.view('podcasts/by_id',
392 if res
['doc']['doc_type'] == 'Podcast':
393 yield Podcast
.wrap(res
['doc'])
395 pg
= PodcastGroup
.wrap(res
['doc'])
397 yield pg
.get_podcast_by_id(id)
401 def for_oldid(cls
, oldid
):
403 r
= cls
.view('podcasts/by_oldid',
405 classes
=[Podcast
, PodcastGroup
],
412 podcast_group
= r
.first()
413 return podcast_group
.get_podcast_by_oldid(oldid
)
417 def for_url(cls
, url
, create
=False):
418 r
= cls
.view('podcasts/by_url',
420 classes
=[Podcast
, PodcastGroup
],
425 podcast_group
= r
.first()
426 return podcast_group
.get_podcast_by_url(url
)
438 def random(cls
, language
='', chunk_size
=5):
439 """ Returns an iterator of random podcasts
441 optionaly a language code can be specified. If given the podcasts will
442 be restricted to this language. chunk_size determines how many podcasts
443 will be fetched at once """
447 res
= cls
.view('podcasts/random',
448 startkey
= [language
, rnd
],
460 if obj
['doc_type'] == 'Podcast':
461 yield Podcast
.wrap(obj
)
463 elif obj
['doc_type'] == 'PodcastGroup':
464 yield PodcastGroup
.wrap(obj
)
468 def by_last_update(cls
):
469 res
= cls
.view('podcasts/by_last_update',
471 stale
= 'update_after',
477 if obj
['doc_type'] == 'Podcast':
478 yield Podcast
.wrap(obj
)
482 pg
= PodcastGroup
.wrap(obj
)
483 podcast
= pg
.get_podcast_by_id(pid
)
488 def for_language(cls
, language
, **kwargs
):
490 res
= cls
.view('podcasts/by_language',
491 startkey
= [language
, None],
492 endkey
= [language
, {}],
495 stale
= 'update_after',
502 if obj
['doc_type'] == 'Podcast':
503 yield Podcast
.wrap(obj
)
507 pg
= PodcastGroup
.wrap(obj
)
508 podcast
= pg
.get_podcast_by_id(pid
)
514 # TODO: fix number calculation
515 r
= cls
.view('podcasts/by_id',
517 stale
= 'update_after',
522 def get_podcast_by_id(self
, id, current_id
=False):
523 if current_id
and id != self
.get_id():
524 raise MergedIdException(self
, self
.get_id())
529 get_podcast_by_oldid
= get_podcast_by_id
530 get_podcast_by_url
= get_podcast_by_id
534 return self
.id or self
._id
537 return set([self
.get_id()] + self
.merged_ids
)
540 def display_title(self
):
541 return self
.title
or self
.url
544 def group_with(self
, other
, grouptitle
, myname
, othername
):
546 if self
.group
and (self
.group
== other
.group
):
547 # they are already grouped
550 group1
= PodcastGroup
.get(self
.group
) if self
.group
else None
551 group2
= PodcastGroup
.get(other
.group
) if other
.group
else None
553 if group1
and group2
:
554 raise ValueError('both podcasts already are in different groups')
556 elif not (group1
or group2
):
557 group
= PodcastGroup(title
=grouptitle
)
559 group
.add_podcast(self
, myname
)
560 group
.add_podcast(other
, othername
)
564 group1
.add_podcast(other
, othername
)
568 group2
.add_podcast(self
, myname
)
573 def get_episodes(self
, since
=None, until
={}, **kwargs
):
575 if kwargs
.get('descending', False):
576 since
, until
= until
, since
578 if isinstance(since
, datetime
):
579 since
= since
.isoformat()
581 if isinstance(until
, datetime
):
582 until
= until
.isoformat()
584 res
= Episode
.view('episodes/by_podcast',
585 startkey
= [self
.get_id(), since
],
586 endkey
= [self
.get_id(), until
],
595 def get_episode_count(self
, since
=None, until
={}, **kwargs
):
597 # use stored episode count for better performance
598 if getattr(self
, 'episode_count', None) is not None:
599 return self
.episode_count
601 if kwargs
.get('descending', False):
602 since
, until
= until
, since
604 if isinstance(since
, datetime
):
605 since
= since
.isoformat()
607 if isinstance(until
, datetime
):
608 until
= until
.isoformat()
610 res
= Episode
.view('episodes/by_podcast',
611 startkey
= [self
.get_id(), since
],
612 endkey
= [self
.get_id(), until
],
618 return res
.one()['value']
621 def get_common_episode_title(self
, num_episodes
=100):
623 if self
.common_episode_title
:
624 return self
.common_episode_title
626 episodes
= self
.get_episodes(descending
=True, limit
=num_episodes
)
628 # We take all non-empty titles
629 titles
= filter(None, (e
.title
for e
in episodes
))
630 # get the longest common substring
631 common_title
= utils
.longest_substr(titles
)
633 # but consider only the part up to the first number. Otherwise we risk
634 # removing part of the number (eg if a feed contains episodes 100-199)
635 common_title
= re
.search(r
'^\D*', common_title
).group(0)
637 if len(common_title
.strip()) < 2:
643 def get_latest_episode(self
):
644 # since = 1 ==> has a timestamp
645 episodes
= self
.get_episodes(since
=1, descending
=True, limit
=1)
646 return next(episodes
, None)
649 def get_episode_before(self
, episode
):
650 if not episode
.released
:
653 prevs
= self
.get_episodes(until
=episode
.released
, descending
=True,
658 except StopIteration:
662 def get_episode_after(self
, episode
):
663 if not episode
.released
:
666 nexts
= self
.get_episodes(since
=episode
.released
, limit
=1)
670 except StopIteration:
674 def get_episode_for_slug(self
, slug
):
675 return Episode
.for_slug(self
.get_id(), slug
)
683 def get_podcast(self
):
687 def get_logo_url(self
, size
):
689 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
691 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
693 prefix
= CoverArt
.get_prefix(filename
)
695 return reverse('logo', args
=[size
, prefix
, filename
])
698 def subscriber_change(self
):
699 prev
= self
.prev_subscriber_count()
703 return self
.subscriber_count() / prev
706 def subscriber_count(self
):
707 if not self
.subscribers
:
709 return self
.subscribers
[-1].subscriber_count
712 def prev_subscriber_count(self
):
713 if len(self
.subscribers
) < 2:
715 return self
.subscribers
[-2].subscriber_count
718 def get_user_state(self
, user
):
719 from mygpo
.users
.models
import PodcastUserState
720 return PodcastUserState
.for_user_podcast(user
, self
)
723 def get_all_states(self
):
724 from mygpo
.users
.models
import PodcastUserState
725 return PodcastUserState
.view('podcast_states/by_podcast',
726 startkey
= [self
.get_id(), None],
727 endkey
= [self
.get_id(), {}],
730 def get_all_subscriber_data(self
):
731 subdata
= PodcastSubscriberData
.for_podcast(self
.get_id())
732 return sorted(self
.subscribers
+ subdata
.subscribers
,
733 key
=lambda s
: s
.timestamp
)
736 @repeat_on_conflict()
737 def subscribe(self
, user
, device
):
738 state
= self
.get_user_state(user
)
739 state
.subscribe(device
)
742 except Unauthorized
as ex
:
743 raise SubscriptionException(ex
)
746 @repeat_on_conflict()
747 def unsubscribe(self
, user
, device
):
748 state
= self
.get_user_state(user
)
749 state
.unsubscribe(device
)
752 except Unauthorized
as ex
:
753 raise SubscriptionException(ex
)
756 def subscribe_targets(self
, user
):
758 returns all Devices and SyncGroups on which this podcast can be subsrbied. This excludes all
759 devices/syncgroups on which the podcast is already subscribed
763 subscriptions_by_devices
= user
.get_subscriptions_by_device()
765 for group
in user
.get_grouped_devices():
769 dev
= group
.devices
[0]
771 if not self
.get_id() in subscriptions_by_devices
[dev
.id]:
772 targets
.append(group
.devices
)
775 for device
in group
.devices
:
776 if not self
.get_id() in subscriptions_by_devices
[device
.id]:
777 targets
.append(device
)
782 def listener_count(self
):
783 """ returns the number of users that have listened to this podcast """
785 from mygpo
.users
.models
import EpisodeUserState
786 r
= EpisodeUserState
.view('listeners/by_podcast',
787 startkey
= [self
.get_id(), None],
788 endkey
= [self
.get_id(), {}],
793 return r
.first()['value'] if r
else 0
796 def listener_count_timespan(self
, start
=None, end
={}):
797 """ returns (date, listener-count) tuples for all days w/ listeners """
799 if isinstance(start
, datetime
):
800 start
= start
.isoformat()
802 if isinstance(end
, datetime
):
803 end
= end
.isoformat()
805 from mygpo
.users
.models
import EpisodeUserState
806 r
= EpisodeUserState
.view('listeners/by_podcast',
807 startkey
= [self
.get_id(), start
],
808 endkey
= [self
.get_id(), end
],
815 date
= parser
.parse(res
['key'][1]).date()
816 listeners
= res
['value']
817 yield (date
, listeners
)
820 def episode_listener_counts(self
):
821 """ (Episode-Id, listener-count) tuples for episodes w/ listeners """
823 from mygpo
.users
.models
import EpisodeUserState
824 r
= EpisodeUserState
.view('listeners/by_podcast_episode',
825 startkey
= [self
.get_id(), None, None],
826 endkey
= [self
.get_id(), {}, {}],
833 episode
= res
['key'][1]
834 listeners
= res
['value']
835 yield (episode
, listeners
)
838 def get_episode_states(self
, user_id
):
839 """ Returns the latest episode actions for the podcast's episodes """
841 from mygpo
.users
.models
import EpisodeUserState
842 db
= get_main_database()
844 res
= db
.view('episode_states/by_user_podcast',
845 startkey
= [user_id
, self
.get_id(), None],
846 endkey
= [user_id
, self
.get_id(), {}],
855 return hash(self
.get_id())
860 return super(Podcast
, self
).__repr
__()
862 return '%s %s (%s)' % (self
.__class
__.__name
__, self
.get_id(), self
.oldid
)
864 return '%s %s' % (self
.__class
__.__name
__, self
.get_id())
868 group
= getattr(self
, 'group', None)
869 if group
: #we are part of a PodcastGroup
870 group
= PodcastGroup
.get(group
)
871 podcasts
= list(group
.podcasts
)
873 if not self
in podcasts
:
874 # the podcast has not been added to the group correctly
875 group
.add_podcast(self
)
878 i
= podcasts
.index(self
)
880 group
.podcasts
= podcasts
883 i
= podcasts
.index(self
)
885 group
.podcasts
= podcasts
889 super(Podcast
, self
).save()
893 group
= getattr(self
, 'group', None)
895 group
= PodcastGroup
.get(group
)
896 podcasts
= list(group
.podcasts
)
899 i
= podcasts
.index(self
)
901 group
.podcasts
= podcasts
905 super(Podcast
, self
).delete()
908 def all_podcasts_groups(cls
):
909 return cls
.view('podcasts/podcasts_groups', include_docs
=True,
910 classes
=[Podcast
, PodcastGroup
]).iterator()
913 def __eq__(self
, other
):
914 if not self
.get_id():
920 return self
.get_id() == other
.get_id()
924 def all_podcasts(cls
):
925 res
= utils
.multi_request_view(cls
, 'podcasts/by_id',
928 stale
= 'update_after',
933 if obj
['doc_type'] == 'Podcast':
934 yield Podcast
.wrap(obj
)
937 pg
= PodcastGroup
.wrap(obj
)
938 podcast
= pg
.get_podcast_by_id(pid
)
943 class PodcastGroup(Document
, SlugMixin
, OldIdMixin
):
944 title
= StringProperty()
945 podcasts
= SchemaListProperty(Podcast
)
951 def for_oldid(cls
, oldid
):
953 r
= cls
.view('podcasts/groups_by_oldid', \
954 key
=oldid
, limit
=1, include_docs
=True)
955 return r
.first() if r
else None
959 def for_slug_id(cls
, slug_id
):
960 """ Returns the Podcast for either an CouchDB-ID for a Slug """
962 if utils
.is_couchdb_id(slug_id
):
963 return cls
.get(slug_id
)
966 return cls
.for_slug(slug_id
)
969 def get_podcast_by_id(self
, id, current_id
=False):
970 for podcast
in self
.podcasts
:
971 if podcast
.get_id() == id:
974 if id in podcast
.merged_ids
:
976 raise MergedIdException(podcast
, podcast
.get_id())
981 def get_podcast_by_oldid(self
, oldid
):
982 for podcast
in list(self
.podcasts
):
983 if podcast
.oldid
== oldid
:
987 def get_podcast_by_url(self
, url
):
988 for podcast
in self
.podcasts
:
989 if url
in list(podcast
.urls
):
993 def subscriber_change(self
):
994 prev
= self
.prev_subscriber_count()
998 return self
.subscriber_count() / prev
1001 def subscriber_count(self
):
1002 return sum([p
.subscriber_count() for p
in self
.podcasts
])
1005 def prev_subscriber_count(self
):
1006 return sum([p
.prev_subscriber_count() for p
in self
.podcasts
])
1009 def display_title(self
):
1013 def get_podcast(self
):
1014 # return podcast with most subscribers (bug 1390)
1015 return sorted(self
.podcasts
, key
=Podcast
.subscriber_count
,
1021 return utils
.first(p
.logo_url
for p
in self
.podcasts
)
1024 def get_logo_url(self
, size
):
1026 filename
= hashlib
.sha1(self
.logo_url
).hexdigest()
1028 filename
= 'podcast-%d.png' % (hash(self
.title
) % 5, )
1030 prefix
= CoverArt
.get_prefix(filename
)
1032 return reverse('logo', args
=[size
, prefix
, filename
])
1035 def add_podcast(self
, podcast
, member_name
):
1038 raise ValueError('group has to have an _id first')
1041 raise ValueError('podcast needs to have an _id first')
1044 podcast
.id = podcast
._id
1047 podcast
.group
= self
._id
1048 podcast
.group_member_name
= member_name
1049 self
.podcasts
= sorted(self
.podcasts
+ [podcast
],
1050 key
=Podcast
.subscriber_count
, reverse
=True)
1056 return super(PodcastGroup
, self
).__repr
__()
1058 return '%s %s (%s)' % (self
.__class
__.__name
__, self
._id
[:10], self
.oldid
)
1060 return '%s %s' % (self
.__class
__.__name
__, self
._id
[:10])
1063 class SanitizingRuleStub(object):
1066 class SanitizingRule(Document
):
1067 slug
= StringProperty()
1068 applies_to
= StringListProperty()
1069 search
= StringProperty()
1070 replace
= StringProperty()
1071 priority
= IntegerProperty()
1072 description
= StringProperty()
1076 def for_obj_type(cls
, obj_type
):
1077 r
= cls
.view('sanitizing_rules/by_target', include_docs
=True,
1078 startkey
=[obj_type
, None], endkey
=[obj_type
, {}])
1081 obj
= SanitizingRuleStub()
1082 obj
.slug
= rule
.slug
1083 obj
.applies_to
= list(rule
.applies_to
)
1084 obj
.search
= rule
.search
1085 obj
.replace
= rule
.replace
1086 obj
.priority
= rule
.priority
1087 obj
.description
= rule
.description
1092 def for_slug(cls
, slug
):
1093 r
= cls
.view('sanitizing_rules/by_slug', include_docs
=True,
1095 return r
.one() if r
else None
1099 return 'SanitizingRule %s' % self
._id