~ubuntu-branches/ubuntu/vivid/gpodder/vivid-proposed

« back to all changes in this revision

Viewing changes to src/gpodder/model.py

  • Committer: Bazaar Package Importer
  • Author(s): tony mancill
  • Date: 2010-12-05 17:08:02 UTC
  • mfrom: (5.3.2 experimental) (5.2.10 sid)
  • Revision ID: james.westby@ubuntu.com-20101205170802-qbsq7r331j21np1i
Tags: 2.10-1
* New upstream release
* Upload to unstable.

Show diffs side-by-side

added added

removed removed

Lines of Context:
27
27
from gpodder import util
28
28
from gpodder import feedcore
29
29
from gpodder import youtube
30
 
from gpodder import corestats
31
30
from gpodder import gstreamer
32
31
 
33
32
from gpodder.liblogger import log
132
131
 
133
132
    @classmethod
134
133
    def load(cls, db, url, create=True, authentication_tokens=None,\
135
 
            max_episodes=0, download_dir=None, allow_empty_feeds=False):
 
134
            max_episodes=0, download_dir=None, allow_empty_feeds=False, \
 
135
            mimetype_prefs=''):
136
136
        if isinstance(url, unicode):
137
137
            url = url.encode('utf-8')
138
138
 
146
146
                tmp.username = authentication_tokens[0]
147
147
                tmp.password = authentication_tokens[1]
148
148
 
149
 
            tmp.update(max_episodes)
 
149
            tmp.update(max_episodes, mimetype_prefs)
150
150
            tmp.save()
151
151
            db.force_last_new(tmp)
152
152
            # Subscribing to empty feeds should yield an error (except if
183
183
 
184
184
        self.db.purge(max_episodes, self.id)
185
185
 
186
 
    def _consume_updated_feed(self, feed, max_episodes=0):
 
186
    def _consume_updated_feed(self, feed, max_episodes=0, mimetype_prefs=''):
187
187
        self.parse_error = feed.get('bozo_exception', None)
188
188
 
189
 
        self.title = feed.feed.get('title', self.url)
 
189
        # Replace multi-space and newlines with single space (Maemo bug 11173)
 
190
        self.title = re.sub('\s+', ' ', feed.feed.get('title', self.url))
 
191
 
190
192
        self.link = feed.feed.get('link', self.link)
191
193
        self.description = feed.feed.get('subtitle', self.description)
192
194
        # Start YouTube-specific title FIX
217
219
 
218
220
        # We can limit the maximum number of entries that gPodder will parse
219
221
        if max_episodes > 0 and len(feed.entries) > max_episodes:
220
 
            entries = feed.entries[:max_episodes]
 
222
            # We have to sort the entries in descending chronological order,
 
223
            # because if the feed lists items in ascending order and has >
 
224
            # max_episodes old episodes, new episodes will not be shown.
 
225
            # See also: gPodder Bug 1186
 
226
            try:
 
227
                entries = sorted(feed.entries, \
 
228
                        key=lambda x: x.get('updated_parsed', (0,)*9), \
 
229
                        reverse=True)[:max_episodes]
 
230
            except Exception, e:
 
231
                log('Could not sort episodes: %s', e, sender=self, traceback=True)
 
232
                entries = feed.entries[:max_episodes]
221
233
        else:
222
234
            entries = feed.entries
223
235
 
233
245
        # Search all entries for new episodes
234
246
        for entry in entries:
235
247
            try:
236
 
                episode = PodcastEpisode.from_feedparser_entry(entry, self)
 
248
                episode = PodcastEpisode.from_feedparser_entry(entry, self, mimetype_prefs)
237
249
                if episode is not None and not episode.title:
238
250
                    episode.title, ext = os.path.splitext(os.path.basename(episode.url))
239
251
            except Exception, e:
290
302
 
291
303
    def _update_etag_modified(self, feed):
292
304
        self.updated_timestamp = time.time()
293
 
        self.calculate_publish_behaviour()
294
305
        self.etag = feed.headers.get('etag', self.etag)
295
306
        self.last_modified = feed.headers.get('last-modified', self.last_modified)
296
307
 
297
 
    def query_automatic_update(self):
298
 
        """Query if this channel should be updated automatically
299
 
 
300
 
        Returns True if the update should happen in automatic
301
 
        mode or False if this channel should be skipped (timeout
302
 
        not yet reached or release not expected right now).
303
 
        """
304
 
        updated = self.updated_timestamp
305
 
        expected = self.release_expected
306
 
 
307
 
        now = time.time()
308
 
        one_day_ago = now - 60*60*24
309
 
        lastcheck = now - 60*10
310
 
 
311
 
        return updated < one_day_ago or \
312
 
                (expected < now and updated < lastcheck)
313
 
 
314
 
    def update(self, max_episodes=0):
 
308
    def update(self, max_episodes=0, mimetype_prefs=''):
315
309
        try:
316
310
            self.feed_fetcher.fetch_channel(self)
