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

« back to all changes in this revision

Viewing changes to .pc/debian-changes-2.15-1/src/gpodder/model.py

  • Committer: Bazaar Package Importer
  • Author(s): tony mancill
  • Date: 2011-05-31 22:05:31 UTC
  • mfrom: (5.2.18 sid)
  • Revision ID: james.westby@ubuntu.com-20110531220531-f3gt49fypbmuair8
Tags: 2.15-2
This time without a patch that reverts the source to 2.14.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# -*- coding: utf-8 -*-
2
 
#
3
 
# gPodder - A media aggregator and podcast client
4
 
# Copyright (c) 2005-2011 Thomas Perl and the gPodder Team
5
 
#
6
 
# gPodder is free software; you can redistribute it and/or modify
7
 
# it under the terms of the GNU General Public License as published by
8
 
# the Free Software Foundation; either version 3 of the License, or
9
 
# (at your option) any later version.
10
 
#
11
 
# gPodder is distributed in the hope that it will be useful,
12
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
 
# GNU General Public License for more details.
15
 
#
16
 
# You should have received a copy of the GNU General Public License
17
 
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
 
#
19
 
 
20
 
 
21
 
#
22
 
#  gpodder.model - Core model classes for gPodder (2009-08-13)
23
 
#  Based on libpodcasts.py (thp, 2005-10-29)
24
 
#
25
 
 
26
 
import gpodder
27
 
from gpodder import util
28
 
from gpodder import feedcore
29
 
from gpodder import youtube
30
 
 
31
 
from gpodder.liblogger import log
32
 
 
33
 
import os
34
 
import re
35
 
import glob
36
 
import shutil
37
 
import time
38
 
import datetime
39
 
import rfc822
40
 
import hashlib
41
 
import feedparser
42
 
import xml.sax.saxutils
43
 
 
44
 
_ = gpodder.gettext
45
 
 
46
 
 
47
 
class CustomFeed(feedcore.ExceptionWithData): pass
48
 
 
49
 
class gPodderFetcher(feedcore.Fetcher):
50
 
    """
51
 
    This class extends the feedcore Fetcher with the gPodder User-Agent and the
52
 
    Proxy handler based on the current settings in gPodder and provides a
53
 
    convenience method (fetch_channel) for use by PodcastChannel objects.
54
 
    """
55
 
    custom_handlers = []
56
 
 
57
 
    def __init__(self):
58
 
        feedcore.Fetcher.__init__(self, gpodder.user_agent)
59
 
 
60
 
    def fetch_channel(self, channel):
61
 
        etag = channel.etag
62
 
        modified = feedparser._parse_date(channel.last_modified)
63
 
        # If we have a username or password, rebuild the url with them included
64
 
        # Note: using a HTTPBasicAuthHandler would be pain because we need to
65
 
        # know the realm. It can be done, but I think this method works, too
66
 
        url = channel.authenticate_url(channel.url)
67
 
        for handler in self.custom_handlers:
68
 
            custom_feed = handler.handle_url(url)
69
 
            if custom_feed is not None:
70
 
                raise CustomFeed(custom_feed)
71
 
        self.fetch(url, etag, modified)
72
 
 
73
 
    def _resolve_url(self, url):
74
 
        return youtube.get_real_channel_url(url)
75
 
 
76
 
    @classmethod
77
 
    def register(cls, handler):
78
 
        cls.custom_handlers.append(handler)
79
 
 
80
 
#    def _get_handlers(self):
81
 
#        # Add a ProxyHandler for fetching data via a proxy server
82
 
#        proxies = {'http': 'http://proxy.example.org:8080'}
83
 
#        return[urllib2.ProxyHandler(proxies))]
84
 
 
85
 
# The "register" method is exposed here for external usage
86
 
register_custom_handler = gPodderFetcher.register
87
 
 
88
 
class PodcastModelObject(object):
89
 
    """
90
 
    A generic base class for our podcast model providing common helper
91
 
    and utility functions.
92
 
    """
93
 
 
94
 
    @classmethod
95
 
    def create_from_dict(cls, d, *args):
96
 
        """
97
 
        Create a new object, passing "args" to the constructor
98
 
        and then updating the object with the values from "d".
99
 
        """
100
 
        o = cls(*args)
101
 
        o.update_from_dict(d)
102
 
        return o
103
 
 
104
 
    def update_from_dict(self, d):
105
 
        """
106
 
        Updates the attributes of this object with values from the
107
 
        dictionary "d" by using the keys found in "d".
108
 
        """
109
 
        for k in d:
110
 
            if hasattr(self, k):
111
 
                setattr(self, k, d[k])
112
 
 
113
 
 
114
 
class PodcastChannel(PodcastModelObject):
115
 
    """holds data for a complete channel"""
116
 
    MAX_FOLDERNAME_LENGTH = 150
117
 
    SECONDS_PER_WEEK = 7*24*60*60
118
 
 
119
 
    feed_fetcher = gPodderFetcher()
120
 
 
121
 
    @classmethod
122
 
    def build_factory(cls, download_dir):
123
 
        def factory(dict, db):
124
 
            return cls.create_from_dict(dict, db, download_dir)
125
 
        return factory
126
 
 
127
 
    @classmethod
128
 
    def load_from_db(cls, db, download_dir):
129
 
        return db.load_channels(factory=cls.build_factory(download_dir))
130
 
 
131
 
    @classmethod
132
 
    def load(cls, db, url, create=True, authentication_tokens=None,\
133
 
            max_episodes=0, download_dir=None, allow_empty_feeds=False, \
134
 
            mimetype_prefs=''):
135
 
        if isinstance(url, unicode):
136
 
            url = url.encode('utf-8')
137
 
 
138
 
        tmp = db.load_channels(factory=cls.build_factory(download_dir), url=url)
139
 
        if len(tmp):
140
 
            return tmp[0]
141
 
        elif create:
142
 
            tmp = PodcastChannel(db, download_dir)
143
 
            tmp.url = url
144
 
            if authentication_tokens is not None:
145
 
                tmp.username = authentication_tokens[0]
146
 
                tmp.password = authentication_tokens[1]
147
 
 
148
 
            tmp.update(max_episodes, mimetype_prefs)
149
 
            tmp.save()
150
 
            db.force_last_new(tmp)
151
 
            # Subscribing to empty feeds should yield an error (except if
152
 
            # the user specifically allows empty feeds in the config UI)
153
 
            if sum(tmp.get_statistics()) == 0 and not allow_empty_feeds:
154
 
                tmp.delete()
155
 
                raise Exception(_('No downloadable episodes in feed'))
156
 
            return tmp
157
 
 
158
 
    def episode_factory(self, d, db__parameter_is_unused=None):
159
 
        """
160
 
        This function takes a dictionary containing key-value pairs for
161
 
        episodes and returns a new PodcastEpisode object that is connected
162
 
        to this PodcastChannel object.
163
 
 
164
 
        Returns: A new PodcastEpisode object
165
 
        """
