Cleaned-up and re-designed preferences dialog
[gpodder.git] / src / gpodder / model.py
blob9de3f3974ef9f3e4cb4015e26922f54e091d2f17
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 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)
26 import gpodder
27 from gpodder import util
28 from gpodder import feedcore
29 from gpodder import youtube
30 from gpodder import corestats
32 from gpodder.liblogger import log
34 import os
35 import re
36 import glob
37 import shutil
38 import urllib
39 import urlparse
40 import time
41 import datetime
42 import rfc822
43 import hashlib
44 import feedparser
45 import xml.sax.saxutils
47 _ = gpodder.gettext
50 class CustomFeed(feedcore.ExceptionWithData): pass
52 class gPodderFetcher(feedcore.Fetcher):
53 """
54 This class extends the feedcore Fetcher with the gPodder User-Agent and the
55 Proxy handler based on the current settings in gPodder and provides a
56 convenience method (fetch_channel) for use by PodcastChannel objects.
57 """
58 custom_handlers = []
60 def __init__(self):
61 feedcore.Fetcher.__init__(self, gpodder.user_agent)
63 def fetch_channel(self, channel):
64 etag = channel.etag
65 modified = feedparser._parse_date(channel.last_modified)
66 # If we have a username or password, rebuild the url with them included
67 # Note: using a HTTPBasicAuthHandler would be pain because we need to
68 # know the realm. It can be done, but I think this method works, too
69 url = channel.authenticate_url(channel.url)
70 for handler in self.custom_handlers:
71 custom_feed = handler.handle_url(url)
72 if custom_feed is not None:
73 raise CustomFeed(custom_feed)
74 self.fetch(url, etag, modified)
76 def _resolve_url(self, url):
77 return youtube.get_real_channel_url(url)
79 @classmethod
80 def register(cls, handler):
81 cls.custom_handlers.append(handler)
83 # def _get_handlers(self):
84 # # Add a ProxyHandler for fetching data via a proxy server
85 # proxies = {'http': 'http://proxy.example.org:8080'}
86 # return[urllib2.ProxyHandler(proxies))]
88 # The "register" method is exposed here for external usage
89 register_custom_handler = gPodderFetcher.register
91 class PodcastModelObject(object):
92 """
93 A generic base class for our podcast model providing common helper
94 and utility functions.
95 """
97 @classmethod
98 def create_from_dict(cls, d, *args):
99 """
100 Create a new object, passing "args" to the constructor
101 and then updating the object with the values from "d".
103 o = cls(*args)
104 o.update_from_dict(d)
105 return o
107 def update_from_dict(self, d):
109 Updates the attributes of this object with values from the
110 dictionary "d" by using the keys found in "d".
112 for k in d:
113 if hasattr(self, k):
114 setattr(self, k, d[k])
117 class PodcastChannel(PodcastModelObject):
118 """holds data for a complete channel"""
119 MAX_FOLDERNAME_LENGTH = 150
121 feed_fetcher = gPodderFetcher()
123 @classmethod
124 def build_factory(cls, download_dir):
125 def factory(dict, db):
126 return cls.create_from_dict(dict, db, download_dir)
127 return factory
129 @classmethod
130 def load_from_db(cls, db, download_dir):
131 return db.load_channels(factory=cls.build_factory(download_dir))
133 @classmethod
134 def load(cls, db, url, create=True, authentication_tokens=None,\
135 max_episodes=0, download_dir=None, allow_empty_feeds=False):
136 if isinstance(url, unicode):
137 url = url.encode('utf-8')
139 tmp = db.load_channels(factory=cls.build_factory(download_dir), url=url)
140 if len(tmp):
141 return tmp[0]
142 elif create:
143 tmp = PodcastChannel(db, download_dir)
144 tmp.url = url
145 if authentication_tokens is not None:
146 tmp.username = authentication_tokens[0]
147 tmp.password = authentication_tokens[1]
149 tmp.update(max_episodes)
150 tmp.save()
151 db.force_last_new(tmp)
152 # Subscribing to empty feeds should yield an error (except if
153 # the user specifically allows empty feeds in the config UI)
154 if sum(tmp.get_statistics()) == 0 and not allow_empty_feeds:
155 tmp.delete()
156 raise Exception(_('No downloadable episodes in feed'))
157 return tmp
159 def episode_factory(self, d, db__parameter_is_unused=None):
161 This function takes a dictionary containing key-value pairs for
162 episodes and returns a new PodcastEpisode object that is connected
163 to this PodcastChannel object.
165 Returns: A new PodcastEpisode object
167 return PodcastEpisode.create_from_dict(d, self)
169 def _consume_custom_feed(self, custom_feed, max_episodes=0):
170 self.title = custom_feed.get_title()
171 self.link = custom_feed.get_link()
172 self.description = custom_feed.get_description()
173 self.image = custom_feed.get_image()
174 self.pubDate = time.time()
175 self.save()
177 guids = [episode.guid for episode in self.get_all_episodes()]
178 self.count_new += custom_feed.get_new_episodes(self, guids)
179 self.save()
181 self.db.purge(max_episodes, self.id)
183 def _consume_updated_feed(self, feed, max_episodes=0):
184 self.parse_error = feed.get('bozo_exception', None)
186 self.title = feed.feed.get('title', self.url)
187 self.link = feed.feed.get('link', self.link)
188 self.description = feed.feed.get('subtitle', self.description)
189 # Start YouTube-specific title FIX
190 YOUTUBE_PREFIX = 'Uploads by '
191 if self.title.startswith(YOUTUBE_PREFIX):
192 self.title = self.title[len(YOUTUBE_PREFIX):] + ' on YouTube'
193 # End YouTube-specific title FIX
195 try:
196 self.pubDate = rfc822.mktime_tz(feed.feed.get('updated_parsed', None+(0,)))
197 except:
198 self.pubDate = time.time()
200 if hasattr(feed.feed, 'image'):
201 for attribute in ('href', 'url'):
202 new_value = getattr(feed.feed.image, attribute, None)
203 if new_value is not None:
204 log('Found cover art in %s: %s', attribute, new_value)
205 self.image = new_value
207 if hasattr(feed.feed, 'icon'):
208 self.image = feed.feed.icon
210 self.save()
212 # Load all episodes to update them properly.
213 existing = self.get_all_episodes()
215 # We can limit the maximum number of entries that gPodder will parse
216 if max_episodes > 0 and len(feed.entries) > max_episodes:
217 entries = feed.entries[:max_episodes]
218 else:
219 entries = feed.entries
221 # Title + PubDate hashes for existing episodes
222 existing_dupes = dict((e.duplicate_id(), e) for e in existing)
224 # GUID-based existing episode list
225 existing_guids = dict((e.guid, e) for e in existing)
227 # Search all entries for new episodes
228 for entry in entries:
229 try:
230 episode = PodcastEpisode.from_feedparser_entry(entry, self)
231 if episode is not None and not episode.title:
232 episode.title, ext = os.path.splitext(os.path.basename(episode.url))
233 except Exception, e:
234 log('Cannot instantiate episode: %s. Skipping.', e, sender=self, traceback=True)
235 continue
237 if episode is None:
238 continue
240 # Detect (and update) existing episode based on GUIDs
241 existing_episode = existing_guids.get(episode.guid, None)
242 if existing_episode:
243 existing_episode.update_from(episode)
244 existing_episode.save()
245 continue
247 # Detect (and update) existing episode based on duplicate ID
248 existing_episode = existing_dupes.get(episode.duplicate_id(), None)
249 if existing_episode:
250 if existing_episode.is_duplicate(episode):
251 existing_episode.update_from(episode)
252 existing_episode.save()
253 continue
255 # Otherwise we have found a new episode to store in the DB
256 self.count_new += 1
257 episode.save()
259 # Remove "unreachable" episodes - episodes that have not been
260 # downloaded and that the feed does not list as downloadable anymore
261 if self.id is not None:
262 seen_guids = set(e.guid for e in feed.entries if hasattr(e, 'guid'))
263 episodes_to_purge = (e for e in existing if \
264 e.state != gpodder.STATE_DOWNLOADED and \
265 e.guid not in seen_guids and e.guid is not None)
266 for episode in episodes_to_purge:
267 log('Episode removed from feed: %s (%s)', episode.title, \
268 episode.guid, sender=self)
269 self.db.delete_episode_by_guid(episode.guid, self.id)
271 # This *might* cause episodes to be skipped if there were more than
272 # max_episodes_per_feed items added to the feed between updates.
273 # The benefit is that it prevents old episodes from apearing as new
274 # in certain situations (see bug #340).
275 self.db.purge(max_episodes, self.id)
277 def update_channel_lock(self):
278 self.db.update_channel_lock(self)
280 def _update_etag_modified(self, feed):
281 self.updated_timestamp = time.time()
282 self.calculate_publish_behaviour()
283 self.etag = feed.headers.get('etag', self.etag)
284 self.last_modified = feed.headers.get('last-modified', self.last_modified)
286 def query_automatic_update(self):
287 """Query if this channel should be updated automatically
289 Returns True if the update should happen in automatic
290 mode or False if this channel should be skipped (timeout
291 not yet reached or release not expected right now).
293 updated = self.updated_timestamp
294 expected = self.release_expected
296 now = time.time()
297 one_day_ago = now - 60*60*24
298 lastcheck = now - 60*10
300 return updated < one_day_ago or \
301 (expected < now and updated < lastcheck)
303 def update(self, max_episodes=0):
304 try:
305 self.feed_fetcher.fetch_channel(self)
306 except CustomFeed, updated:
307 custom_feed = updated.data
308 self._consume_custom_feed(custom_feed, max_episodes)
309 self.save()
310 except feedcore.UpdatedFeed, updated:
311 feed = updated.data
312 self._consume_updated_feed(feed, max_episodes)
313 self._update_etag_modified(feed)
314 self.save()
315 except feedcore.NewLocation, updated:
316 feed = updated.data
317 self.url = feed.href
318 self._consume_updated_feed(feed, max_episodes)
319 self._update_etag_modified(feed)
320 self.save()
321 except feedcore.NotModified, updated:
322 feed = updated.data
323 self._update_etag_modified(feed)
324 self.save()
325 except Exception, e:
326 # "Not really" errors
327 #feedcore.AuthenticationRequired
328 # Temporary errors
329 #feedcore.Offline
330 #feedcore.BadRequest
331 #feedcore.InternalServerError
332 #feedcore.WifiLogin
333 # Permanent errors
334 #feedcore.Unsubscribe
335 #feedcore.NotFound
336 #feedcore.InvalidFeed
337 #feedcore.UnknownStatusCode
338 raise
340 self.db.commit()
342 def delete(self, purge=True):
343 self.db.delete_channel(self, purge)
345 def save(self):
346 self.db.save_channel(self)
348 def get_statistics(self):
349 if self.id is None:
350 return (0, 0, 0, 0, 0)
351 else:
352 return self.db.get_channel_count(int(self.id))
354 def authenticate_url(self, url):
355 return util.url_add_authentication(url, self.username, self.password)
357 def __init__(self, db, download_dir):
358 self.db = db
359 self.download_dir = download_dir
360 self.id = None
361 self.url = None
362 self.title = ''
363 self.link = ''
364 self.description = ''
365 self.image = None
366 self.pubDate = 0
367 self.parse_error = None
368 self.newest_pubdate_cached = None
369 self.foldername = None
370 self.auto_foldername = 1 # automatically generated foldername
372 # should this channel be synced to devices? (ex: iPod)
373 self.sync_to_devices = True
374 # to which playlist should be synced
375 self.device_playlist_name = 'gPodder'
376 # if set, this overrides the channel-provided title
377 self.override_title = ''
378 self.username = ''
379 self.password = ''
381 self.last_modified = None
382 self.etag = None
384 self.save_dir_size = 0
385 self.__save_dir_size_set = False
387 self.count_downloaded = 0
388 self.count_new = 0
389 self.count_unplayed = 0
391 self.channel_is_locked = False
393 self.release_expected = time.time()
394 self.release_deviation = 0
395 self.updated_timestamp = 0
397 def calculate_publish_behaviour(self):
398 episodes = self.db.load_episodes(self, factory=self.episode_factory, limit=30)
399 if len(episodes) < 3:
400 return
402 deltas = []
403 latest = max(e.pubDate for e in episodes)
404 for index in range(len(episodes)-1):
405 if episodes[index].pubDate != 0 and episodes[index+1].pubDate != 0:
406 deltas.append(episodes[index].pubDate - episodes[index+1].pubDate)
408 if len(deltas) > 1:
409 stats = corestats.Stats(deltas)
410 self.release_expected = min([latest+stats.stdev(), latest+(stats.min()+stats.avg())*.5])
411 self.release_deviation = stats.stdev()
412 else:
413 self.release_expected = latest
414 self.release_deviation = 0
416 def request_save_dir_size(self):
417 if not self.__save_dir_size_set:
418 self.update_save_dir_size()
419 self.__save_dir_size_set = True
421 def update_save_dir_size(self):
422 self.save_dir_size = util.calculate_size(self.save_dir)
424 def get_title( self):
425 if self.override_title:
426 return self.override_title
427 elif not self.__title.strip():
428 return self.url
429 else:
430 return self.__title
432 def set_title( self, value):
433 self.__title = value.strip()
435 title = property(fget=get_title,
436 fset=set_title)
438 def set_custom_title( self, custom_title):
439 custom_title = custom_title.strip()
441 # if the custom title is the same as we have
442 if custom_title == self.override_title:
443 return
445 # if custom title is the same as channel title and we didn't have a custom title
446 if custom_title == self.__title and self.override_title == '':
447 return
449 # make sure self.foldername is initialized
450 self.get_save_dir()
452 # rename folder if custom_title looks sane
453 new_folder_name = self.find_unique_folder_name(custom_title)
454 if len(new_folder_name) > 0 and new_folder_name != self.foldername:
455 log('Changing foldername based on custom title: %s', custom_title, sender=self)
456 new_folder = os.path.join(self.download_dir, new_folder_name)
457 old_folder = os.path.join(self.download_dir, self.foldername)
458 if os.path.exists(old_folder):
459 if not os.path.exists(new_folder):
460 # Old folder exists, new folder does not -> simply rename
461 log('Renaming %s => %s', old_folder, new_folder, sender=self)
462 os.rename(old_folder, new_folder)
463 else:
464 # Both folders exist -> move files and delete old folder
465 log('Moving files from %s to %s', old_folder, new_folder, sender=self)
466 for file in glob.glob(os.path.join(old_folder, '*')):
467 shutil.move(file, new_folder)
468 log('Removing %s', old_folder, sender=self)
469 shutil.rmtree(old_folder, ignore_errors=True)
470 self.foldername = new_folder_name
471 self.save()
473 if custom_title != self.__title:
474 self.override_title = custom_title
475 else:
476 self.override_title = ''
478 def get_downloaded_episodes(self):
479 return self.db.load_episodes(self, factory=self.episode_factory, state=gpodder.STATE_DOWNLOADED)
481 def get_new_episodes(self, downloading=lambda e: False):
483 Get a list of new episodes. You can optionally specify
484 "downloading" as a callback that takes an episode as
485 a parameter and returns True if the episode is currently
486 being downloaded or False if not.
488 By default, "downloading" is implemented so that it
489 reports all episodes as not downloading.
491 return [episode for episode in self.db.load_episodes(self, \
492 factory=self.episode_factory) if \
493 episode.check_is_new(downloading=downloading)]
495 def get_playlist_filename(self):
496 # If the save_dir doesn't end with a slash (which it really should
497 # not, if the implementation is correct, we can just append .m3u :)
498 assert self.save_dir[-1] != '/'
499 return self.save_dir+'.m3u'
501 def update_m3u_playlist(self):
502 m3u_filename = self.get_playlist_filename()
504 downloaded_episodes = self.get_downloaded_episodes()
505 if not downloaded_episodes:
506 log('No episodes - removing %s', m3u_filename, sender=self)
507 util.delete_file(m3u_filename)
508 return
510 log('Writing playlist to %s', m3u_filename, sender=self)
511 f = open(m3u_filename, 'w')
512 f.write('#EXTM3U\n')
514 for episode in PodcastEpisode.sort_by_pubdate(downloaded_episodes):
515 if episode.was_downloaded(and_exists=True):
516 filename = episode.local_filename(create=False)
517 assert filename is not None
519 if os.path.dirname(filename).startswith(os.path.dirname(m3u_filename)):
520 filename = filename[len(os.path.dirname(m3u_filename)+os.sep):]
521 f.write('#EXTINF:0,'+self.title+' - '+episode.title+' ('+episode.cute_pubdate()+')\n')
522 f.write(filename+'\n')
524 f.close()
526 def addDownloadedItem(self, item):
527 log('addDownloadedItem(%s)', item.url)
529 if not item.was_downloaded():
530 item.mark_downloaded(save=True)
531 self.update_m3u_playlist()
533 def get_all_episodes(self):
534 return self.db.load_episodes(self, factory=self.episode_factory)
536 def find_unique_folder_name(self, foldername):
537 # Remove trailing dots to avoid errors on Windows (bug 600)
538 foldername = foldername.strip().rstrip('.')
540 current_try = util.sanitize_filename(foldername, \
541 self.MAX_FOLDERNAME_LENGTH)
542 next_try_id = 2
544 while True:
545 if not os.path.exists(os.path.join(self.download_dir, current_try)):
546 self.db.remove_foldername_if_deleted_channel(current_try)
548 if self.db.channel_foldername_exists(current_try):
549 current_try = '%s (%d)' % (foldername, next_try_id)
550 next_try_id += 1
551 else:
552 return current_try
554 def get_save_dir(self):
555 urldigest = hashlib.md5(self.url).hexdigest()
556 sanitizedurl = util.sanitize_filename(self.url, self.MAX_FOLDERNAME_LENGTH)
557 if self.foldername is None or (self.auto_foldername and (self.foldername == urldigest or self.foldername.startswith(sanitizedurl))):
558 # we must change the folder name, because it has not been set manually
559 fn_template = util.sanitize_filename(self.title, self.MAX_FOLDERNAME_LENGTH)
561 # if this is an empty string, try the basename
562 if len(fn_template) == 0:
563 log('That is one ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
564 fn_template = util.sanitize_filename(os.path.basename(self.url), self.MAX_FOLDERNAME_LENGTH)
566 # If the basename is also empty, use the first 6 md5 hexdigest chars of the URL
567 if len(fn_template) == 0:
568 log('That is one REALLY ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
569 fn_template = urldigest # no need for sanitize_filename here
571 # Find a unique folder name for this podcast
572 wanted_foldername = self.find_unique_folder_name(fn_template)
574 # if the foldername has not been set, check if the (old) md5 filename exists
575 if self.foldername is None and os.path.exists(os.path.join(self.download_dir, urldigest)):
576 log('Found pre-0.15.0 download folder for %s: %s', self.title, urldigest, sender=self)
577 self.foldername = urldigest
579 # we have a valid, new folder name in "current_try" -> use that!
580 if self.foldername is not None and wanted_foldername != self.foldername:
581 # there might be an old download folder crawling around - move it!
582 new_folder_name = os.path.join(self.download_dir, wanted_foldername)
583 old_folder_name = os.path.join(self.download_dir, self.foldername)
584 if os.path.exists(old_folder_name):
585 if not os.path.exists(new_folder_name):
586 # Old folder exists, new folder does not -> simply rename
587 log('Renaming %s => %s', old_folder_name, new_folder_name, sender=self)
588 os.rename(old_folder_name, new_folder_name)
589 else:
590 # Both folders exist -> move files and delete old folder
591 log('Moving files from %s to %s', old_folder_name, new_folder_name, sender=self)
592 for file in glob.glob(os.path.join(old_folder_name, '*')):
593 shutil.move(file, new_folder_name)
594 log('Removing %s', old_folder_name, sender=self)
595 shutil.rmtree(old_folder_name, ignore_errors=True)
596 log('Updating foldername of %s to "%s".', self.url, wanted_foldername, sender=self)
597 self.foldername = wanted_foldername
598 self.save()
600 save_dir = os.path.join(self.download_dir, self.foldername)
602 # Create save_dir if it does not yet exist
603 if not util.make_directory( save_dir):
604 log( 'Could not create save_dir: %s', save_dir, sender = self)
606 return save_dir
608 save_dir = property(fget=get_save_dir)
610 def remove_downloaded( self):
611 shutil.rmtree( self.save_dir, True)
613 @property
614 def cover_file(self):
615 new_name = os.path.join(self.save_dir, 'folder.jpg')
616 if not os.path.exists(new_name):
617 old_names = ('cover', '.cover')
618 for old_name in old_names:
619 filename = os.path.join(self.save_dir, old_name)
620 if os.path.exists(filename):
621 shutil.move(filename, new_name)
622 return new_name
624 return new_name
626 def delete_episode_by_url(self, url):
627 episode = self.db.load_episode(url, factory=self.episode_factory)
629 if episode is not None:
630 filename = episode.local_filename(create=False)
631 if filename is not None:
632 util.delete_file(filename)
633 else:
634 log('Cannot delete episode: %s (I have no filename!)', episode.title, sender=self)
635 episode.set_state(gpodder.STATE_DELETED)
637 self.update_m3u_playlist()
640 class PodcastEpisode(PodcastModelObject):
641 """holds data for one object in a channel"""
642 MAX_FILENAME_LENGTH = 200
644 @staticmethod
645 def sort_by_pubdate(episodes, reverse=False):
646 """Sort a list of PodcastEpisode objects chronologically
648 Returns a iterable, sorted sequence of the episodes
650 key_pubdate = lambda e: e.pubDate
651 return sorted(episodes, key=key_pubdate, reverse=reverse)
653 def reload_from_db(self):
655 Re-reads all episode details for this object from the
656 database and updates this object accordingly. Can be
657 used to refresh existing objects when the database has
658 been updated (e.g. the filename has been set after a
659 download where it was not set before the download)
661 d = self.db.load_episode(self.url)
662 if d is not None:
663 self.update_from_dict(d)
665 return self
667 def has_website_link(self):
668 return bool(self.link) and (self.link != self.url)
670 @staticmethod
671 def from_feedparser_entry(entry, channel):
672 episode = PodcastEpisode(channel)
674 episode.title = entry.get('title', '')
675 episode.link = entry.get('link', '')
676 episode.description = entry.get('summary', '')
678 # Fallback to subtitle if summary is not available0
679 if not episode.description:
680 episode.description = entry.get('subtitle', '')
682 episode.guid = entry.get('id', '')
683 if entry.get('updated_parsed', None):
684 episode.pubDate = rfc822.mktime_tz(entry.updated_parsed+(0,))
686 # Enclosures
687 for e in entry.get('enclosures', ()):
688 episode.mimetype = e.get('type', 'application/octet-stream')
689 if '/' not in episode.mimetype:
690 continue
692 episode.url = util.normalize_feed_url(e.get('href', ''))
693 if not episode.url:
694 continue
696 try:
697 episode.length = int(e.length) or -1
698 except:
699 episode.length = -1
701 return episode
703 # Media RSS content
704 for m in entry.get('media_content', ()):
705 episode.mimetype = m.get('type', 'application/octet-stream')
706 if '/' not in episode.mimetype:
707 continue
709 episode.url = util.normalize_feed_url(m.get('url', ''))
710 if not episode.url:
711 continue
713 try:
714 episode.length = int(m.fileSize) or -1
715 except:
716 episode.length = -1
718 return episode
720 # Brute-force detection of any links
721 for l in entry.get('links', ()):
722 episode.url = util.normalize_feed_url(l.get('href', ''))
723 if not episode.url:
724 continue
726 if youtube.is_video_link(episode.url):
727 return episode
729 # Check if we can resolve this link to a audio/video file
730 filename, extension = util.filename_from_url(episode.url)
731 file_type = util.file_type_by_extension(extension)
732 if file_type is None and hasattr(l, 'type'):
733 extension = util.extension_from_mimetype(l.type)
734 file_type = util.file_type_by_extension(extension)
736 # The link points to a audio or video file - use it!
737 if file_type is not None:
738 return episode
740 # Scan MP3 links in description text
741 mp3s = re.compile(r'http://[^"]*\.mp3')
742 for content in entry.get('content', ()):
743 html = content.value
744 for match in mp3s.finditer(html):
745 episode.url = match.group(0)
746 return episode
748 return None
750 def __init__(self, channel):
751 self.db = channel.db
752 # Used by Storage for faster saving
753 self.id = None
754 self.url = ''
755 self.title = ''
756 self.length = 0
757 self.mimetype = 'application/octet-stream'
758 self.guid = ''
759 self.description = ''
760 self.link = ''
761 self.channel = channel
762 self.pubDate = 0
763 self.filename = None
764 self.auto_filename = 1 # automatically generated filename
766 self.state = gpodder.STATE_NORMAL
767 self.is_played = False
769 # Initialize the "is_locked" property
770 self._is_locked = False
771 self.is_locked = channel.channel_is_locked
773 def get_is_locked(self):
774 return self._is_locked
776 def set_is_locked(self, is_locked):
777 self._is_locked = bool(is_locked)
779 is_locked = property(fget=get_is_locked, fset=set_is_locked)
781 def save(self):
782 if self.state != gpodder.STATE_DOWNLOADED and self.file_exists():
783 self.state = gpodder.STATE_DOWNLOADED
784 self.db.save_episode(self)
786 def set_state(self, state):
787 self.state = state
788 self.db.mark_episode(self.url, state=self.state, is_played=self.is_played, is_locked=self.is_locked)
790 def mark(self, state=None, is_played=None, is_locked=None):
791 if state is not None:
792 self.state = state
793 if is_played is not None:
794 self.is_played = is_played
795 if is_locked is not None:
796 self.is_locked = is_locked
797 self.db.mark_episode(self.url, state=state, is_played=is_played, is_locked=is_locked)
799 def mark_downloaded(self, save=False):
800 self.state = gpodder.STATE_DOWNLOADED
801 self.is_played = False
802 if save:
803 self.save()
804 self.db.commit()
806 @property
807 def title_markup(self):
808 return '%s\n<small>%s</small>' % (xml.sax.saxutils.escape(self.title),
809 xml.sax.saxutils.escape(self.channel.title))
811 @property
812 def maemo_markup(self):
813 return ('<b>%s</b>\n<small>%s; '+_('released %s')+ \
814 '; '+_('from %s')+'</small>') % (\
815 xml.sax.saxutils.escape(self.title), \
816 xml.sax.saxutils.escape(self.filesize_prop), \
817 xml.sax.saxutils.escape(self.pubdate_prop), \
818 xml.sax.saxutils.escape(self.channel.title))
820 @property
821 def maemo_remove_markup(self):
822 if self.is_played:
823 played_string = _('played')
824 else:
825 played_string = _('unplayed')
826 downloaded_string = self.get_age_string()
827 if not downloaded_string:
828 downloaded_string = _('today')
829 return ('<b>%s</b>\n<small>%s; %s; '+_('downloaded %s')+ \
830 '; '+_('from %s')+'</small>') % (\
831 xml.sax.saxutils.escape(self.title), \
832 xml.sax.saxutils.escape(self.filesize_prop), \
833 xml.sax.saxutils.escape(played_string), \
834 xml.sax.saxutils.escape(downloaded_string), \
835 xml.sax.saxutils.escape(self.channel.title))
837 def age_in_days(self):
838 return util.file_age_in_days(self.local_filename(create=False, \
839 check_only=True))
841 def get_age_string(self):
842 return util.file_age_to_string(self.age_in_days())
844 age_prop = property(fget=get_age_string)
846 def one_line_description( self):
847 lines = util.remove_html_tags(self.description).strip().splitlines()
848 if not lines or lines[0] == '':
849 return _('No description available')
850 else:
851 return ' '.join(lines)
853 def delete_from_disk(self):
854 try:
855 self.channel.delete_episode_by_url(self.url)
856 except:
857 log('Cannot delete episode from disk: %s', self.title, traceback=True, sender=self)
859 def find_unique_file_name(self, url, filename, extension):
860 current_try = util.sanitize_filename(filename, self.MAX_FILENAME_LENGTH)+extension
861 next_try_id = 2
862 lookup_url = None
864 if self.filename == current_try and current_try is not None:
865 # We already have this filename - good!
866 return current_try
868 while self.db.episode_filename_exists(current_try):
869 current_try = '%s (%d)%s' % (filename, next_try_id, extension)
870 next_try_id += 1
872 return current_try
874 def local_filename(self, create, force_update=False, check_only=False,
875 template=None):
876 """Get (and possibly generate) the local saving filename
878 Pass create=True if you want this function to generate a
879 new filename if none exists. You only want to do this when
880 planning to create/download the file after calling this function.
882 Normally, you should pass create=False. This will only
883 create a filename when the file already exists from a previous
884 version of gPodder (where we used md5 filenames). If the file
885 does not exist (and the filename also does not exist), this
886 function will return None.
888 If you pass force_update=True to this function, it will try to
889 find a new (better) filename and move the current file if this
890 is the case. This is useful if (during the download) you get
891 more information about the file, e.g. the mimetype and you want
892 to include this information in the file name generation process.
894 If check_only=True is passed to this function, it will never try
895 to rename the file, even if would be a good idea. Use this if you
896 only want to check if a file exists.
898 If "template" is specified, it should be a filename that is to
899 be used as a template for generating the "real" filename.
901 The generated filename is stored in the database for future access.
903 ext = self.extension(may_call_local_filename=False).encode('utf-8', 'ignore')
905 # For compatibility with already-downloaded episodes, we
906 # have to know md5 filenames if they are downloaded already
907 urldigest = hashlib.md5(self.url).hexdigest()
909 if not create and self.filename is None:
910 urldigest_filename = os.path.join(self.channel.save_dir, urldigest+ext)
911 if os.path.exists(urldigest_filename):
912 # The file exists, so set it up in our database
913 log('Recovering pre-0.15.0 file: %s', urldigest_filename, sender=self)
914 self.filename = urldigest+ext
915 self.auto_filename = 1
916 self.save()
917 return urldigest_filename
918 return None
920 # We only want to check if the file exists, so don't try to
921 # rename the file, even if it would be reasonable. See also:
922 # http://bugs.gpodder.org/attachment.cgi?id=236
923 if check_only:
924 if self.filename is None:
925 return None
926 else:
927 return os.path.join(self.channel.save_dir, self.filename)
929 if self.filename is None or force_update or (self.auto_filename and self.filename == urldigest+ext):
930 # Try to find a new filename for the current file
931 if template is not None:
932 # If template is specified, trust the template's extension
933 episode_filename, ext = os.path.splitext(template)
934 else:
935 episode_filename, extension_UNUSED = util.filename_from_url(self.url)
936 fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
938 if 'redirect' in fn_template and template is None:
939 # This looks like a redirection URL - force URL resolving!
940 log('Looks like a redirection to me: %s', self.url, sender=self)
941 url = util.get_real_url(self.channel.authenticate_url(self.url))
942 log('Redirection resolved to: %s', url, sender=self)
943 (episode_filename, extension_UNUSED) = util.filename_from_url(url)
944 fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
946 # Use the video title for YouTube downloads
947 for yt_url in ('http://youtube.com/', 'http://www.youtube.com/'):
948 if self.url.startswith(yt_url):
949 fn_template = util.sanitize_filename(os.path.basename(self.title), self.MAX_FILENAME_LENGTH)
951 # If the basename is empty, use the md5 hexdigest of the URL
952 if len(fn_template) == 0 or fn_template.startswith('redirect.'):
953 log('Report to bugs.gpodder.org: Podcast at %s with episode URL: %s', self.channel.url, self.url, sender=self)
954 fn_template = urldigest
956 # Find a unique filename for this episode
957 wanted_filename = self.find_unique_file_name(self.url, fn_template, ext)
959 # We populate the filename field the first time - does the old file still exist?
960 if self.filename is None and os.path.exists(os.path.join(self.channel.save_dir, urldigest+ext)):
961 log('Found pre-0.15.0 downloaded file: %s', urldigest, sender=self)
962 self.filename = urldigest+ext
964 # The old file exists, but we have decided to want a different filename
965 if self.filename is not None and wanted_filename != self.filename:
966 # there might be an old download folder crawling around - move it!
967 new_file_name = os.path.join(self.channel.save_dir, wanted_filename)
968 old_file_name = os.path.join(self.channel.save_dir, self.filename)
969 if os.path.exists(old_file_name) and not os.path.exists(new_file_name):
970 log('Renaming %s => %s', old_file_name, new_file_name, sender=self)
971 os.rename(old_file_name, new_file_name)
972 elif force_update and not os.path.exists(old_file_name):
973 # When we call force_update, the file might not yet exist when we
974 # call it from the downloading code before saving the file
975 log('Choosing new filename: %s', new_file_name, sender=self)
976 else:
977 log('Warning: %s exists or %s does not.', new_file_name, old_file_name, sender=self)
978 log('Updating filename of %s to "%s".', self.url, wanted_filename, sender=self)
979 elif self.filename is None:
980 log('Setting filename to "%s".', wanted_filename, sender=self)
981 else:
982 log('Should update filename. Stays the same (%s). Good!', \
983 wanted_filename, sender=self)
984 self.filename = wanted_filename
985 self.save()
986 self.db.commit()
988 return os.path.join(self.channel.save_dir, self.filename)
990 def set_mimetype(self, mimetype, commit=False):
991 """Sets the mimetype for this episode"""
992 self.mimetype = mimetype
993 if commit:
994 self.db.commit()
996 def extension(self, may_call_local_filename=True):
997 filename, ext = util.filename_from_url(self.url)
998 if may_call_local_filename:
999 filename = self.local_filename(create=False)
1000 if filename is not None:
1001 filename, ext = os.path.splitext(filename)
1002 # if we can't detect the extension from the url fallback on the mimetype
1003 if ext == '' or util.file_type_by_extension(ext) is None:
1004 ext = util.extension_from_mimetype(self.mimetype)
1005 return ext
1007 def check_is_new(self, downloading=lambda e: False):
1009 Returns True if this episode is to be considered new.
1010 "Downloading" should be a callback that gets an episode
1011 as its parameter and returns True if the episode is
1012 being downloaded at the moment.
1014 return self.state == gpodder.STATE_NORMAL and \
1015 not self.is_played and \
1016 not downloading(self)
1018 def mark_new(self):
1019 self.state = gpodder.STATE_NORMAL
1020 self.is_played = False
1021 self.db.mark_episode(self.url, state=self.state, is_played=self.is_played)
1023 def mark_old(self):
1024 self.is_played = True
1025 self.db.mark_episode(self.url, is_played=True)
1027 def file_exists(self):
1028 filename = self.local_filename(create=False, check_only=True)
1029 if filename is None:
1030 return False
1031 else:
1032 return os.path.exists(filename)
1034 def was_downloaded(self, and_exists=False):
1035 if self.state != gpodder.STATE_DOWNLOADED:
1036 return False
1037 if and_exists and not self.file_exists():
1038 return False
1039 return True
1041 def sync_filename(self, use_custom=False, custom_format=None):
1042 if use_custom:
1043 return util.object_string_formatter(custom_format,
1044 episode=self, podcast=self.channel)
1045 else:
1046 return self.title
1048 def file_type( self):
1049 return util.file_type_by_extension( self.extension() )
1051 @property
1052 def basename( self):
1053 return os.path.splitext( os.path.basename( self.url))[0]
1055 @property
1056 def published( self):
1058 Returns published date as YYYYMMDD (or 00000000 if not available)
1060 try:
1061 return datetime.datetime.fromtimestamp(self.pubDate).strftime('%Y%m%d')
1062 except:
1063 log( 'Cannot format pubDate for "%s".', self.title, sender = self)
1064 return '00000000'
1066 @property
1067 def pubtime(self):
1069 Returns published time as HHMM (or 0000 if not available)
1071 try:
1072 return datetime.datetime.fromtimestamp(self.pubDate).strftime('%H%M')
1073 except:
1074 log('Cannot format pubDate (time) for "%s".', self.title, sender=self)
1075 return '0000'
1077 def cute_pubdate(self):
1078 result = util.format_date(self.pubDate)
1079 if result is None:
1080 return '(%s)' % _('unknown')
1081 else:
1082 return result
1084 pubdate_prop = property(fget=cute_pubdate)
1086 def calculate_filesize( self):
1087 filename = self.local_filename(create=False)
1088 if filename is None:
1089 log('calculate_filesized called, but filename is None!', sender=self)
1090 try:
1091 self.length = os.path.getsize(filename)
1092 except:
1093 log( 'Could not get filesize for %s.', self.url)
1095 def get_filesize_string(self):
1096 return util.format_filesize(self.length)
1098 filesize_prop = property(fget=get_filesize_string)
1100 def get_played_string( self):
1101 if not self.is_played:
1102 return _('Unplayed')
1104 return ''
1106 played_prop = property(fget=get_played_string)
1108 def is_duplicate(self, episode):
1109 if self.title == episode.title and self.pubDate == episode.pubDate:
1110 log('Possible duplicate detected: %s', self.title)
1111 return True
1112 return False
1114 def duplicate_id(self):
1115 return hash((self.title, self.pubDate))
1117 def update_from(self, episode):
1118 for k in ('title', 'url', 'description', 'link', 'pubDate', 'guid'):
1119 setattr(self, k, getattr(episode, k))