317
311
        except CustomFeed, updated:
320
314
            self.save()
321
315
        except feedcore.UpdatedFeed, updated:
322
316
            feed = updated.data
323
 
            self._consume_updated_feed(feed, max_episodes)
 
317
            self._consume_updated_feed(feed, max_episodes, mimetype_prefs)
324
318
            self._update_etag_modified(feed)
325
319
            self.save()
326
320
        except feedcore.NewLocation, updated:
327
321
            feed = updated.data
328
322
            self.url = feed.href
329
 
            self._consume_updated_feed(feed, max_episodes)
 
323
            self._consume_updated_feed(feed, max_episodes, mimetype_prefs)
330
324
            self._update_etag_modified(feed)
331
325
            self.save()
332
326
        except feedcore.NotModified, updated:
359
353
    def save(self):
360
354
        if gpodder.user_hooks is not None:
361
355
            gpodder.user_hooks.on_podcast_save(self)
 
356
        if self.foldername is None:
 
357
            # get_save_dir() finds a unique value for foldername
 
358
            self.get_save_dir()
362
359
        self.db.save_channel(self)
363
360
 
364
361
    def get_statistics(self):
401
398
 
402
399
        self.channel_is_locked = False
403
400
 
404
 
        self.release_expected = time.time()
405
 
        self.release_deviation = 0
 
401
        self.release_expected = time.time() # <= DEPRECATED
 
402
        self.release_deviation = 0 # <= DEPRECATED
406
403
        self.updated_timestamp = 0
407
404
 
408
 
    def calculate_publish_behaviour(self):
409
 
        episodes = self.db.load_episodes(self, factory=self.episode_factory, limit=30)
410
 
        if len(episodes) < 3:
411
 
            return
412
 
 
413
 
        deltas = []
414
 
        latest = max(e.pubDate for e in episodes)
415
 
        for index in range(len(episodes)-1):
416
 
            if episodes[index].pubDate != 0 and episodes[index+1].pubDate != 0:
417
 
                deltas.append(episodes[index].pubDate - episodes[index+1].pubDate)
418
 
 
419
 
        if len(deltas) > 1:
420
 
            stats = corestats.Stats(deltas)
421
 
            self.release_expected = min([latest+stats.stdev(), latest+(stats.min()+stats.avg())*.5])
422
 
            self.release_deviation = stats.stdev()
423
 
        else:
424
 
            self.release_expected = latest
425
 
            self.release_deviation = 0
 
405
        self.feed_update_enabled = True
426
406
 
427
407
    def request_save_dir_size(self):
428
408
        if not self.__save_dir_size_set:
519
499
            return
520
500
 
521
501
        log('Writing playlist to %s', m3u_filename, sender=self)
522
 
        f = open(m3u_filename, 'w')
523
 
        f.write('#EXTM3U\n')
524
 
 
525
 
        for episode in PodcastEpisode.sort_by_pubdate(downloaded_episodes):
526
 
            if episode.was_downloaded(and_exists=True):
527
 
                filename = episode.local_filename(create=False)
528
 
                assert filename is not None
529
 
 
530
 
                if os.path.dirname(filename).startswith(os.path.dirname(m3u_filename)):
531
 
                    filename = filename[len(os.path.dirname(m3u_filename)+os.sep):]
532
 
                f.write('#EXTINF:0,'+self.title+' - '+episode.title+' ('+episode.cute_pubdate()+')\n')
533
 
                f.write(filename+'\n')
534
 
 
535
 
        f.close()
 
502
        util.write_m3u_playlist(m3u_filename, \
 
503
                PodcastEpisode.sort_by_pubdate(downloaded_episodes))
536
504
 
537
505
    def get_episode_by_url(self, url):
538
506
        return self.db.load_single_episode(self, \
703
671
                youtube.is_video_link(self.link))
704
672
 
705
673
    @staticmethod
706
 
    def from_feedparser_entry(entry, channel):
 
674
    def from_feedparser_entry(entry, channel, mimetype_prefs=''):
707
675
        episode = PodcastEpisode(channel)
708
676
 
709
 
        episode.title = entry.get('title', '')
 
677
        # Replace multi-space and newlines with single space (Maemo bug 11173)
 
678
        episode.title = re.sub('\s+', ' ', entry.get('title', ''))
710
679
        episode.link = entry.get('link', '')
711
 
        episode.description = entry.get('summary', '')
 
680
        if 'content' in entry and len(entry['content']) and \
 
681
                entry['content'][0].type == 'text/html':
 
682
            episode.description = entry['content'][0].value
 
683
        else:
 
684
            episode.description = entry.get('summary', '')
712
685
 
713
686
        try:
714
687
            # Parse iTunes-specific podcast duration metadata
731
704
        video_available = any(e.get('type', '').startswith('video/') \
732
705
                for e in enclosures)
733
706
 
 
707
        # Create the list of preferred mime types
 
708
        mimetype_prefs = mimetype_prefs.split(',')
 