166
 
        return PodcastEpisode.create_from_dict(d, self)
167
 
 
168
 
    def _consume_custom_feed(self, custom_feed, max_episodes=0):
169
 
        self.title = custom_feed.get_title()
170
 
        self.link = custom_feed.get_link()
171
 
        self.description = custom_feed.get_description()
172
 
        self.image = custom_feed.get_image()
173
 
        self.pubDate = time.time()
174
 
        self.save()
175
 
 
176
 
        guids = [episode.guid for episode in self.get_all_episodes()]
177
 
 
178
 
        # Insert newly-found episodes into the database
179
 
        custom_feed.get_new_episodes(self, guids)
180
 
 
181
 
        self.save()
182
 
 
183
 
        self.db.purge(max_episodes, self.id)
184
 
 
185
 
    def _consume_updated_feed(self, feed, max_episodes=0, mimetype_prefs=''):
186
 
        self.parse_error = feed.get('bozo_exception', None)
187
 
 
188
 
        # Replace multi-space and newlines with single space (Maemo bug 11173)
189
 
        self.title = re.sub('\s+', ' ', feed.feed.get('title', self.url))
190
 
 
191
 
        self.link = feed.feed.get('link', self.link)
192
 
        self.description = feed.feed.get('subtitle', self.description)
193
 
        # Start YouTube-specific title FIX
194
 
        YOUTUBE_PREFIX = 'Uploads by '
195
 
        if self.title.startswith(YOUTUBE_PREFIX):
196
 
            self.title = self.title[len(YOUTUBE_PREFIX):] + ' on YouTube'
197
 
        # End YouTube-specific title FIX
198
 
 
199
 
        try:
200
 
            self.pubDate = rfc822.mktime_tz(feed.feed.get('updated_parsed', None+(0,)))
201
 
        except:
202
 
            self.pubDate = time.time()
203
 
 
204
 
        if hasattr(feed.feed, 'image'):
205
 
            for attribute in ('href', 'url'):
206
 
                new_value = getattr(feed.feed.image, attribute, None)
207
 
                if new_value is not None:
208
 
                    log('Found cover art in %s: %s', attribute, new_value)
209
 
                    self.image = new_value
210
 
 
211
 
        if hasattr(feed.feed, 'icon'):
212
 
            self.image = feed.feed.icon
213
 
 
214
 
        self.save()
215
 
 
216
 
        # Load all episodes to update them properly.
217
 
        existing = self.get_all_episodes()
218
 
 
219
 
        # We can limit the maximum number of entries that gPodder will parse
220
 
        if max_episodes > 0 and len(feed.entries) > max_episodes:
221
 
            # We have to sort the entries in descending chronological order,
222
 
            # because if the feed lists items in ascending order and has >
223
 
            # max_episodes old episodes, new episodes will not be shown.
224
 
            # See also: gPodder Bug 1186
225
 
            try:
226
 
                entries = sorted(feed.entries, \
227
 
                        key=lambda x: x.get('updated_parsed', (0,)*9), \
228
 
                        reverse=True)[:max_episodes]
229
 
            except Exception, e:
230
 
                log('Could not sort episodes: %s', e, sender=self, traceback=True)
231
 
                entries = feed.entries[:max_episodes]
232
 
        else:
233
 
            entries = feed.entries
234
 
 
235
 
        # Title + PubDate hashes for existing episodes
236
 
        existing_dupes = dict((e.duplicate_id(), e) for e in existing)
237
 
 
238
 
        # GUID-based existing episode list
239
 
        existing_guids = dict((e.guid, e) for e in existing)
240
 
 
241
 
        # Get most recent pubDate of all episodes
242
 
        last_pubdate = self.db.get_last_pubdate(self) or 0
243
 
 
244
 
        # Keep track of episode GUIDs currently seen in the feed
245
 
        seen_guids = set()
246
 
 
247
 
        # Search all entries for new episodes
248
 
        for entry in entries:
249
 
            try:
250
 
                episode = PodcastEpisode.from_feedparser_entry(entry, self, mimetype_prefs)
251
 
                if episode is not None:
252
 
                    if not episode.title:
253
 
                        log('Using filename as title for episode at %s.', \
254
 
                                episode.url, sender=self)
255
 
                        basename = os.path.basename(episode.url)
256
 
                        episode.title, ext = os.path.splitext(basename)
257
 
 
258
 
                    # Maemo bug 12073
259
 
                    if not episode.guid:
260
 
                        log('Using download URL as GUID for episode %s.', \
261
 
                                episode.title, sender=self)
262
 
                        episode.guid = episode.url
263
 
 
264
 
                    seen_guids.add(episode.guid)
265
 
            except Exception, e:
266
 
                log('Cannot instantiate episode: %s. Skipping.', e, sender=self, traceback=True)
267
 
                continue
268
 
 
269
 
            if episode is None:
270
 
                continue
271
 
 
272
 
            # Detect (and update) existing episode based on GUIDs
273
 
            existing_episode = existing_guids.get(episode.guid, None)
274
 
            if existing_episode:
275
 
                existing_episode.update_from(episode)
276
 
                existing_episode.save()
277
 
                continue
278
 
 
279
 
            # Detect (and update) existing episode based on duplicate ID
280
 
            existing_episode = existing_dupes.get(episode.duplicate_id(), None)
281
 
            if existing_episode:
282
 
                if existing_episode.is_duplicate(episode):
283
 
                    existing_episode.update_from(episode)
284
 
                    existing_episode.save()
285
 
                    continue
286
 
 
287
 
            # Workaround for bug 340: If the episode has been
288
 
            # published earlier than one week before the most
289
 
            # recent existing episode, do not mark it as new.
290
 
            if episode.pubDate < last_pubdate - self.SECONDS_PER_WEEK:
291
 
                log('Episode with old date: %s', episode.title, sender=self)
292
 
                episode.is_played = True
293
 
 
294
 
            episode.save()
295
 
 
296
 
        # Remove "unreachable" episodes - episodes that have not been
297
 
        # downloaded and that the feed does not list as downloadable anymore
298
 
        if self.id is not None:
299
 
            episodes_to_purge = (e for e in existing if \
300
 
                    e.state != gpodder.STATE_DOWNLOADED and \
301
 
                    e.guid not in seen_guids)
302
 
            for episode in episodes_to_purge:
303
 
                log('Episode removed from feed: %s (%s)', episode.title, \
304
 
                        episode.guid, sender=self)
305
 
                self.db.delete_episode_by_guid(episode.guid, self.id)
306
 
 
307
 
        # This *might* cause episodes to be skipped if there were more than
308
 
        # max_episodes_per_feed items added to the feed between updates.
309
 
        # The benefit is that it prevents old episodes from apearing as new
310
 
        # in certain situations (see bug #340).
311
 
        self.db.purge(max_episodes, self.id)
312
 
 
313
 
    def update_channel_lock(self):
