Save the database as soon as possible.
[gpodder.git] / src / gpodder / libpodcasts.py
blob6651fb694fd74fa0de8d283d15d814533533ea63
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 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 # libpodcasts.py -- data classes for gpodder
23 # thomas perl <thp@perli.net> 20051029
25 # Contains code based on:
26 # liblocdbwriter.py (2006-01-09)
27 # liblocdbreader.py (2006-01-10)
30 import gtk
31 import gobject
32 import pango
34 import gpodder
35 from gpodder import util
36 from gpodder import opml
37 from gpodder import cache
38 from gpodder import services
39 from gpodder import draw
40 from gpodder import libtagupdate
41 from gpodder import dumbshelve
42 from gpodder import resolver
44 from gpodder.liblogger import log
45 from gpodder.libgpodder import gl
46 from gpodder.dbsqlite import db
48 import os.path
49 import os
50 import glob
51 import shutil
52 import sys
53 import urllib
54 import urlparse
55 import time
56 import datetime
57 import rfc822
58 import hashlib
59 import xml.dom.minidom
60 import feedparser
62 from xml.sax import saxutils
65 if gpodder.interface == gpodder.MAEMO:
66 ICON_AUDIO_FILE = 'gnome-mime-audio-mp3'
67 ICON_VIDEO_FILE = 'gnome-mime-video-mp4'
68 ICON_DOWNLOADING = 'qgn_toolb_messagin_moveto'
69 ICON_DELETED = 'qgn_toolb_gene_deletebutton'
70 ICON_NEW = 'qgn_list_gene_favor'
71 else:
72 ICON_AUDIO_FILE = 'audio-x-generic'
73 ICON_VIDEO_FILE = 'video-x-generic'
74 ICON_DOWNLOADING = gtk.STOCK_GO_DOWN
75 ICON_DELETED = gtk.STOCK_DELETE
76 ICON_NEW = gtk.STOCK_ABOUT
79 class HTTPAuthError(Exception): pass
81 class podcastChannel(object):
82 """holds data for a complete channel"""
83 SETTINGS = ('sync_to_devices', 'device_playlist_name','override_title','username','password')
84 MAX_FOLDERNAME_LENGTH = 150
85 icon_cache = {}
87 fc = cache.Cache()
89 @classmethod
90 def load(cls, url, create=True, authentication_tokens=None):
91 if isinstance(url, unicode):
92 url = url.encode('utf-8')
94 tmp = db.load_channels(factory=lambda d: cls.create_from_dict(d), url=url)
95 if len(tmp):
96 return tmp[0]
97 elif create:
98 tmp = podcastChannel(url)
99 if authentication_tokens is not None:
100 tmp.username = authentication_tokens[0]
101 tmp.password = authentication_tokens[1]
102 success, error_code = tmp.update()
103 if not success:
104 if error_code == 401:
105 raise HTTPAuthError
106 else:
107 return None
108 tmp.save()
109 db.force_last_new(tmp)
110 return tmp
112 @staticmethod
113 def create_from_dict(d):
114 c = podcastChannel()
115 for key in d:
116 if hasattr(c, key):
117 setattr(c, key, d[key])
118 return c
120 def update(self):
121 (updated, c) = self.fc.fetch(self.url, self)
123 if c is None:
124 return ( False, None )
126 if c.status == 401:
127 return ( False, 401 )
129 if self.url != c.url:
130 log('Updating channel URL from %s to %s', self.url, c.url, sender=self)
131 self.url = c.url
133 # update the cover if it's not there
134 self.update_cover()
136 # If we have an old instance of this channel, and
137 # feedcache says the feed hasn't changed, return old
138 if not updated:
139 log('Channel %s is up to date', self.url)
140 return ( True, None )
142 # Save etag and last-modified for later reuse
143 if c.headers.get('etag'):
144 self.etag = c.headers.get('etag')
145 if c.headers.get('last-modified'):
146 self.last_modified = c.headers.get('last-modified')
148 self.parse_error = c.get('bozo_exception', None)
150 if hasattr(c.feed, 'title'):
151 self.title = c.feed.title
152 # Start YouTube-specific title FIX
153 YOUTUBE_PREFIX = 'Videos uploaded by '
154 if self.title.startswith(YOUTUBE_PREFIX):
155 self.title = self.title[len(YOUTUBE_PREFIX):] + ' on YouTube'
156 # End YouTube-specific title FIX
157 else:
158 self.title = self.url
159 if hasattr( c.feed, 'link'):
160 self.link = c.feed.link
161 if hasattr( c.feed, 'subtitle'):
162 self.description = c.feed.subtitle
164 if hasattr(c.feed, 'updated_parsed') and c.feed.updated_parsed is not None:
165 self.pubDate = rfc822.mktime_tz(c.feed.updated_parsed+(0,))
166 else:
167 self.pubDate = time.time()
168 if hasattr( c.feed, 'image'):
169 if hasattr(c.feed.image, 'href') and c.feed.image.href:
170 old = self.image
171 self.image = c.feed.image.href
172 if old != self.image:
173 self.update_cover(force=True)
175 # Marked as bulk because we commit after importing episodes.
176 db.save_channel(self, bulk=True)
178 # Remove old episodes before adding the new ones. This helps
179 # deal with hyperactive channels, such as TV news, when there
180 # can be more new episodes than the user wants in the list.
181 # By cleaning up old episodes before receiving the new ones we
182 # ensure that the user doesn't miss any.
183 db.purge(gl.config.max_episodes_per_feed, self.id)
185 # Load all episodes to update them properly.
186 existing = self.get_all_episodes()
188 # We can limit the maximum number of entries that gPodder will parse
189 # via the "max_episodes_per_feed" configuration option.
190 if len(c.entries) > gl.config.max_episodes_per_feed:
191 log('Limiting number of episodes for %s to %d', self.title, gl.config.max_episodes_per_feed)
192 for entry in c.entries[:min(gl.config.max_episodes_per_feed, len(c.entries))]:
193 episode = None
195 try:
196 episode = podcastItem.from_feedparser_entry(entry, self)
197 except Exception, e:
198 log('Cannot instantiate episode "%s": %s. Skipping.', entry.get('id', '(no id available)'), e, sender=self, traceback=True)
200 if episode:
201 self.count_new += 1
203 for ex in existing:
204 if ex.guid == episode.guid:
205 for k in ('title', 'title', 'description', 'link', 'pubDate'):
206 setattr(ex, k, getattr(episode, k))
207 self.count_new -= 1
208 episode = ex
210 episode.save(bulk=True)
212 db.commit()
213 return ( True, None )
215 def update_cover(self, force=False):
216 if self.cover_file is None or not os.path.exists(self.cover_file) or force:
217 if self.image is not None:
218 services.cover_downloader.request_cover(self)
220 def delete(self):
221 db.delete_channel(self)
223 def save(self):
224 db.save_channel(self)
226 def stat(self, state=None, is_played=None, is_locked=None):
227 return db.get_channel_stat(self.url, state=state, is_played=is_played, is_locked=is_locked)
229 def __init__( self, url = "", title = "", link = "", description = ""):
230 self.id = None
231 self.url = url
232 self.title = title
233 self.link = link
234 self.description = description
235 self.image = None
236 self.pubDate = 0
237 self.parse_error = None
238 self.newest_pubdate_cached = None
239 self.update_flag = False # channel is updating or to be updated
240 self.iter = None
241 self.foldername = None
242 self.auto_foldername = 1 # automatically generated foldername
244 # should this channel be synced to devices? (ex: iPod)
245 self.sync_to_devices = True
246 # to which playlist should be synced
247 self.device_playlist_name = 'gPodder'
248 # if set, this overrides the channel-provided title
249 self.override_title = ''
250 self.username = ''
251 self.password = ''
253 self.last_modified = None
254 self.etag = None
256 self.save_dir_size = 0
257 self.__save_dir_size_set = False
259 self.count_downloaded = 0
260 self.count_new = 0
261 self.count_unplayed = 0
263 self.channel_is_locked = False
265 def request_save_dir_size(self):
266 if not self.__save_dir_size_set:
267 self.update_save_dir_size()
268 self.__save_dir_size_set = True
270 def update_save_dir_size(self):
271 self.save_dir_size = util.calculate_size(self.save_dir)
273 def get_title( self):
274 if self.override_title:
275 return self.override_title
276 elif not self.__title.strip():
277 return self.url
278 else:
279 return self.__title
281 def set_title( self, value):
282 self.__title = value.strip()
284 title = property(fget=get_title,
285 fset=set_title)
287 def set_custom_title( self, custom_title):
288 custom_title = custom_title.strip()
290 # make sure self.foldername is initialized
291 self.get_save_dir()
293 # rename folder if custom_title looks sane
294 new_folder_name = self.find_unique_folder_name(custom_title)
295 if len(new_folder_name) > 0 and new_folder_name != self.foldername:
296 log('Changing foldername based on custom title: %s', custom_title, sender=self)
297 new_folder = os.path.join(gl.downloaddir, new_folder_name)
298 old_folder = os.path.join(gl.downloaddir, self.foldername)
299 if os.path.exists(old_folder):
300 if not os.path.exists(new_folder):
301 # Old folder exists, new folder does not -> simply rename
302 log('Renaming %s => %s', old_folder, new_folder, sender=self)
303 os.rename(old_folder, new_folder)
304 else:
305 # Both folders exist -> move files and delete old folder
306 log('Moving files from %s to %s', old_folder, new_folder, sender=self)
307 for file in glob.glob(os.path.join(old_folder, '*')):
308 shutil.move(file, new_folder)
309 log('Removing %s', old_folder, sender=self)
310 shutil.rmtree(old_folder, ignore_errors=True)
311 self.foldername = new_folder_name
312 self.save()
314 if custom_title != self.__title:
315 self.override_title = custom_title
316 else:
317 self.override_title = ''
319 def get_downloaded_episodes(self):
320 return db.load_episodes(self, factory=lambda c: podcastItem.create_from_dict(c, self), state=db.STATE_DOWNLOADED)
322 def save_settings(self):
323 db.save_channel(self)
325 def get_new_episodes( self):
326 return [episode for episode in db.load_episodes(self, factory=lambda x: podcastItem.create_from_dict(x, self)) if episode.state == db.STATE_NORMAL and not episode.is_played and not services.download_status_manager.is_download_in_progress(episode.url)]
328 def update_m3u_playlist(self):
329 if gl.config.create_m3u_playlists:
330 downloaded_episodes = self.get_downloaded_episodes()
331 fn = util.sanitize_filename(self.title)
332 if len(fn) == 0:
333 fn = os.path.basename(self.save_dir)
334 m3u_filename = os.path.join(gl.downloaddir, fn+'.m3u')
335 log('Writing playlist to %s', m3u_filename, sender=self)
336 f = open(m3u_filename, 'w')
337 f.write('#EXTM3U\n')
339 for episode in downloaded_episodes:
340 if episode.was_downloaded(and_exists=True):
341 filename = episode.local_filename(create=False)
342 assert filename is not None
344 if os.path.dirname(filename).startswith(os.path.dirname(m3u_filename)):
345 filename = filename[len(os.path.dirname(m3u_filename)+os.sep):]
346 f.write('#EXTINF:0,'+self.title+' - '+episode.title+' ('+episode.cute_pubdate()+')\n')
347 f.write(filename+'\n')
348 f.close()
350 def addDownloadedItem(self, item):
351 log('addDownloadedItem(%s)', item.url)
353 if not item.was_downloaded():
354 item.mark_downloaded(save=True)
356 # Update metadata on file (if possible and wanted)
357 if gl.config.update_tags and libtagupdate.tagging_supported():
358 filename = item.local_filename(create=False)
359 assert filename is not None
361 try:
362 libtagupdate.update_metadata_on_file(filename, title=item.title, artist=self.title, genre='Podcast')
363 except Exception, e:
364 log('Error while calling update_metadata_on_file(): %s', e)
366 self.update_m3u_playlist()
368 def get_all_episodes(self):
369 return db.load_episodes(self, factory = lambda d: podcastItem.create_from_dict(d, self))
371 def iter_set_downloading_columns( self, model, iter, episode=None):
372 global ICON_AUDIO_FILE, ICON_VIDEO_FILE
373 global ICON_DOWNLOADING, ICON_DELETED, ICON_NEW
375 if episode is None:
376 url = model.get_value( iter, 0)
377 episode = db.load_episode(url, factory=lambda x: podcastItem.create_from_dict(x, self))
378 else:
379 url = episode.url
381 if gl.config.episode_list_descriptions or gpodder.interface == gpodder.MAEMO:
382 icon_size = 32
383 else:
384 icon_size = 16
386 if services.download_status_manager.is_download_in_progress(url):
387 status_icon = util.get_tree_icon(ICON_DOWNLOADING, icon_cache=self.icon_cache, icon_size=icon_size)
388 else:
389 if episode.state == db.STATE_NORMAL:
390 if episode.is_played:
391 status_icon = None
392 else:
393 status_icon = util.get_tree_icon(ICON_NEW, icon_cache=self.icon_cache, icon_size=icon_size)
394 elif episode.was_downloaded():
395 missing = not episode.file_exists()
397 if missing:
398 log('Episode missing: %s (before drawing an icon)', episode.url, sender=self)
400 file_type = util.file_type_by_extension( model.get_value( iter, 9))
401 if file_type == 'audio':
402 status_icon = util.get_tree_icon(ICON_AUDIO_FILE, not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
403 elif file_type == 'video':
404 status_icon = util.get_tree_icon(ICON_VIDEO_FILE, not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
405 else:
406 status_icon = util.get_tree_icon('unknown', not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
407 elif episode.state == db.STATE_DELETED or episode.state == db.STATE_DOWNLOADED:
408 status_icon = util.get_tree_icon(ICON_DELETED, not episode.is_played, icon_cache=self.icon_cache, icon_size=icon_size)
409 else:
410 log('Warning: Cannot determine status icon.', sender=self)
411 status_icon = None
413 model.set( iter, 4, status_icon)
415 def get_tree_model(self):
417 Return a gtk.ListStore containing episodes for this channel
419 new_model = gtk.ListStore( gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING,
420 gobject.TYPE_BOOLEAN, gtk.gdk.Pixbuf, gobject.TYPE_STRING, gobject.TYPE_STRING,
421 gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING )
423 log('Returning TreeModel for %s', self.url, sender = self)
424 urls = []
425 for item in self.get_all_episodes():
426 description = item.title_and_description
428 if item.length > 0:
429 filelength = gl.format_filesize(item.length, 1)
430 else:
431 filelength = None
433 new_iter = new_model.append((item.url, item.title, filelength,
434 True, None, item.cute_pubdate(), description, util.remove_html_tags(item.description),
435 'XXXXXXXXXXXXXUNUSEDXXXXXXXXXXXXXXXXXXX', item.extension()))
436 self.iter_set_downloading_columns( new_model, new_iter, episode=item)
437 urls.append(item.url)
439 self.update_save_dir_size()
440 return (new_model, urls)
442 def find_episode( self, url):
443 return db.load_episode(url, factory=lambda x: podcastItem.create_from_dict(x, self))
445 @classmethod
446 def find_unique_folder_name(cls, foldername):
447 current_try = util.sanitize_filename(foldername, cls.MAX_FOLDERNAME_LENGTH)
448 next_try_id = 2
450 while db.channel_foldername_exists(current_try) and \
451 not os.path.exists(os.path.join(gl.downloaddir, current_try)):
452 current_try = '%s (%d)' % (foldername, next_try_id)
453 next_try_id += 1
455 return current_try
457 def get_save_dir(self):
458 urldigest = hashlib.md5(self.url).hexdigest()
459 sanitizedurl = util.sanitize_filename(self.url, self.MAX_FOLDERNAME_LENGTH)
460 if self.foldername is None or (self.auto_foldername and (self.foldername == urldigest or self.foldername == sanitizedurl)):
461 # we must change the folder name, because it has not been set manually
462 fn_template = util.sanitize_filename(self.title, self.MAX_FOLDERNAME_LENGTH)
464 # if this is an empty string, try the basename
465 if len(fn_template) == 0:
466 log('That is one ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
467 fn_template = util.sanitize_filename(os.path.basename(self.url), self.MAX_FOLDERNAME_LENGTH)
469 # If the basename is also empty, use the first 6 md5 hexdigest chars of the URL
470 if len(fn_template) == 0:
471 log('That is one REALLY ugly feed you have here! (Report this to bugs.gpodder.org: %s)', self.url, sender=self)
472 fn_template = urldigest # no need for sanitize_filename here
474 # Find a unique folder name for this podcast
475 wanted_foldername = self.find_unique_folder_name(fn_template)
477 # if the foldername has not been set, check if the (old) md5 filename exists
478 if self.foldername is None and os.path.exists(os.path.join(gl.downloaddir, urldigest)):
479 log('Found pre-0.15.0 download folder for %s: %s', self.title, urldigest, sender=self)
480 self.foldername = urldigest
482 # we have a valid, new folder name in "current_try" -> use that!
483 if self.foldername is not None and wanted_foldername != self.foldername:
484 # there might be an old download folder crawling around - move it!
485 new_folder_name = os.path.join(gl.downloaddir, wanted_foldername)
486 old_folder_name = os.path.join(gl.downloaddir, self.foldername)
487 if os.path.exists(old_folder_name):
488 if not os.path.exists(new_folder_name):
489 # Old folder exists, new folder does not -> simply rename
490 log('Renaming %s => %s', old_folder_name, new_folder_name, sender=self)
491 os.rename(old_folder_name, new_folder_name)
492 else:
493 # Both folders exist -> move files and delete old folder
494 log('Moving files from %s to %s', old_folder_name, new_folder_name, sender=self)
495 for file in glob.glob(os.path.join(old_folder_name, '*')):
496 shutil.move(file, new_folder_name)
497 log('Removing %s', old_folder_name, sender=self)
498 shutil.rmtree(old_folder_name, ignore_errors=True)
499 log('Updating foldername of %s to "%s".', self.url, wanted_foldername, sender=self)
500 self.foldername = wanted_foldername
501 self.save()
503 save_dir = os.path.join(gl.downloaddir, self.foldername)
505 # Create save_dir if it does not yet exist
506 if not util.make_directory( save_dir):
507 log( 'Could not create save_dir: %s', save_dir, sender = self)
509 return save_dir
511 save_dir = property(fget=get_save_dir)
513 def remove_downloaded( self):
514 shutil.rmtree( self.save_dir, True)
516 def get_index_file(self):
517 # gets index xml filename for downloaded channels list
518 return os.path.join( self.save_dir, 'index.xml')
520 index_file = property(fget=get_index_file)
522 def get_cover_file( self):
523 # gets cover filename for cover download cache
524 return os.path.join( self.save_dir, 'cover')
526 cover_file = property(fget=get_cover_file)
528 def delete_episode_by_url(self, url):
529 episode = db.load_episode(url, lambda c: podcastItem.create_from_dict(c, self))
531 if episode is not None:
532 filename = episode.local_filename(create=False)
533 if filename is not None:
534 util.delete_file(filename)
535 else:
536 log('Cannot delete episode: %s (I have no filename!)', episode.title, sender=self)
537 episode.set_state(db.STATE_DELETED)
539 self.update_m3u_playlist()
542 class podcastItem(object):
543 """holds data for one object in a channel"""
544 MAX_FILENAME_LENGTH = 200
546 @staticmethod
547 def load(url, channel):
548 e = podcastItem(channel)
549 d = db.load_episode(url)
550 if d is not None:
551 for k, v in d.iteritems():
552 if hasattr(e, k):
553 setattr(e, k, v)
554 return e
556 @staticmethod
557 def from_feedparser_entry( entry, channel):
558 episode = podcastItem( channel)
560 episode.title = entry.get( 'title', util.get_first_line( util.remove_html_tags( entry.get( 'summary', ''))))
561 episode.link = entry.get( 'link', '')
562 episode.description = entry.get( 'summary', entry.get( 'link', entry.get( 'title', '')))
563 episode.guid = entry.get( 'id', '')
564 if entry.get( 'updated_parsed', None):
565 episode.pubDate = rfc822.mktime_tz(entry.updated_parsed+(0,))
567 if episode.title == '':
568 log( 'Warning: Episode has no title, adding anyways.. (Feed Is Buggy!)', sender = episode)
570 enclosure = None
571 if hasattr(entry, 'enclosures') and len(entry.enclosures) > 0:
572 enclosure = entry.enclosures[0]
573 if len(entry.enclosures) > 1:
574 for e in entry.enclosures:
575 if hasattr( e, 'href') and hasattr( e, 'length') and hasattr( e, 'type') and (e.type.startswith('audio/') or e.type.startswith('video/')):
576 if util.normalize_feed_url(e.href) is not None:
577 log( 'Selected enclosure: %s', e.href, sender = episode)
578 enclosure = e
579 break
580 episode.url = util.normalize_feed_url( enclosure.get( 'href', ''))
581 elif hasattr(entry, 'link'):
582 (filename, extension) = util.filename_from_url(entry.link)
583 if extension == '' and hasattr( entry, 'type'):
584 extension = util.extension_from_mimetype(e.type)
585 file_type = util.file_type_by_extension(extension)
586 if file_type is not None:
587 log('Adding episode with link to file type "%s".', file_type, sender=episode)
588 episode.url = entry.link
590 # YouTube specific
591 if not episode.url and hasattr(entry, 'links') and len(entry.links) and hasattr(entry.links[0], 'href'):
592 episode.url = entry.links[0].href
594 if not episode.url:
595 log('Episode has no URL')
596 log('Episode: %s', episode)
597 log('Entry: %s', entry)
598 # This item in the feed has no downloadable enclosure
599 return None
601 if not episode.pubDate:
602 metainfo = util.get_episode_info_from_url(episode.url)
603 if 'pubdate' in metainfo:
604 try:
605 episode.pubDate = int(float(metainfo['pubdate']))
606 except:
607 log('Cannot convert pubDate "%s" in from_feedparser_entry.', str(metainfo['pubdate']), traceback=True)
609 if hasattr(enclosure, 'length'):
610 try:
611 episode.length = int(enclosure.length)
612 except:
613 episode.length = -1
615 if hasattr( enclosure, 'type'):
616 episode.mimetype = enclosure.type
618 if episode.title == '':
619 ( filename, extension ) = os.path.splitext( os.path.basename( episode.url))
620 episode.title = filename
622 return episode
625 def __init__( self, channel):
626 # Used by Storage for faster saving
627 self.id = None
628 self.url = ''
629 self.title = ''
630 self.length = 0
631 self.mimetype = 'application/octet-stream'
632 self.guid = ''
633 self.description = ''
634 self.link = ''
635 self.channel = channel
636 self.pubDate = 0
637 self.filename = None
638 self.auto_filename = 1 # automatically generated filename
640 self.state = db.STATE_NORMAL
641 self.is_played = False
642 self.is_locked = channel.channel_is_locked
644 def save(self, bulk=False):
645 if self.state != db.STATE_DOWNLOADED and self.file_exists():
646 self.state = db.STATE_DOWNLOADED
647 db.save_episode(self, bulk=bulk)
649 def set_state(self, state):
650 self.state = state
651 db.mark_episode(self.url, state=self.state, is_played=self.is_played, is_locked=self.is_locked)
653 def mark(self, state=None, is_played=None, is_locked=None):
654 if state is not None:
655 self.state = state
656 if is_played is not None:
657 self.is_played = is_played
658 if is_locked is not None:
659 self.is_locked = is_locked
660 db.mark_episode(self.url, state=state, is_played=is_played, is_locked=is_locked)
662 def mark_downloaded(self, save=False):
663 self.state = db.STATE_DOWNLOADED
664 self.is_played = False
665 if save:
666 self.save()
667 db.commit()
669 @staticmethod
670 def create_from_dict(d, channel):
671 e = podcastItem(channel)
672 for key in d:
673 if hasattr(e, key):
674 setattr(e, key, d[key])
675 return e
677 @property
678 def title_and_description(self):
680 Returns Pango markup for displaying in a TreeView, and
681 disables the description when the config variable
682 "episode_list_descriptions" is not set.
684 if gl.config.episode_list_descriptions and gpodder.interface != gpodder.MAEMO:
685 return '%s\n<small>%s</small>' % (saxutils.escape(self.title), saxutils.escape(self.one_line_description()))
686 else:
687 return saxutils.escape(self.title)
689 def age_in_days(self):
690 return util.file_age_in_days(self.local_filename(create=False))
692 def is_old(self):
693 return self.age_in_days() > gl.config.episode_old_age
695 def get_age_string(self):
696 return util.file_age_to_string(self.age_in_days())
698 age_prop = property(fget=get_age_string)
700 def one_line_description( self):
701 lines = util.remove_html_tags(self.description).strip().splitlines()
702 if not lines or lines[0] == '':
703 return _('No description available')
704 else:
705 return ' '.join(lines)
707 def delete_from_disk(self):
708 try:
709 self.channel.delete_episode_by_url(self.url)
710 except:
711 log('Cannot delete episode from disk: %s', self.title, traceback=True, sender=self)
713 @classmethod
714 def find_unique_file_name(cls, url, filename, extension):
715 current_try = util.sanitize_filename(filename, cls.MAX_FILENAME_LENGTH)+extension
716 next_try_id = 2
717 lookup_url = None
719 while db.episode_filename_exists(current_try):
720 if next_try_id == 2:
721 # If we arrive here, current_try has a collision, so
722 # try to resolve the URL for a better basename
723 log('Filename collision: %s - trying to resolve...', current_try)
724 url = util.get_real_url(url)
725 (episode_filename, extension_UNUSED) = util.filename_from_url(url)
726 current_try = util.sanitize_filename(episode_filename, cls.MAX_FILENAME_LENGTH)
727 if not db.episode_filename_exists(current_try):
728 log('Filename %s is available - collision resolved.', current_try)
729 return current_try
730 else:
731 log('Continuing search with %s as basename...', current_try)
733 current_try = '%s (%d)%s' % (filename, next_try_id, extension)
734 next_try_id += 1
736 return current_try
738 def local_filename(self, create, force_update=False):
739 """Get (and possibly generate) the local saving filename
741 Pass create=True if you want this function to generate a
742 new filename if none exists. You only want to do this when
743 planning to create/download the file after calling this function.
745 Normally, you should pass create=False. This will only
746 create a filename when the file already exists from a previous
747 version of gPodder (where we used md5 filenames). If the file
748 does not exist (and the filename also does not exist), this
749 function will return None.
751 If you pass force_update=True to this function, it will try to
752 find a new (better) filename and move the current file if this
753 is the case. This is useful if (during the download) you get
754 more information about the file, e.g. the mimetype and you want
755 to include this information in the file name generation process.
757 The generated filename is stored in the database for future access.
759 ext = self.extension()
761 # For compatibility with already-downloaded episodes, we
762 # have to know md5 filenames if they are downloaded already
763 urldigest = hashlib.md5(self.url).hexdigest()
765 if not create and self.filename is None:
766 urldigest_filename = os.path.join(self.channel.save_dir, urldigest+ext)
767 if os.path.exists(urldigest_filename):
768 # The file exists, so set it up in our database
769 log('Recovering pre-0.15.0 file: %s', urldigest_filename, sender=self)
770 self.filename = urldigest+ext
771 self.auto_filename = 1
772 self.save()
773 return urldigest_filename
774 return None
776 if self.filename is None or force_update or (self.auto_filename and self.filename == urldigest+ext):
777 # Try to find a new filename for the current file
778 (episode_filename, extension_UNUSED) = util.filename_from_url(self.url)
779 fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
781 if 'redirect' in fn_template:
782 # This looks like a redirection URL - force URL resolving!
783 log('Looks like a redirection to me: %s', self.url, sender=self)
784 url = util.get_real_url(self.url)
785 log('Redirection resolved to: %s', url, sender=self)
786 (episode_filename, extension_UNUSED) = util.filename_from_url(url)
787 fn_template = util.sanitize_filename(episode_filename, self.MAX_FILENAME_LENGTH)
789 # If the basename is empty, use the md5 hexdigest of the URL
790 if len(fn_template) == 0 or fn_template.startswith('redirect.'):
791 log('Report to bugs.gpodder.org: Podcast at %s with episode URL: %s', self.channel.url, self.url, sender=self)
792 fn_template = urldigest
794 # Find a unique filename for this episode
795 wanted_filename = self.find_unique_file_name(self.url, fn_template, ext)
797 # We populate the filename field the first time - does the old file still exist?
798 if self.filename is None and os.path.exists(os.path.join(self.channel.save_dir, urldigest+ext)):
799 log('Found pre-0.15.0 downloaded file: %s', urldigest, sender=self)
800 self.filename = urldigest+ext
802 # The old file exists, but we have decided to want a different filename
803 if self.filename is not None and wanted_filename != self.filename:
804 # there might be an old download folder crawling around - move it!
805 new_file_name = os.path.join(self.channel.save_dir, wanted_filename)
806 old_file_name = os.path.join(self.channel.save_dir, self.filename)
807 if os.path.exists(old_file_name) and not os.path.exists(new_file_name):
808 log('Renaming %s => %s', old_file_name, new_file_name, sender=self)
809 os.rename(old_file_name, new_file_name)
810 else:
811 log('Warning: %s exists or %s does not.', new_file_name, old_file_name, sender=self)
812 log('Updating filename of %s to "%s".', self.url, wanted_filename, sender=self)
813 self.filename = wanted_filename
814 self.save()
816 return os.path.join(self.channel.save_dir, self.filename)
818 def extension( self):
819 ( filename, ext ) = util.filename_from_url(self.url)
820 # if we can't detect the extension from the url fallback on the mimetype
821 if ext == '' or util.file_type_by_extension(ext) is None:
822 ext = util.extension_from_mimetype(self.mimetype)
823 #log('Getting extension from mimetype for: %s (mimetype: %s)' % (self.title, ext), sender=self)
824 return ext
826 def mark_new(self):
827 self.state = db.STATE_NORMAL
828 self.is_played = False
829 db.mark_episode(self.url, state=self.state, is_played=self.is_played)
831 def mark_old(self):
832 self.is_played = True
833 db.mark_episode(self.url, is_played=True)
835 def file_exists(self):
836 filename = self.local_filename(create=False)
837 if filename is None:
838 return False
839 else:
840 return os.path.exists(filename)
842 def was_downloaded(self, and_exists=False):
843 if self.state != db.STATE_DOWNLOADED:
844 return False
845 if and_exists and not self.file_exists():
846 return False
847 return True
849 def sync_filename( self):
850 if gl.config.custom_sync_name_enabled:
851 if '{channel' in gl.config.custom_sync_name:
852 log('Fixing OLD syntax {channel.*} => {podcast.*} in custom_sync_name.', sender=self)
853 gl.config.custom_sync_name = gl.config.custom_sync_name.replace('{channel.', '{podcast.')
854 return util.object_string_formatter(gl.config.custom_sync_name, episode=self, podcast=self.channel)
855 else:
856 return self.title
858 def file_type( self):
859 return util.file_type_by_extension( self.extension() )
861 @property
862 def basename( self):
863 return os.path.splitext( os.path.basename( self.url))[0]
865 @property
866 def published( self):
868 Returns published date as YYYYMMDD (or 00000000 if not available)
870 try:
871 return datetime.datetime.fromtimestamp(self.pubDate).strftime('%Y%m%d')
872 except:
873 log( 'Cannot format pubDate for "%s".', self.title, sender = self)
874 return '00000000'
876 @property
877 def pubtime(self):
879 Returns published time as HHMM (or 0000 if not available)
881 try:
882 return datetime.datetime.fromtimestamp(self.pubDate).strftime('%H%M')
883 except:
884 log('Cannot format pubDate (time) for "%s".', self.title, sender=self)
885 return '0000'
887 def cute_pubdate(self):
888 result = util.format_date(self.pubDate)
889 if result is None:
890 return '(%s)' % _('unknown')
891 else:
892 return result
894 pubdate_prop = property(fget=cute_pubdate)
896 def calculate_filesize( self):
897 filename = self.local_filename(create=False)
898 if filename is None:
899 log('calculate_filesized called, but filename is None!', sender=self)
900 try:
901 self.length = os.path.getsize(filename)
902 except:
903 log( 'Could not get filesize for %s.', self.url)
905 def get_filesize_string( self):
906 return gl.format_filesize( self.length)
908 filesize_prop = property(fget=get_filesize_string)
910 def get_channel_title( self):
911 return self.channel.title
913 channel_prop = property(fget=get_channel_title)
915 def get_played_string( self):
916 if not self.is_played:
917 return _('Unplayed')
919 return ''
921 played_prop = property(fget=get_played_string)
925 def update_channel_model_by_iter( model, iter, channel, color_dict,
926 cover_cache=None, max_width=0, max_height=0, initialize_all=False):
928 count_downloaded = channel.stat(state=db.STATE_DOWNLOADED)
929 count_new = channel.stat(state=db.STATE_NORMAL, is_played=False)
930 count_unplayed = channel.stat(state=db.STATE_DOWNLOADED, is_played=False)
932 channel.iter = iter
933 if initialize_all:
934 model.set(iter, 0, channel.url)
936 model.set(iter, 1, channel.title)
937 title_markup = saxutils.escape(channel.title)
938 description_markup = saxutils.escape(util.get_first_line(channel.description) or _('No description available'))
939 d = []
940 if count_new:
941 d.append('<span weight="bold">')
942 d.append(title_markup)
943 if count_new:
944 d.append('</span>')
946 description = ''.join(d+['\n', '<small>', description_markup, '</small>'])
947 model.set(iter, 2, description)
949 if channel.parse_error is not None:
950 model.set(iter, 6, channel.parse_error)
951 color = color_dict['parse_error']
952 else:
953 color = color_dict['default']
955 if channel.update_flag:
956 color = color_dict['updating']
958 model.set(iter, 8, color)
960 if count_unplayed > 0 or count_downloaded > 0:
961 model.set(iter, 3, draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded)))
962 model.set(iter, 7, True)
963 else:
964 model.set(iter, 7, False)
966 if initialize_all:
967 # Load the cover if we have it, but don't download
968 # it if it's not available (to avoid blocking here)
969 pixbuf = services.cover_downloader.get_cover(channel, avoid_downloading=True)
970 new_pixbuf = None
971 if pixbuf is not None:
972 new_pixbuf = util.resize_pixbuf_keep_ratio(pixbuf, max_width, max_height, channel.url, cover_cache)
973 model.set(iter, 5, new_pixbuf or pixbuf)
975 def channels_to_model(channels, color_dict, cover_cache=None, max_width=0, max_height=0):
976 new_model = gtk.ListStore( str, str, str, gtk.gdk.Pixbuf, int,
977 gtk.gdk.Pixbuf, str, bool, str )
979 urls = []
980 for channel in channels:
981 update_channel_model_by_iter(new_model, new_model.append(), channel,
982 color_dict, cover_cache, max_width, max_height, True)
983 urls.append(channel.url)
985 return (new_model, urls)
988 def load_channels():
989 return db.load_channels(lambda d: podcastChannel.create_from_dict(d))
991 def update_channels(callback_proc=None, callback_error=None, is_cancelled_cb=None):
992 log('Updating channels....')
994 channels = load_channels()
995 count = 0
997 for channel in channels:
998 if is_cancelled_cb is not None and is_cancelled_cb():
999 return channels
1000 callback_proc and callback_proc(count, len(channels))
1001 channel.update()
1002 count += 1
1004 return channels
1006 def save_channels( channels):
1007 exporter = opml.Exporter(gl.channel_opml_file)
1008 return exporter.write(channels)
1010 def can_restore_from_opml():
1011 try:
1012 if len(opml.Importer(gl.channel_opml_file).items):
1013 return gl.channel_opml_file
1014 except:
1015 return None
1019 class LocalDBReader( object):
1021 DEPRECATED - Only used for migration to SQLite
1023 def __init__( self, url):
1024 self.url = url
1026 def get_text( self, nodelist):
1027 return ''.join( [ node.data for node in nodelist if node.nodeType == node.TEXT_NODE ])
1029 def get_text_by_first_node( self, element, name):
1030 return self.get_text( element.getElementsByTagName( name)[0].childNodes)
1032 def get_episode_from_element( self, channel, element):
1033 episode = podcastItem( channel)
1034 episode.title = self.get_text_by_first_node( element, 'title')
1035 episode.description = self.get_text_by_first_node( element, 'description')
1036 episode.url = self.get_text_by_first_node( element, 'url')
1037 episode.link = self.get_text_by_first_node( element, 'link')
1038 episode.guid = self.get_text_by_first_node( element, 'guid')
1040 if not episode.guid:
1041 for k in ('url', 'link'):
1042 if getattr(episode, k) is not None:
1043 episode.guid = getattr(episode, k)
1044 log('Notice: episode has no guid, using %s', episode.guid)
1045 break
1046 try:
1047 episode.pubDate = float(self.get_text_by_first_node(element, 'pubDate'))
1048 except:
1049 log('Looks like you have an old pubDate in your LocalDB -> converting it')
1050 episode.pubDate = self.get_text_by_first_node(element, 'pubDate')
1051 log('FYI: pubDate value is: "%s"', episode.pubDate, sender=self)
1052 pubdate = feedparser._parse_date(episode.pubDate)
1053 if pubdate is None:
1054 log('Error converting the old pubDate - sorry!', sender=self)
1055 episode.pubDate = 0
1056 else:
1057 log('PubDate converted successfully - yay!', sender=self)
1058 episode.pubDate = time.mktime(pubdate)
1059 try:
1060 episode.mimetype = self.get_text_by_first_node( element, 'mimetype')
1061 except:
1062 log('No mimetype info for %s', episode.url, sender=self)
1063 episode.calculate_filesize()
1064 return episode
1066 def load_and_clean( self, filename):
1068 Clean-up a LocalDB XML file that could potentially contain
1069 "unbound prefix" XML elements (generated by the old print-based
1070 LocalDB code). The code removes those lines to make the new
1071 DOM parser happy.
1073 This should be removed in a future version.
1075 lines = []
1076 for line in open(filename).read().split('\n'):
1077 if not line.startswith('<gpodder:info'):
1078 lines.append( line)
1080 return '\n'.join( lines)
1082 def read( self, filename):
1083 doc = xml.dom.minidom.parseString( self.load_and_clean( filename))
1084 rss = doc.getElementsByTagName('rss')[0]
1086 channel_element = rss.getElementsByTagName('channel')[0]
1088 channel = podcastChannel( url = self.url)
1089 channel.title = self.get_text_by_first_node( channel_element, 'title')
1090 channel.description = self.get_text_by_first_node( channel_element, 'description')
1091 channel.link = self.get_text_by_first_node( channel_element, 'link')
1093 episodes = []
1094 for episode_element in rss.getElementsByTagName('item'):
1095 episode = self.get_episode_from_element( channel, episode_element)
1096 episodes.append(episode)
1098 return episodes