709
 
 
710
        def calculate_preference_value(enclosure):
 
711
            """Calculate preference value of an enclosure
 
712
 
 
713
            This is based on mime types and allows users to prefer
 
714
            certain mime types over others (e.g. MP3 over AAC, ...)
 
715
            """
 
716
            mimetype = enclosure.get('type', None)
 
717
            try:
 
718
                # If the mime type is found, return its (zero-based) index
 
719
                return mimetype_prefs.index(mimetype)
 
720
            except ValueError:
 
721
                # If it is not found, assume it comes after all listed items
 
722
                return len(mimetype_prefs)
 
723
 
734
724
        # Enclosures
735
 
        for e in enclosures:
 
725
        for e in sorted(enclosures, key=calculate_preference_value):
736
726
            episode.mimetype = e.get('type', 'application/octet-stream')
737
727
            if episode.mimetype == '':
738
728
                # See Maemo bug 10036
897
887
            length_str = ''
898
888
        return ('<b>%s</b>\n<small>%s'+_('released %s')+ \
899
889
                '; '+_('from %s')+'</small>') % (\
900
 
                xml.sax.saxutils.escape(self.title), \
 
890
                xml.sax.saxutils.escape(re.sub('\s+', ' ', self.title)), \
901
891
                xml.sax.saxutils.escape(length_str), \
902
892
                xml.sax.saxutils.escape(self.pubdate_prop), \
903
 
                xml.sax.saxutils.escape(self.channel.title))
 
893
                xml.sax.saxutils.escape(re.sub('\s+', ' ', self.channel.title)))
904
894
 
905
895
    @property
906
896
    def maemo_remove_markup(self):
907
 
        if self.is_played:
 
897
        if self.total_time and self.current_position:
 
898
            played_string = self.get_play_info_string()
 
899
        elif self.is_played:
908
900
            played_string = _('played')
909
901
        else:
910
902
            played_string = _('unplayed')
923
915
        return util.file_age_in_days(self.local_filename(create=False, \
924
916
                check_only=True))
925
917
 
 
918
    age_int_prop = property(fget=age_in_days)
 
919
 
926
920
    def get_age_string(self):
927
921
        return util.file_age_to_string(self.age_in_days())
928
922
 
929
923
    age_prop = property(fget=get_age_string)
930
924
 
931
 
    def one_line_description( self):
932
 
        lines = util.remove_html_tags(self.description).strip().splitlines()
933
 
        if not lines or lines[0] == '':
 
925
    def one_line_description(self):
 
926
        MAX_LINE_LENGTH = 120
 
927
        desc = util.remove_html_tags(self.description or '')
 
928
        desc = re.sub('\n', ' ', desc).strip()
 
929
        if not desc:
934
930
            return _('No description available')
935
931
        else:
936
 
            return ' '.join(lines)
 
932
            if len(desc) > MAX_LINE_LENGTH:
 
933
                return desc[:MAX_LINE_LENGTH] + '...'
 
934
            else:
 
935
                return desc
937
936
 
938
937
    def delete_from_disk(self):
939
938
        try:
1162
1161
        except:
1163
1162
            log('Cannot format pubDate (time) for "%s".', self.title, sender=self)
1164
1163
            return '0000'
1165
 
    
 
1164
 
 
1165
    def playlist_title(self):
 
1166
        """Return a title for this episode in a playlist
 
1167
 
 
1168
        The title will be composed of the podcast name, the
 
1169
        episode name and the publication date. The return
 
1170
        value is the canonical representation of this episode
 
1171
        in playlists (for example, M3U playlists).
 
1172
        """
 
1173
        return '%s - %s (%s)' % (self.channel.title, \
 
1174
                self.title, \
 
1175
                self.cute_pubdate())
 
1176
 
1166
1177
    def cute_pubdate(self):
1167
1178
        result = util.format_date(self.pubDate)
1168
1179
        if result is None:
1181
1192
        except:
1182
1193
            log( 'Could not get filesize for %s.', self.url)
1183
1194
 
 
1195
    def is_finished(self):
 
1196
        """Return True if this episode is considered "finished playing"
 
1197
 
 
1198
        An episode is considered "finished" when there is a
 
1199
        current position mark on the track, and when the
 
1200
        current position is greater than 99 percent of the
 
1201
        total time or inside the last 10 seconds of a track.
 
1202
        """
 
1203
        return self.current_position > 0 and \
 
1204
                (self.current_position + 10 >= self.total_time or \
 
1205
                 self.current_position >= self.total_time*.99)
 
1206
 
1184
1207
    def get_play_info_string(self):
1185
 
        if self.current_position > 0 and \
1186
 
                self.total_time <= self.current_position:
 
1208
        if self.is_finished():
1187
1209
            return '%s (%s)' % (_('Finished'), self.get_duration_string(),)
1188
1210
        if self.current_position > 0:
1189
1211
            return '%s / %s' % (self.get_position_string(), \