314
 
        self.db.update_channel_lock(self)
315
 
 
316
 
    def _update_etag_modified(self, feed):
317
 
        self.updated_timestamp = time.time()
318
 
        self.etag = feed.headers.get('etag', self.etag)
319
 
        self.last_modified = feed.headers.get('last-modified', self.last_modified)
320
 
 
321
 
    def update(self, max_episodes=0, mimetype_prefs=''):
322
 
        try:
323
 
            self.feed_fetcher.fetch_channel(self)
324
 
        except CustomFeed, updated:
325
 
            custom_feed = updated.data
326
 
            self._consume_custom_feed(custom_feed, max_episodes)
327
 
            self.save()
328
 
        except feedcore.UpdatedFeed, updated:
329
 
            feed = updated.data
330
 
            self._consume_updated_feed(feed, max_episodes, mimetype_prefs)
331
 
            self._update_etag_modified(feed)
332
 
            self.save()
333
 
        except feedcore.NewLocation, updated:
334
 
            feed = updated.data
335
 
            self.url = feed.href
336
 
            self._consume_updated_feed(feed, max_episodes, mimetype_prefs)
337
 
            self._update_etag_modified(feed)
338
 
            self.save()
339
 
        except feedcore.NotModified, updated:
340
 
            feed = updated.data
341
 
            self._update_etag_modified(feed)
342
 
            self.save()
343
 
        except Exception, e:
344
 
            # "Not really" errors
345
 
            #feedcore.AuthenticationRequired
346
 
            # Temporary errors
347
 
            #feedcore.Offline
348
 
            #feedcore.BadRequest
349
 
            #feedcore.InternalServerError
350
 
            #feedcore.WifiLogin
351
 
            # Permanent errors
352
 
            #feedcore.Unsubscribe
353
 
            #feedcore.NotFound
354
 
            #feedcore.InvalidFeed
355
 
            #feedcore.UnknownStatusCode
356
 
            raise
357
 
 
358
 
        if gpodder.user_hooks is not None:
359
 
            gpodder.user_hooks.on_podcast_updated(self)
360
 
 
361
 
        self.db.commit()
362
 
 
363
 
    def delete(self):
364
 
        self.db.delete_channel(self)
365
 
 
366
 
    def save(self):
367
 
        if gpodder.user_hooks is not None:
368
 
            gpodder.user_hooks.on_podcast_save(self)
369
 
        if self.foldername is None:
370
 
            # get_save_dir() finds a unique value for foldername
371
 
            self.get_save_dir()
372
 
        self.db.save_channel(self)
373
 
 
374
 
    def get_statistics(self):
375
 
        if self.id is None:
376
 
            return (0, 0, 0, 0, 0)
377
 
        else:
378
 
            return self.db.get_channel_count(int(self.id))
379
 
 
380
 
    def authenticate_url(self, url):
381
 
        return util.url_add_authentication(url, self.username, self.password)
382
 
 
383
 
    def __init__(self, db, download_dir):
384
 
        self.db = db
385
 
        self.download_dir = download_dir
386
 
        self.id = None
387
 
        self.url = None
388
 
        self.title = ''
389
 
        self.link = ''
390
 
        self.description = ''
391
 
        self.image = None
392
 
        self.pubDate = 0
393
 
        self.parse_error = None
394
 
        self.foldername = None
395
 
        self.auto_foldername = 1 # automatically generated foldername
396
 
 
397
 
        # should this channel be synced to devices? (ex: iPod)
398
 
        self.sync_to_devices = True
399
 
        # to which playlist should be synced
400
 
        self.device_playlist_name = 'gPodder'
401
 
        # if set, this overrides the channel-provided title
402
 
        self.override_title = ''
403
 
        self.username = ''
404
 
        self.password = ''
405
 
 
406
 
        self.last_modified = None
407
 
        self.etag = None
408
 
 
409
 
        self.save_dir_size = 0
410
 
        self.__save_dir_size_set = False
411
 
 
412
 
        self.channel_is_locked = False
413
 
 
414
 
        self.release_expected = time.time() # <= DEPRECATED
415
 
        self.release_deviation = 0 # <= DEPRECATED
416
 
        self.updated_timestamp = 0
417
 
 
418
 
        self.feed_update_enabled = True
419
 
 
420
 
    def request_save_dir_size(self):
421
 
        if not self.__save_dir_size_set:
422
 
            self.update_save_dir_size()
423
 
        self.__save_dir_size_set = True
424
 
 
425
 
    def update_save_dir_size(self):
426
 
        self.save_dir_size = util.calculate_size(self.save_dir)
427
 
 
428
 
    def get_title( self):
429
 
        if self.override_title:
430
 
            return self.override_title
431
 
        elif not self.__title.strip():
432
 
            return self.url
433
 
        else:
434
 
            return self.__title
435
 
 
436
 
    def set_title( self, value):
437
 
        self.__title = value.strip()
438
 
 
439
 
    title = property(fget=get_title,
440
 
                     fset=set_title)
441
 
 
442
 
    def set_custom_title( self, custom_title):
443
 
        custom_title = custom_title.strip()
444
 
        
445
 
        # if the custom title is the same as we have
446
 
        if custom_title == self.override_title:
447
 
            return
448
 
        
449
 
        # if custom title is the same as channel title and we didn't have a custom title
450
 
        if custom_title == self.__title and self.override_title == '':
451
 
            return
452
 
 
453
 
        # make sure self.foldername is initialized
454
 
        self.get_save_dir()
455
 
 
456
 
        # rename folder if custom_title looks sane
457
 
        new_folder_name = self.find_unique_folder_name(custom_title)
458
 
        if len(new_folder_name) > 0 and new_folder_name != self.foldername:
459
 
            log('Changing foldername based on custom title: %s', custom_title, sender=self)
460
 
            new_folder = os.path.join(self.download_dir, new_folder_name)
461
 
            old_folder = os.path.join(self.download_dir, self.foldername)
462
 
            if os.path.exists(old_folder):
463
 
                if not os.path.exists(new_folder):
464
 
                    # Old folder exists, new folder does not -> simply rename
465
 
                    log('Renaming %s => %s', old_folder, new_folder, sender=self)
466
 
                    os.rename(old_folder, new_folder)
467
 
                else:
468
 
                    # Both folders exist -> move files and delete old folder
469
 
                    log('Moving files from %s to %s', old_folder, new_folder, sender=self)
470
 
                    for file in glob.glob(os.path.join(old_folder, '*')):
471
 
                        shutil.move(file, new_folder)
472
 
                    log('Removing %s', old_folder, sender=self)
473
 
                    shutil.rmtree(old_folder, ignore_errors=True)
474
 
            self.foldername = new_folder_name
475
 
            self.save()
476
 
 
477
 
        if custom_title != self.__title:
478
 
            self.override_title = custom_title
479
 
        else:
480
 
            self.override_title = ''
481
 
 
482
 
    def get_downloaded_episodes(self):
