1
# -*- coding: utf-8 -*-
3
# gPodder - A media aggregator and podcast client
4
# Copyright (c) 2005-2011 Thomas Perl and the gPodder Team
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.
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.
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/>.
22
# gpodder.model - Core model classes for gPodder (2009-08-13)
23
# Based on libpodcasts.py (thp, 2005-10-29)
27
from gpodder import util
28
from gpodder import feedcore
29
from gpodder import youtube
31
from gpodder.liblogger import log
42
import xml.sax.saxutils
47
class CustomFeed(feedcore.ExceptionWithData): pass
49
class gPodderFetcher(feedcore.Fetcher):
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.
58
feedcore.Fetcher.__init__(self, gpodder.user_agent)
60
def fetch_channel(self, channel):
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)
73
def _resolve_url(self, url):
74
return youtube.get_real_channel_url(url)
77
def register(cls, handler):
78
cls.custom_handlers.append(handler)
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))]
85
# The "register" method is exposed here for external usage
86
register_custom_handler = gPodderFetcher.register
88
class PodcastModelObject(object):
90
A generic base class for our podcast model providing common helper
91
and utility functions.
95
def create_from_dict(cls, d, *args):
97
Create a new object, passing "args" to the constructor
98
and then updating the object with the values from "d".
101
o.update_from_dict(d)
104
def update_from_dict(self, d):
106
Updates the attributes of this object with values from the
107
dictionary "d" by using the keys found in "d".
111
setattr(self, k, d[k])
114
class PodcastChannel(PodcastModelObject):
115
"""holds data for a complete channel"""
116
MAX_FOLDERNAME_LENGTH = 150
117
SECONDS_PER_WEEK = 7*24*60*60
119
feed_fetcher = gPodderFetcher()
122
def build_factory(cls, download_dir):
123
def factory(dict, db):
124
return cls.create_from_dict(dict, db, download_dir)
128
def load_from_db(cls, db, download_dir):
129
return db.load_channels(factory=cls.build_factory(download_dir))
132
def load(cls, db, url, create=True, authentication_tokens=None,\
133
max_episodes=0, download_dir=None, allow_empty_feeds=False, \
135
if isinstance(url, unicode):
136
url = url.encode('utf-8')
138
tmp = db.load_channels(factory=cls.build_factory(download_dir), url=url)
142
tmp = PodcastChannel(db, download_dir)
144
if authentication_tokens is not None:
145
tmp.username = authentication_tokens[0]
146
tmp.password = authentication_tokens[1]
148
tmp.update(max_episodes, mimetype_prefs)
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:
155
raise Exception(_('No downloadable episodes in feed'))
158
def episode_factory(self, d, db__parameter_is_unused=None):
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.
164
Returns: A new PodcastEpisode object
166
return PodcastEpisode.create_from_dict(d, self)
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()
176
guids = [episode.guid for episode in self.get_all_episodes()]
178
# Insert newly-found episodes into the database
179
custom_feed.get_new_episodes(self, guids)
183
self.db.purge(max_episodes, self.id)
185
def _consume_updated_feed(self, feed, max_episodes=0, mimetype_prefs=''):
186
self.parse_error = feed.get('bozo_exception', None)
188
# Replace multi-space and newlines with single space (Maemo bug 11173)
189
self.title = re.sub('\s+', ' ', feed.feed.get('title', self.url))
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
200
self.pubDate = rfc822.mktime_tz(feed.feed.get('updated_parsed', None+(0,)))
202
self.pubDate = time.time()
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
211
if hasattr(feed.feed, 'icon'):
212
self.image = feed.feed.icon
216
# Load all episodes to update them properly.
217
existing = self.get_all_episodes()
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
226
entries = sorted(feed.entries, \
227
key=lambda x: x.get('updated_parsed', (0,)*9), \
228
reverse=True)[:max_episodes]
230
log('Could not sort episodes: %s', e, sender=self, traceback=True)
231
entries = feed.entries[:max_episodes]
233
entries = feed.entries
235
# Title + PubDate hashes for existing episodes
236
existing_dupes = dict((e.duplicate_id(), e) for e in existing)
238
# GUID-based existing episode list
239
existing_guids = dict((e.guid, e) for e in existing)
241
# Get most recent pubDate of all episodes
242
last_pubdate = self.db.get_last_pubdate(self) or 0
244
# Keep track of episode GUIDs currently seen in the feed
247
# Search all entries for new episodes
248
for entry in entries:
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)
260
log('Using download URL as GUID for episode %s.', \
261
episode.title, sender=self)
262
episode.guid = episode.url
264
seen_guids.add(episode.guid)
266
log('Cannot instantiate episode: %s. Skipping.', e, sender=self, traceback=True)
272
# Detect (and update) existing episode based on GUIDs
273
existing_episode = existing_guids.get(episode.guid, None)
275
existing_episode.update_from(episode)
276
existing_episode.save()
279
# Detect (and update) existing episode based on duplicate ID
280
existing_episode = existing_dupes.get(episode.duplicate_id(), None)
282
if existing_episode.is_duplicate(episode):
283
existing_episode.update_from(episode)
284
existing_episode.save()
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
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)
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)
313
def update_channel_lock(self):
314
self.db.update_channel_lock(self)
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)
321
def update(self, max_episodes=0, mimetype_prefs=''):
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)
328
except feedcore.UpdatedFeed, updated:
330
self._consume_updated_feed(feed, max_episodes, mimetype_prefs)
331
self._update_etag_modified(feed)
333
except feedcore.NewLocation, updated:
336
self._consume_updated_feed(feed, max_episodes, mimetype_prefs)
337
self._update_etag_modified(feed)
339
except feedcore.NotModified, updated:
341
self._update_etag_modified(feed)
344
# "Not really" errors
345
#feedcore.AuthenticationRequired
349
#feedcore.InternalServerError
352
#feedcore.Unsubscribe
354
#feedcore.InvalidFeed
355
#feedcore.UnknownStatusCode
358
if gpodder.user_hooks is not None:
359
gpodder.user_hooks.on_podcast_updated(self)
364
self.db.delete_channel(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
372
self.db.save_channel(self)
374
def get_statistics(self):
376
return (0, 0, 0, 0, 0)
378
return self.db.get_channel_count(int(self.id))
380
def authenticate_url(self, url):
381
return util.url_add_authentication(url, self.username, self.password)
383
def __init__(self, db, download_dir):
385
self.download_dir = download_dir
390
self.description = ''
393
self.parse_error = None
394
self.foldername = None
395
self.auto_foldername = 1 # automatically generated foldername
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 = ''
406
self.last_modified = None
409
self.save_dir_size = 0
410
self.__save_dir_size_set = False
412
self.channel_is_locked = False
414
self.release_expected = time.time() # <= DEPRECATED
415
self.release_deviation = 0 # <= DEPRECATED
416
self.updated_timestamp = 0
418
self.feed_update_enabled = True
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
425
def update_save_dir_size(self):
426
self.save_dir_size = util.calculate_size(self.save_dir)
428
def get_title( self):
429
if self.override_title:
430
return self.override_title
431
elif not self.__title.strip():
436
def set_title( self, value):
437
self.__title = value.strip()
439
title = property(fget=get_title,
442
def set_custom_title( self, custom_title):
443
custom_title = custom_title.strip()
445
# if the custom title is the same as we have
446
if custom_title == self.override_title:
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 == '':
453
# make sure self.foldername is initialized
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)
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
477
if custom_title != self.__title:
478
self.override_title = custom_title
480
self.override_title = ''
482
def get_downloaded_episodes(self):
483
return self.db.load_episodes(self, factory=self.episode_factory, state=gpodder.STATE_DOWNLOADED)
485
def get_new_episodes(self, downloading=lambda e: False):
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.
492
By default, "downloading" is implemented so that it
493
reports all episodes as not downloading.
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)]
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'
505
def update_m3u_playlist(self):
506
m3u_filename = self.get_playlist_filename()
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)
514
log('Writing playlist to %s', m3u_filename, sender=self)
515
util.write_m3u_playlist(m3u_filename, \
516
PodcastEpisode.sort_by_pubdate(downloaded_episodes))
518
def get_episode_by_url(self, url):
519
return self.db.load_single_episode(self, \
520
factory=self.episode_factory, url=url)
522
def get_episode_by_filename(self, filename):
523
return self.db.load_single_episode(self, \
524
factory=self.episode_factory, filename=filename)
526
def get_all_episodes(self):
527
return self.db.load_episodes(self, factory=self.episode_factory)
529
def find_unique_folder_name(self, foldername):
530
# Remove trailing dots to avoid errors on Windows (bug 600)
531
foldername = foldername.strip().rstrip('.')
533
current_try = util.sanitize_filename(foldername, \
534
self.MAX_FOLDERNAME_LENGTH)
538
if self.db.channel_foldername_exists(current_try):
539
current_try = '%s (%d)' % (foldername, next_try_id)
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)
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)
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
561
# Find a unique folder name for this podcast
562
wanted_foldername = self.find_unique_folder_name(fn_template)
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
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)
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
590
save_dir = os.path.join(self.download_dir, self.foldername)
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)
598
save_dir = property(fget=get_save_dir)
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)
606
# Remove the download directory
607
shutil.rmtree(self.save_dir, True)
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)
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)
627
episode.set_state(gpodder.STATE_DELETED)
630
class PodcastEpisode(PodcastModelObject):
631
"""holds data for one object in a channel"""
632
MAX_FILENAME_LENGTH = 200
634
def _get_played(self):
635
return self.is_played
637
def _set_played(self, played):
638
self.is_played = played
640
# Alias "is_played" to "played" for DB column mapping
641
played = property(fget=_get_played, fset=_set_played)
643
def _get_locked(self):
644
return self.is_locked
646
def _set_locked(self, locked):
647
self.is_locked = locked
649
# Alias "is_locked" to "locked" for DB column mapping
650
locked = property(fget=_get_locked, fset=_set_locked)
652
def _get_channel_id(self):
653
return self.channel.id
655
def _set_channel_id(self, channel_id):
656
assert self.channel.id == channel_id
658
# Accessor for the "channel_id" DB column
659
channel_id = property(fget=_get_channel_id, fset=_set_channel_id)
662
def sort_by_pubdate(episodes, reverse=False):
663
"""Sort a list of PodcastEpisode objects chronologically
665
Returns a iterable, sorted sequence of the episodes
667
key_pubdate = lambda e: e.pubDate
668
return sorted(episodes, key=key_pubdate, reverse=reverse)
670
def reload_from_db(self):
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)
678
d = self.db.load_episode(self.id)
679
self.update_from_dict(d or {})
682
def has_website_link(self):
683
return bool(self.link) and (self.link != self.url or \
684
youtube.is_video_link(self.link))
687
def from_feedparser_entry(entry, channel, mimetype_prefs=''):
688
episode = PodcastEpisode(channel)
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
697
episode.description = entry.get('summary', '')
700
# Parse iTunes-specific podcast duration metadata
701
total_time = util.parse_time(entry.get('itunes_duration', ''))
702
episode.total_time = total_time
706
# Fallback to subtitle if summary is not available0
707
if not episode.description:
708
episode.description = entry.get('subtitle', '')
710
episode.guid = entry.get('id', '')
711
if entry.get('updated_parsed', None):
712
episode.pubDate = rfc822.mktime_tz(entry.updated_parsed+(0,))
714
enclosures = entry.get('enclosures', ())
715
audio_available = any(e.get('type', '').startswith('audio/') \
717
video_available = any(e.get('type', '').startswith('video/') \
720
# Create the list of preferred mime types
721
mimetype_prefs = mimetype_prefs.split(',')
723
def calculate_preference_value(enclosure):
724
"""Calculate preference value of an enclosure
726
This is based on mime types and allows users to prefer
727
certain mime types over others (e.g. MP3 over AAC, ...)
729
mimetype = enclosure.get('type', None)
731
# If the mime type is found, return its (zero-based) index
732
return mimetype_prefs.index(mimetype)
734
# If it is not found, assume it comes after all listed items
735
return len(mimetype_prefs)
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'
745
if '/' not in episode.mimetype:
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):
753
episode.url = util.normalize_feed_url(e.get('href', ''))
758
episode.length = int(e.length) or -1
765
for m in entry.get('media_content', ()):
766
episode.mimetype = m.get('type', 'application/octet-stream')
767
if '/' not in episode.mimetype:
770
episode.url = util.normalize_feed_url(m.get('url', ''))
775
episode.length = int(m.fileSize) or -1
781
# Brute-force detection of any links
782
for l in entry.get('links', ()):
783
episode.url = util.normalize_feed_url(l.get('href', ''))
787
if youtube.is_video_link(episode.url):
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)
797
# The link points to a audio or video file - use it!
798
if file_type is not None:
801
# Scan MP3 links in description text
802
mp3s = re.compile(r'http://[^"]*\.mp3')
803
for content in entry.get('content', ()):
805
for match in mp3s.finditer(html):
806
episode.url = match.group(0)
811
def __init__(self, channel):
813
# Used by Storage for faster saving
818
self.mimetype = 'application/octet-stream'
820
self.description = ''
822
self.channel = channel
825
self.auto_filename = 1 # automatically generated filename
827
self.state = gpodder.STATE_NORMAL
828
self.is_played = False
830
# Initialize the "is_locked" property
831
self._is_locked = False
832
self.is_locked = channel.channel_is_locked
836
self.current_position = 0
837
self.current_position_updated = 0
839
def get_is_locked(self):
840
return self._is_locked
842
def set_is_locked(self, is_locked):
843
self._is_locked = bool(is_locked)
845
is_locked = property(fget=get_is_locked, fset=set_is_locked)
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)
854
def on_downloaded(self, filename):
855
self.state = gpodder.STATE_DOWNLOADED
856
self.is_played = False
857
self.length = os.path.getsize(filename)
859
self.db.save_downloaded_episode(self)
862
def set_state(self, state):
864
self.db.update_episode_state(self)
866
def mark(self, state=None, is_played=None, is_locked=None):
867
if state is not None:
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)
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))
881
def maemo_markup(self):
883
length_str = '%s; ' % self.filesize_prop
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)))
894
def maemo_remove_markup(self):
895
if self.total_time and self.current_position:
896
played_string = self.get_play_info_string()
898
played_string = _('played')
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))
912
def age_in_days(self):
913
return util.file_age_in_days(self.local_filename(create=False, \
916
age_int_prop = property(fget=age_in_days)
918
def get_age_string(self):
919
return util.file_age_to_string(self.age_in_days())
921
age_prop = property(fget=get_age_string)
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()
928
return _('No description available')
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] + '...'
938
def delete_from_disk(self):
940
self.channel.delete_episode(self)
942
log('Cannot delete episode from disk: %s', self.title, traceback=True, sender=self)
944
def find_unique_file_name(self, url, filename, extension):
945
current_try = util.sanitize_filename(filename, self.MAX_FILENAME_LENGTH)+extension
949
if self.filename == current_try and current_try is not None:
950
# We already have this filename - good!
953
while self.db.episode_filename_exists(current_try):
954
current_try = '%s (%d)%s' % (filename, next_try_id, extension)
959
def local_filename(self, create, force_update=False, check_only=False,
961
"""Get (and possibly generate) the local saving filename
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.
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.
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.
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.
983
If "template" is specified, it should be a filename that is to
984
be used as a template for generating the "real" filename.
986
The generated filename is stored in the database for future access.
988
ext = self.extension(may_call_local_filename=False).encode('utf-8', 'ignore')
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()
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
1002
return urldigest_filename
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
1009
if self.filename is None:
1012
return os.path.join(self.channel.save_dir, self.filename)
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)
1020
episode_filename, extension_UNUSED = util.filename_from_url(self.url)
1021
fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
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)
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)
1036
# Nicer download filenames for Soundcloud streams
1037
if fn_template == 'stream':
1038
sanitized = util.sanitize_filename(self.title, self.MAX_FILENAME_LENGTH)
1040
fn_template = sanitized
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
1047
# Find a unique filename for this episode
1048
wanted_filename = self.find_unique_file_name(self.url, fn_template, ext)
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
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)
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)
1073
log('Should update filename. Stays the same (%s). Good!', \
1074
wanted_filename, sender=self)
1075
self.filename = wanted_filename
1079
return os.path.join(self.channel.save_dir, self.filename)
1081
def set_mimetype(self, mimetype, commit=False):
1082
"""Sets the mimetype for this episode"""
1083
self.mimetype = mimetype
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)
1098
def check_is_new(self, downloading=lambda e: False):
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.
1105
return self.state == gpodder.STATE_NORMAL and \
1106
not self.is_played and \
1107
not downloading(self)
1110
self.state = gpodder.STATE_NORMAL
1111
self.is_played = False
1112
self.db.update_episode_state(self)
1115
self.is_played = True
1116
self.db.update_episode_state(self)
1118
def file_exists(self):
1119
filename = self.local_filename(create=False, check_only=True)
1120
if filename is None:
1123
return os.path.exists(filename)
1125
def was_downloaded(self, and_exists=False):
1126
if self.state != gpodder.STATE_DOWNLOADED:
1128
if and_exists and not self.file_exists():
1132
def sync_filename(self, use_custom=False, custom_format=None):
1134
return util.object_string_formatter(custom_format,
1135
episode=self, podcast=self.channel)
1139
def file_type(self):
1140
# Assume all YouTube links are video files
1141
if youtube.is_video_link(self.url):
1144
return util.file_type_by_extension(self.extension())
1147
def basename( self):
1148
return os.path.splitext( os.path.basename( self.url))[0]
1151
def published( self):
1153
Returns published date as YYYYMMDD (or 00000000 if not available)
1156
return datetime.datetime.fromtimestamp(self.pubDate).strftime('%Y%m%d')
1158
log( 'Cannot format pubDate for "%s".', self.title, sender = self)
1164
Returns published time as HHMM (or 0000 if not available)
1167
return datetime.datetime.fromtimestamp(self.pubDate).strftime('%H%M')
1169
log('Cannot format pubDate (time) for "%s".', self.title, sender=self)
1172
def playlist_title(self):
1173
"""Return a title for this episode in a playlist
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).
1180
return '%s - %s (%s)' % (self.channel.title, \
1182
self.cute_pubdate())
1184
def cute_pubdate(self):
1185
result = util.format_date(self.pubDate)
1187
return '(%s)' % _('unknown')
1191
pubdate_prop = property(fget=cute_pubdate)
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)
1198
self.length = os.path.getsize(filename)
1200
log( 'Could not get filesize for %s.', self.url)
1202
def is_finished(self):
1203
"""Return True if this episode is considered "finished playing"
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.
1210
return self.current_position > 0 and \
1211
(self.current_position + 10 >= self.total_time or \
1212
self.current_position >= self.total_time*.99)
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())
1221
return self.get_duration_string()
1223
def get_position_string(self):
1224
return util.format_time(self.current_position)
1226
def get_duration_string(self):
1227
return util.format_time(self.total_time)
1229
def get_filesize_string(self):
1230
return util.format_filesize(self.length)
1232
filesize_prop = property(fget=get_filesize_string)
1234
def get_played_string( self):
1235
if not self.is_played:
1236
return _('Unplayed')
1240
played_prop = property(fget=get_played_string)
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)
1248
def duplicate_id(self):
1249
return hash((self.title, self.pubDate))
1251
def update_from(self, episode):
1252
for k in ('title', 'url', 'description', 'link', 'pubDate', 'guid'):
1253
setattr(self, k, getattr(episode, k))