483
 
        return self.db.load_episodes(self, factory=self.episode_factory, state=gpodder.STATE_DOWNLOADED)
484
 
    
485
 
    def get_new_episodes(self, downloading=lambda e: False):
486
 
        """
487
 
        Get a list of new episodes. You can optionally specify
488
 
        "downloading" as a callback that takes an episode as
489
 
        a parameter and returns True if the episode is currently
490
 
        being downloaded or False if not.
491
 
 
492
 
        By default, "downloading" is implemented so that it
493
 
        reports all episodes as not downloading.
494
 
        """
495
 
        return [episode for episode in self.db.load_episodes(self, \
496
 
                factory=self.episode_factory, state=gpodder.STATE_NORMAL) if \
497
 
                episode.check_is_new(downloading=downloading)]
498
 
 
499
 
    def get_playlist_filename(self):
500
 
        # If the save_dir doesn't end with a slash (which it really should
501
 
        # not, if the implementation is correct, we can just append .m3u :)
502
 
        assert self.save_dir[-1] != '/'
503
 
        return self.save_dir+'.m3u'
504
 
 
505
 
    def update_m3u_playlist(self):
506
 
        m3u_filename = self.get_playlist_filename()
507
 
 
508
 
        downloaded_episodes = self.get_downloaded_episodes()
509
 
        if not downloaded_episodes:
510
 
            log('No episodes - removing %s', m3u_filename, sender=self)
511
 
            util.delete_file(m3u_filename)
512
 
            return
513
 
 
514
 
        log('Writing playlist to %s', m3u_filename, sender=self)
515
 
        util.write_m3u_playlist(m3u_filename, \
516
 
                PodcastEpisode.sort_by_pubdate(downloaded_episodes))
517
 
 
518
 
    def get_episode_by_url(self, url):
519
 
        return self.db.load_single_episode(self, \
520
 
                factory=self.episode_factory, url=url)
521
 
 
522
 
    def get_episode_by_filename(self, filename):
523
 
        return self.db.load_single_episode(self, \
524
 
                factory=self.episode_factory, filename=filename)
525
 
 
526
 
    def get_all_episodes(self):
527
 
        return self.db.load_episodes(self, factory=self.episode_factory)
528
 
 
529
 
    def find_unique_folder_name(self, foldername):
530
 
        # Remove trailing dots to avoid errors on Windows (bug 600)
531
 
        foldername = foldername.strip().rstrip('.')
532
 
 
533
 
        current_try = util.sanitize_filename(foldername, \
534
 
                self.MAX_FOLDERNAME_LENGTH)
535
 
        next_try_id = 2
536
 
 
537
 
        while True:
538
 
            if self.db.channel_foldername_exists(current_try):
539
 
                current_try = '%s (%d)' % (foldername, next_try_id)
540
 
                next_try_id += 1
541
 
            else:
542
 
                return current_try
543
 
 
544
 
    def get_save_dir(self):
545
 
        urldigest = hashlib.md5(self.url).hexdigest()
546
 
        sanitizedurl = util.sanitize_filename(self.url, self.MAX_FOLDERNAME_LENGTH)
547
 
        if self.foldername is None or (self.auto_foldername and (self.foldername == urldigest or self.foldername.startswith(sanitizedurl))):
548
 
            # we must change the folder name, because it has not been set manually
549
 
            fn_template = util.sanitize_filename(self.title, self.MAX_FOLDERNAME_LENGTH)
550
 
 
551
 
            # if this is an empty string, try the basename
552
 
            if len(fn_template) == 0:
553
 
                log('That is one ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
554
 
                fn_template = util.sanitize_filename(os.path.basename(self.url), self.MAX_FOLDERNAME_LENGTH)
555
 
 
556
 
            # If the basename is also empty, use the first 6 md5 hexdigest chars of the URL
557
 
            if len(fn_template) == 0:
558
 
                log('That is one REALLY ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
559
 
                fn_template = urldigest # no need for sanitize_filename here
560
 
 
561
 
            # Find a unique folder name for this podcast
562
 
            wanted_foldername = self.find_unique_folder_name(fn_template)
563
 
 
564
 
            # if the foldername has not been set, check if the (old) md5 filename exists
565
 
            if self.foldername is None and os.path.exists(os.path.join(self.download_dir, urldigest)):
566
 
                log('Found pre-0.15.0 download folder for %s: %s', self.title, urldigest, sender=self)
567
 
                self.foldername = urldigest
568
 
 
569
 
            # we have a valid, new folder name in "current_try" -> use that!
570
 
            if self.foldername is not None and wanted_foldername != self.foldername:
571
 
                # there might be an old download folder crawling around - move it!
572
 
                new_folder_name = os.path.join(self.download_dir, wanted_foldername)
573
 
                old_folder_name = os.path.join(self.download_dir, self.foldername)
574
 
                if os.path.exists(old_folder_name):
575
 
                    if not os.path.exists(new_folder_name):
576
 
                        # Old folder exists, new folder does not -> simply rename
577
 
                        log('Renaming %s => %s', old_folder_name, new_folder_name, sender=self)
578
 
                        os.rename(old_folder_name, new_folder_name)
579
 
                    else:
580
 
                        # Both folders exist -> move files and delete old folder
581
 
                        log('Moving files from %s to %s', old_folder_name, new_folder_name, sender=self)
582
 
                        for file in glob.glob(os.path.join(old_folder_name, '*')):
583
 
                            shutil.move(file, new_folder_name)
584
 
                        log('Removing %s', old_folder_name, sender=self)
585
 
                        shutil.rmtree(old_folder_name, ignore_errors=True)
586
 
            log('Updating foldername of %s to "%s".', self.url, wanted_foldername, sender=self)
587
 
            self.foldername = wanted_foldername
588
 
            self.save()
589
 
 
590
 
        save_dir = os.path.join(self.download_dir, self.foldername)
591
 
 
592
 
        # Create save_dir if it does not yet exist
593
 
        if not util.make_directory( save_dir):
594
 
            log( 'Could not create save_dir: %s', save_dir, sender = self)
595
 
 
596
 
        return save_dir
597
 
    
598
 
    save_dir = property(fget=get_save_dir)
599
 
 
600
 
    def remove_downloaded(self):
601
 
        # Remove the playlist file if it exists
602
 
        m3u_filename = self.get_playlist_filename()
603
 
        if os.path.exists(m3u_filename):
604
 
            util.delete_file(m3u_filename)
605
 
 
606
 
        # Remove the download directory
607
 
        shutil.rmtree(self.save_dir, True)
608
 
 
609
 
    @property
610
 
    def cover_file(self):
611
 
        new_name = os.path.join(self.save_dir, 'folder.jpg')
612
 
        if not os.path.exists(new_name):
613
 
            old_names = ('cover', '.cover')
614
 
            for old_name in old_names:
615
 
                filename = os.path.join(self.save_dir, old_name)
616
 
                if os.path.exists(filename):
617
 
                    shutil.move(filename, new_name)
618
 
                    return new_name
619
 
 
620
 
        return new_name
621
 
 
622
 
    def delete_episode(self, episode):
623
 
        filename = episode.local_filename(create=False, check_only=True)
624
 
        if filename is not None:
625
 
            util.delete_file(filename)
626
 
 
627
 
        episode.set_state(gpodder.STATE_DELETED)
628
 
 
629
 
 
630
 
class PodcastEpisode(PodcastModelObject):
631
 
    """holds data for one object in a channel"""
632
 
    MAX_FILENAME_LENGTH = 200
633
 
 
634
 
    def _get_played(self):
635
 
        return self.is_played
636
 
 
637
 
    def _set_played(self, played):
638
 
        self.is_played = played
639
 
 
640
 
    # Alias "is_played" to "played" for DB column mapping
641
 
    played = property(fget=_get_played, fset=_set_played)
642
 
 
643
 
    def _get_locked(self):
644
 
        return self.is_locked
645
 
 
646
 
    def _set_locked(self, locked):
647
 
        self.is_locked = locked
648
 
 
649
 
    # Alias "is_locked" to "locked" for DB column mapping
650
 
    locked = property(fget=_get_locked, fset=_set_locked)
651
 
 
652
 
    def _get_channel_id(self):
653
 
        return self.channel.id
654
 
 
655
 
    def _set_channel_id(self, channel_id):
656
 
        assert self.channel.id == channel_id
657
 
 
658
 
    # Accessor for the "channel_id" DB column
659
 
    channel_id = property(fget=_get_channel_id, fset=_set_channel_id)
660
 
 
661
 
    @staticmethod
662
 
    def sort_by_pubdate(episodes, reverse=False):
663
 
        """Sort a list of PodcastEpisode objects chronologically
664
 
 
665
 
        Returns a iterable, sorted sequence of the episodes
666
 
        """
667
 
        key_pubdate = lambda e: e.pubDate
668
 
        return sorted(episodes, key=key_pubdate, reverse=reverse)
669
 
 
670
 
    def reload_from_db(self):
671
 
        """
672
 
        Re-reads all episode details for this object from the
673
 
        database and updates this object accordingly. Can be
674
 
        used to refresh existing objects when the database has
675
 
        been updated (e.g. the filename has been set after a
676
 
        download where it was not set before the download)
677
 
        """
678
 
        d = self.db.load_episode(self.id)
679
 
        self.update_from_dict(d or {})
680
 
        return self
681
 
 
682
 
    def has_website_link(self):
683
 
        return bool(self.link) and (self.link != self.url or \
684
 
                youtube.is_video_link(self.link))
685
 
 
686
 
    @staticmethod
687
 
    def from_feedparser_entry(entry, channel, mimetype_prefs=''):
688
 
        episode = PodcastEpisode(channel)
689
 
 
690
 
        # Replace multi-space and newlines with single space (Maemo bug 11173)
691
 
        episode.title = re.sub('\s+', ' ', entry.get('title', ''))
692
 
        episode.link = entry.get('link', '')
693
 
        if 'content' in entry and len(entry['content']) and \
694
 
                entry['content'][0].get('type', '') == 'text/html':
695
 
            episode.description = entry['content'][0].value
696
 
        else:
697
 
            episode.description = entry.get('summary', '')
698
 
 
699
 
        try:
700
 
            # Parse iTunes-specific podcast duration metadata
701
 
            total_time = util.parse_time(entry.get('itunes_duration', ''))
702
 
            episode.total_time = total_time
703
 
        except:
704
 
            pass
705
 
 
706
 
        # Fallback to subtitle if summary is not available0
707
 
        if not episode.description:
708
 
            episode.description = entry.get('subtitle', '')
709
 
 
710
 
        episode.guid = entry.get('id', '')
711
 
        if entry.get('updated_parsed', None):
712
 
            episode.pubDate = rfc822.mktime_tz(entry.updated_parsed+(0,))
713
 
 
714
 
        enclosures = entry.get('enclosures', ())
715
 
        audio_available = any(e.get('type', '').startswith('audio/') \
716
 
                for e in enclosures)
717
 
        video_available = any(e.get('type', '').startswith('video/') \
718
 
                for e in enclosures)
719
 
 
720
 
        # Create the list of preferred mime types
721
 
        mimetype_prefs = mimetype_prefs.split(',')
722
 
 
723
 
        def calculate_preference_value(enclosure):
724
 
            """Calculate preference value of an enclosure
725
 
 
726
 
            This is based on mime types and allows users to prefer
727
 
            certain mime types over others (e.g. MP3 over AAC, ...)
728
 
            """
729
 
            mimetype = enclosure.get('type', None)
730
 
            try:
731
 
                # If the mime type is found, return its (zero-based) index
732
 
                return mimetype_prefs.index(mimetype)
733
 
            except ValueError:
734
 
                # If it is not found, assume it comes after all listed items
735
 
                return len(mimetype_prefs)
736
 
 
737
 
        # Enclosures
738
 
        for e in sorted(enclosures, key=calculate_preference_value):
739
 
            episode.mimetype = e.get('type', 'application/octet-stream')
740
 
            if episode.mimetype == '':
741
 
                # See Maemo bug 10036
742
 
                log('Fixing empty mimetype in ugly feed', sender=episode)
743
 
                episode.mimetype = 'application/octet-stream'
744
 
 
745
 
            if '/' not in episode.mimetype:
746
 
                continue
747
 
 
748
 
            # Skip images in feeds if audio or video is available (bug 979)
749
 
            if episode.mimetype.startswith('image/') and \
750
 
                    (audio_available or video_available):
751
 
                continue
752
 
 
753
 
            episode.url = util.normalize_feed_url(e.get('href', ''))
754
 
            if not episode.url:
755
 
                continue
756
 
 
757
 
            try:
758
 
                episode.length = int(e.length) or -1
759
 
            except:
760
 
                episode.length = -1
761
 
 
762
 
            return episode
763
 
 
764
 
        # Media RSS content
765
 
        for m in entry.get('media_content', ()):
766
 
            episode.mimetype = m.get('type', 'application/octet-stream')
767
 
            if '/' not in episode.mimetype:
768
 
                continue
769
 
 
770
 
            episode.url = util.normalize_feed_url(m.get('url', ''))
771
 
            if not episode.url:
772
 
                continue
773
 
 
774
 
            try:
775
 
                episode.length = int(m.fileSize) or -1
776
 
            except:
777
 
                episode.length = -1
778
 
 
779
 
            return episode
780
 
 
781
 
        # Brute-force detection of any links
782
 
        for l in entry.get('links', ()):
783
 
            episode.url = util.normalize_feed_url(l.get('href', ''))
784
 
            if not episode.url:
785
 
                continue
786
 
 
787
 
            if youtube.is_video_link(episode.url):
788
 
                return episode
789
 
 
790
 
            # Check if we can resolve this link to a audio/video file
791
 
            filename, extension = util.filename_from_url(episode.url)
792
 
            file_type = util.file_type_by_extension(extension)
793
 
            if file_type is None and hasattr(l, 'type'):
794
 
                extension = util.extension_from_mimetype(l.type)
795
 
                file_type = util.file_type_by_extension(extension)
796
 
 
797
 
            # The link points to a audio or video file - use it!
798
 
            if file_type is not None:
799
 
                return episode
800
 
 
801
 
        # Scan MP3 links in description text
802
 
        mp3s = re.compile(r'http://[^"]*\.mp3')
803
 
        for content in entry.get('content', ()):
804
 
            html = content.value
805
 
            for match in mp3s.finditer(html):
806
 
                episode.url = match.group(0)
807
 
                return episode
808
 
 
809
 
        return None
810
 
 
811
 
    def __init__(self, channel):
812
 
        self.db = channel.db
813
 
        # Used by Storage for faster saving
814
 
        self.id = None
815
 
        self.url = ''
816
 
        self.title = ''
817
 
        self.length = 0
818
 
        self.mimetype = 'application/octet-stream'
819
 
        self.guid = ''
820
 
        self.description = ''
821
 
        self.link = ''
822
 
        self.channel = channel
823
 
        self.pubDate = 0
824
 
        self.filename = None
825
 
        self.auto_filename = 1 # automatically generated filename
826
 
 
827
 
        self.state = gpodder.STATE_NORMAL
828
 
        self.is_played = False
829
 
 
830
 
        # Initialize the "is_locked" property
831
 
        self._is_locked = False
832
 
        self.is_locked = channel.channel_is_locked
833
 
 
834
 
        # Time attributes
835
 
        self.total_time = 0
836
 
        self.current_position = 0
837
 
        self.current_position_updated = 0
838
 
 
839
 
    def get_is_locked(self):
840
 
        return self._is_locked
841
 
 
842
 
    def set_is_locked(self, is_locked):
843
 
        self._is_locked = bool(is_locked)
844
 
 
845
 
    is_locked = property(fget=get_is_locked, fset=set_is_locked)
846
 
 
847
 
    def save(self):
848
 
        if self.state != gpodder.STATE_DOWNLOADED and self.file_exists():
849
 
            self.state = gpodder.STATE_DOWNLOADED
850
 
        if gpodder.user_hooks is not None:
851
 
            gpodder.user_hooks.on_episode_save(self)
852
 
        self.db.save_episode(self)
853
 
 
854
 
    def on_downloaded(self, filename):
855
 
        self.state = gpodder.STATE_DOWNLOADED
856
 
        self.is_played = False
857
 
        self.length = os.path.getsize(filename)
858
 
 
859
 
        self.db.save_downloaded_episode(self)
860
 
        self.db.commit()
861
 
 
862
 
    def set_state(self, state):
863
 
        self.state = state
864
 
        self.db.update_episode_state(self)
865
 
 
866
 
    def mark(self, state=None, is_played=None, is_locked=None):
867
 
        if state is not None:
868
 
            self.state = state
869
 
        if is_played is not None:
870
 
            self.is_played = is_played
871
 
        if is_locked is not None:
872
 
            self.is_locked = is_locked
873
 
        self.db.update_episode_state(self)
874
 
 
875
 
    @property
876
 
    def title_markup(self):
877
 
        return '%s\n<small>%s</small>' % (xml.sax.saxutils.escape(self.title),
878
 
                          xml.sax.saxutils.escape(self.channel.title))
879
 
 
880
 
    @property
881
 
    def maemo_markup(self):
882
 
        if self.length > 0:
883
 
            length_str = '%s; ' % self.filesize_prop
884
 
        else:
885
 
            length_str = ''
886
 
        return ('<b>%s</b>\n<small>%s'+_('released %s')+ \
887
 
                '; '+_('from %s')+'</small>') % (\
888
 
                xml.sax.saxutils.escape(re.sub('\s+', ' ', self.title)), \
889
 
                xml.sax.saxutils.escape(length_str), \
890
 
                xml.sax.saxutils.escape(self.pubdate_prop), \
891
 
                xml.sax.saxutils.escape(re.sub('\s+', ' ', self.channel.title)))
892
 
 
893
 
    @property
894
 
    def maemo_remove_markup(self):
895
 
        if self.total_time and self.current_position:
896
 
            played_string = self.get_play_info_string()
897
 
        elif self.is_played:
898
 
            played_string = _('played')
899
 
        else:
900
 
            played_string = _('unplayed')
901
 
        downloaded_string = self.get_age_string()
902
 
        if not downloaded_string:
903
 
            downloaded_string = _('today')
904
 
        return ('<b>%s</b>\n<small>%s; %s; '+_('downloaded %s')+ \
905
 
                '; '+_('from %s')+'</small>') % (\
906
 
                xml.sax.saxutils.escape(self.title), \
907
 
                xml.sax.saxutils.escape(self.filesize_prop), \
908
 
                xml.sax.saxutils.escape(played_string), \
909
 
                xml.sax.saxutils.escape(downloaded_string), \
910
 
                xml.sax.saxutils.escape(self.channel.title))
911
 
 
912
 
    def age_in_days(self):
913
 
        return util.file_age_in_days(self.local_filename(create=False, \
914
 
                check_only=True))
915
 
 
916
 
    age_int_prop = property(fget=age_in_days)
917
 
 
918
 
    def get_age_string(self):
919
 
        return util.file_age_to_string(self.age_in_days())
920
 
 
921
 
    age_prop = property(fget=get_age_string)
922
 
 
923
 
    def one_line_description(self):
924
 
        MAX_LINE_LENGTH = 120
925
 
        desc = util.remove_html_tags(self.description or '')
926
 
        desc = re.sub('\s+', ' ', desc).strip()
927
 
        if not desc:
928
 
            return _('No description available')
929
 
        else:
930
 
            # Decode the description to avoid gPodder bug 1277
931
 
            if isinstance(desc, str):
932
 
                desc = desc.decode('utf-8', 'ignore')
933
 
            if len(desc) > MAX_LINE_LENGTH:
934
 
                return desc[:MAX_LINE_LENGTH] + '...'
935
 
            else:
936
 
                return desc
937
 
 
938
 
    def delete_from_disk(self):
939
 
        try:
940
 
            self.channel.delete_episode(self)
941
 
        except:
942
 
            log('Cannot delete episode from disk: %s', self.title, traceback=True, sender=self)
943
 
 
944
 
    def find_unique_file_name(self, url, filename, extension):
945
 
        current_try = util.sanitize_filename(filename, self.MAX_FILENAME_LENGTH)+extension
946
 
        next_try_id = 2
947
 
        lookup_url = None
948
 
 
949
 
        if self.filename == current_try and current_try is not None:
950
 
            # We already have this filename - good!
951
 
            return current_try
952
 
 
953
 
        while self.db.episode_filename_exists(current_try):
954
 
            current_try = '%s (%d)%s' % (filename, next_try_id, extension)
955
 
            next_try_id += 1
956
 
 
957
 
        return current_try
958
 
 
959
 
    def local_filename(self, create, force_update=False, check_only=False,
960
 
            template=None):
961
 
        """Get (and possibly generate) the local saving filename
962
 
 
963
 
        Pass create=True if you want this function to generate a
964
 
        new filename if none exists. You only want to do this when
965
 
        planning to create/download the file after calling this function.
966
 
 
967
 
        Normally, you should pass create=False. This will only
968
 
        create a filename when the file already exists from a previous
969
 
        version of gPodder (where we used md5 filenames). If the file
970
 
        does not exist (and the filename also does not exist), this
971
 
        function will return None.
972
 
 
973
 
        If you pass force_update=True to this function, it will try to
974
 
        find a new (better) filename and move the current file if this
975
 
        is the case. This is useful if (during the download) you get
976
 
        more information about the file, e.g. the mimetype and you want
977
 
        to include this information in the file name generation process.
978
 
 
979
 
        If check_only=True is passed to this function, it will never try
980
 
        to rename the file, even if would be a good idea. Use this if you
981
 
        only want to check if a file exists.
982
 
 
983
 
        If "template" is specified, it should be a filename that is to
984
 
        be used as a template for generating the "real" filename.
985
 
 
986
 
        The generated filename is stored in the database for future access.
987
 
        """
988
 
        ext = self.extension(may_call_local_filename=False).encode('utf-8', 'ignore')
989
 
 
990
 
        # For compatibility with already-downloaded episodes, we
991
 
        # have to know md5 filenames if they are downloaded already
992
 
        urldigest = hashlib.md5(self.url).hexdigest()
993
 
 
994
 
        if not create and self.filename is None:
995
 
            urldigest_filename = os.path.join(self.channel.save_dir, urldigest+ext)
996
 
            if os.path.exists(urldigest_filename):
997
 
                # The file exists, so set it up in our database
998
 
                log('Recovering pre-0.15.0 file: %s', urldigest_filename, sender=self)
999
 
                self.filename = urldigest+ext
1000
 
                self.auto_filename = 1
1001
 
                self.save()
1002
 
                return urldigest_filename
1003
 
            return None
1004
 
 
1005
 
        # We only want to check if the file exists, so don't try to
1006
 
        # rename the file, even if it would be reasonable. See also:
1007
 
        # http://bugs.gpodder.org/attachment.cgi?id=236
1008
 
        if check_only:
1009
 
            if self.filename is None:
1010
 
                return None
1011
 
            else:
1012
 
                return os.path.join(self.channel.save_dir, self.filename)
1013
 
 
1014
 
        if self.filename is None or force_update or (self.auto_filename and self.filename == urldigest+ext):
1015
 
            # Try to find a new filename for the current file
1016
 
            if template is not None:
1017
 
                # If template is specified, trust the template's extension
1018
 
                episode_filename, ext = os.path.splitext(template)
1019
 
            else:
1020
 
                episode_filename, extension_UNUSED = util.filename_from_url(self.url)
1021
 
            fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
1022
 
 
1023
 
            if 'redirect' in fn_template and template is None:
1024
 
                # This looks like a redirection URL - force URL resolving!
1025
 
                log('Looks like a redirection to me: %s', self.url, sender=self)
1026
 
                url = util.get_real_url(self.channel.authenticate_url(self.url))
1027
 
                log('Redirection resolved to: %s', url, sender=self)
1028
 
                (episode_filename, extension_UNUSED) = util.filename_from_url(url)
1029
 
                fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
1030
 
 
1031
 
            # Use the video title for YouTube downloads
1032
 
            for yt_url in ('http://youtube.com/', 'http://www.youtube.com/'):
1033
 
                if self.url.startswith(yt_url):
1034
 
                    fn_template = util.sanitize_filename(os.path.basename(self.title), self.MAX_FILENAME_LENGTH)
1035
 
 
1036
 
            # Nicer download filenames for Soundcloud streams
1037
 
            if fn_template == 'stream':
1038
 
                sanitized = util.sanitize_filename(self.title, self.MAX_FILENAME_LENGTH)
1039
 
                if sanitized:
1040
 
                    fn_template = sanitized
1041
 
 
1042
 
            # If the basename is empty, use the md5 hexdigest of the URL
1043
 
            if len(fn_template) == 0 or fn_template.startswith('redirect.'):
1044
 
                log('Report to bugs.gpodder.org: Podcast at %s with episode URL: %s', self.channel.url, self.url, sender=self)
1045
 
                fn_template = urldigest
1046
 
 
1047
 
            # Find a unique filename for this episode
1048
 
            wanted_filename = self.find_unique_file_name(self.url, fn_template, ext)
1049
 
 
1050
 
            # We populate the filename field the first time - does the old file still exist?
1051
 
            if self.filename is None and os.path.exists(os.path.join(self.channel.save_dir, urldigest+ext)):
1052
 
                log('Found pre-0.15.0 downloaded file: %s', urldigest, sender=self)
1053
 
                self.filename = urldigest+ext
1054
 
 
1055
 
            # The old file exists, but we have decided to want a different filename
1056
 
            if self.filename is not None and wanted_filename != self.filename:
1057
 
                # there might be an old download folder crawling around - move it!
1058
 
                new_file_name = os.path.join(self.channel.save_dir, wanted_filename)
1059
 
                old_file_name = os.path.join(self.channel.save_dir, self.filename)
1060
 
                if os.path.exists(old_file_name) and not os.path.exists(new_file_name):
1061
 
                    log('Renaming %s => %s', old_file_name, new_file_name, sender=self)
1062
 
                    os.rename(old_file_name, new_file_name)
1063
 
                elif force_update and not os.path.exists(old_file_name):
1064
 
                    # When we call force_update, the file might not yet exist when we
1065
 
                    # call it from the downloading code before saving the file
1066
 
                    log('Choosing new filename: %s', new_file_name, sender=self)
1067
 
                else:
1068
 
                    log('Warning: %s exists or %s does not.', new_file_name, old_file_name, sender=self)
1069
 
                log('Updating filename of %s to "%s".', self.url, wanted_filename, sender=self)
1070
 
            elif self.filename is None:
1071
 
                log('Setting filename to "%s".', wanted_filename, sender=self)
1072
 
            else:
1073
 
                log('Should update filename. Stays the same (%s). Good!', \
1074
 
                        wanted_filename, sender=self)
1075
 
            self.filename = wanted_filename
1076
 
            self.save()
1077
 
            self.db.commit()
1078
 
 
1079
 
        return os.path.join(self.channel.save_dir, self.filename)
1080
 
 
1081
 
    def set_mimetype(self, mimetype, commit=False):
1082
 
        """Sets the mimetype for this episode"""
1083
 
        self.mimetype = mimetype
1084
 
        if commit:
1085
 
            self.db.commit()
1086
 
 
1087
 
    def extension(self, may_call_local_filename=True):
1088
 
        filename, ext = util.filename_from_url(self.url)
1089
 
        if may_call_local_filename:
1090
 
            filename = self.local_filename(create=False)
1091
 
            if filename is not None:
1092
 
                filename, ext = os.path.splitext(filename)
1093
 
        # if we can't detect the extension from the url fallback on the mimetype
1094
 
        if ext == '' or util.file_type_by_extension(ext) is None:
1095
 
            ext = util.extension_from_mimetype(self.mimetype)
1096
 
        return ext
1097
 
 
1098
 
    def check_is_new(self, downloading=lambda e: False):
1099
 
        """
1100
 
        Returns True if this episode is to be considered new.
1101
 
        "Downloading" should be a callback that gets an episode
1102
 
        as its parameter and returns True if the episode is
1103
 
        being downloaded at the moment.
1104
 
        """
1105
 
        return self.state == gpodder.STATE_NORMAL and \
1106
 
                not self.is_played and \
1107
 
                not downloading(self)
1108
 
 
1109
 
    def mark_new(self):
1110
 
        self.state = gpodder.STATE_NORMAL
1111
 
        self.is_played = False
1112
 
        self.db.update_episode_state(self)
1113
 
 
1114
 
    def mark_old(self):
1115
 
        self.is_played = True
1116
 
        self.db.update_episode_state(self)
1117
 
 
1118
 
    def file_exists(self):
1119
 
        filename = self.local_filename(create=False, check_only=True)
1120
 
        if filename is None:
1121
 
            return False
1122
 
        else:
1123
 
            return os.path.exists(filename)
1124
 
 
1125
 
    def was_downloaded(self, and_exists=False):
1126
 
        if self.state != gpodder.STATE_DOWNLOADED:
1127
 
            return False
1128
 
        if and_exists and not self.file_exists():
1129
 
            return False
1130
 
        return True
1131
 
 
1132
 
    def sync_filename(self, use_custom=False, custom_format=None):
1133
 
        if use_custom:
1134
 
            return util.object_string_formatter(custom_format,
1135
 
                    episode=self, podcast=self.channel)
1136
 
        else:
1137
 
            return self.title
1138
 
 
1139
 
    def file_type(self):
1140
 
        # Assume all YouTube links are video files
1141
 
        if youtube.is_video_link(self.url):
1142
 
            return 'video'
1143
 
 
1144
 
        return util.file_type_by_extension(self.extension())
1145
 
 
1146
 
    @property
1147
 
    def basename( self):
1148
 
        return os.path.splitext( os.path.basename( self.url))[0]
1149
 
    
1150
 
    @property
1151
 
    def published( self):
1152
 
        """
1153
 
        Returns published date as YYYYMMDD (or 00000000 if not available)
1154
 
        """
1155
 
        try:
1156
 
            return datetime.datetime.fromtimestamp(self.pubDate).strftime('%Y%m%d')
1157
 
        except:
1158
 
            log( 'Cannot format pubDate for "%s".', self.title, sender = self)
1159
 
            return '00000000'
1160
 
 
1161
 
    @property
1162
 
    def pubtime(self):
1163
 
        """
1164
 
        Returns published time as HHMM (or 0000 if not available)
1165
 
        """
1166
 
        try:
1167
 
            return datetime.datetime.fromtimestamp(self.pubDate).strftime('%H%M')
1168
 
        except:
1169
 
            log('Cannot format pubDate (time) for "%s".', self.title, sender=self)
1170
 
            return '0000'
1171
 
 
1172
 
    def playlist_title(self):
1173
 
        """Return a title for this episode in a playlist
1174
 
 
1175
 
        The title will be composed of the podcast name, the
1176
 
        episode name and the publication date. The return
1177
 
        value is the canonical representation of this episode
1178
 
        in playlists (for example, M3U playlists).
1179
 
        """
1180
 
        return '%s - %s (%s)' % (self.channel.title, \
1181
 
                self.title, \
1182
 
                self.cute_pubdate())
1183
 
 
1184
 
    def cute_pubdate(self):
1185
 
        result = util.format_date(self.pubDate)
1186
 
        if result is None:
1187
 
            return '(%s)' % _('unknown')
1188
 
        else:
1189
 
            return result
1190
 
    
1191
 
    pubdate_prop = property(fget=cute_pubdate)
1192
 
 
1193
 
    def calculate_filesize( self):
1194
 
        filename = self.local_filename(create=False)
1195
 
        if filename is None:
1196
 
            log('calculate_filesized called, but filename is None!', sender=self)
1197
 
        try:
1198
 
            self.length = os.path.getsize(filename)
1199
 
        except:
1200
 
            log( 'Could not get filesize for %s.', self.url)
1201
 
 
1202
 
    def is_finished(self):
1203
 
        """Return True if this episode is considered "finished playing"
1204
 
 
1205
 
        An episode is considered "finished" when there is a
1206
 
        current position mark on the track, and when the
1207
 
        current position is greater than 99 percent of the
1208
 
        total time or inside the last 10 seconds of a track.
1209
 
        """
1210
 
        return self.current_position > 0 and \
1211
 
                (self.current_position + 10 >= self.total_time or \
1212
 
                 self.current_position >= self.total_time*.99)
1213
 
 
1214
 
    def get_play_info_string(self):
1215
 
        if self.is_finished():
1216
 
            return '%s (%s)' % (_('Finished'), self.get_duration_string(),)
1217
 
        if self.current_position > 0:
1218
 
            return '%s / %s' % (self.get_position_string(), \
1219
 
                    self.get_duration_string())
1220
 
        else:
1221
 
            return self.get_duration_string()
1222
 
 
1223
 
    def get_position_string(self):
1224
 
        return util.format_time(self.current_position)
1225
 
 
1226
 
    def get_duration_string(self):
1227
 
        return util.format_time(self.total_time)
1228
 
 
1229
 
    def get_filesize_string(self):
1230
 
        return util.format_filesize(self.length)
1231
 
 
1232
 
    filesize_prop = property(fget=get_filesize_string)
1233
 
 
1234
 
    def get_played_string( self):
1235
 
        if not self.is_played:
1236
 
            return _('Unplayed')
1237
 
        
1238
 
        return ''
1239
 
 
1240
 
    played_prop = property(fget=get_played_string)
1241
 
 
1242
 
    def is_duplicate(self, episode):
1243
 
        if self.title == episode.title and self.pubDate == episode.pubDate:
1244
 
            log('Possible duplicate detected: %s', self.title)
1245
 
            return True
1246
 
        return False
1247
 
 
1248
 
    def duplicate_id(self):
1249
 
        return hash((self.title, self.pubDate))
1250
 
 
1251
 
    def update_from(self, episode):
1252
 
        for k in ('title', 'url', 'description', 'link', 'pubDate', 'guid'):
1253
 
            setattr(self, k, getattr(episode, k))
1254