Add {podcast.title} for custom_sync_name (bug 234)
[gpodder.git] / src / gpodder / libpodcasts.py
blob89b6a6a25c439360a8dff44debf956cbc3d4ef02
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 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 md5
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 icon_cache = {}
86 fc = cache.Cache()
88 @classmethod
89 def load(cls, url, create=True, authentication_tokens=None):
90 if isinstance(url, unicode):
91 url = url.encode('utf-8')
93 tmp = db.load_channels(factory=lambda d: cls.create_from_dict(d), url=url)
94 if len(tmp):
95 return tmp[0]
96 elif create:
97 tmp = podcastChannel(url)
98 if authentication_tokens is not None:
99 tmp.username = authentication_tokens[0]
100 tmp.password = authentication_tokens[1]
101 success, error_code = tmp.update()
102 if not success:
103 if error_code == 401:
104 raise HTTPAuthError
105 else:
106 return None
107 tmp.save()
108 db.force_last_new(tmp)
109 return tmp
111 @staticmethod
112 def create_from_dict(d):
113 c = podcastChannel()
114 for key in d:
115 if hasattr(c, key):
116 setattr(c, key, d[key])
117 return c
119 def update(self):
120 (updated, c) = self.fc.fetch(self.url, self)
122 if c is None:
123 return ( False, None )
125 if c.status == 401:
126 return ( False, 401 )
128 if self.url != c.url:
129 log('Updating channel URL from %s to %s', self.url, c.url, sender=self)
130 self.url = c.url
132 # update the cover if it's not there
133 self.update_cover()
135 # If we have an old instance of this channel, and
136 # feedcache says the feed hasn't changed, return old
137 if not updated:
138 log('Channel %s is up to date', self.url)
139 return ( True, None )
141 # Save etag and last-modified for later reuse
142 if c.headers.get('etag'):
143 self.etag = c.headers.get('etag')
144 if c.headers.get('last-modified'):
145 self.last_modified = c.headers.get('last-modified')
147 self.parse_error = c.get('bozo_exception', None)
149 if hasattr(c.feed, 'title'):
150 self.title = c.feed.title
151 else:
152 self.title = self.url
153 if hasattr( c.feed, 'link'):
154 self.link = c.feed.link
155 if hasattr( c.feed, 'subtitle'):
156 self.description = c.feed.subtitle
158 if hasattr(c.feed, 'updated_parsed') and c.feed.updated_parsed is not None:
159 self.pubDate = rfc822.mktime_tz(c.feed.updated_parsed+(0,))
160 else:
161 self.pubDate = time.time()
162 if hasattr( c.feed, 'image'):
163 if hasattr(c.feed.image, 'href') and c.feed.image.href:
164 old = self.image
165 self.image = c.feed.image.href
166 if old != self.image:
167 self.update_cover(force=True)
169 # Marked as bulk because we commit after importing episodes.
170 db.save_channel(self, bulk=True)
172 # Remove old episodes before adding the new ones. This helps
173 # deal with hyperactive channels, such as TV news, when there
174 # can be more new episodes than the user wants in the list.
175 # By cleaning up old episodes before receiving the new ones we
176 # ensure that the user doesn't miss any.
177 db.purge(gl.config.max_episodes_per_feed, self.id)
179 # Load all episodes to update them properly.
180 existing = self.get_all_episodes()
182 # We can limit the maximum number of entries that gPodder will parse
183 # via the "max_episodes_per_feed" configuration option.
184 if len(c.entries) > gl.config.max_episodes_per_feed:
185 log('Limiting number of episodes for %s to %d', self.title, gl.config.max_episodes_per_feed)
186 for entry in c.entries[:min(gl.config.max_episodes_per_feed, len(c.entries))]:
187 episode = None
189 try:
190 episode = podcastItem.from_feedparser_entry(entry, self)
191 except Exception, e:
192 log('Cannot instantiate episode "%s": %s. Skipping.', entry.get('id', '(no id available)'), e, sender=self, traceback=True)
194 if episode:
195 self.count_new += 1
197 for ex in existing:
198 if ex.guid == episode.guid:
199 for k in ('title', 'title', 'description', 'link', 'pubDate'):
200 setattr(ex, k, getattr(episode, k))
201 self.count_new -= 1
202 episode = ex
204 if not episode.length:
205 episode.length = resolver.get_real_episode_length(episode)
207 episode.save(bulk=True)
209 return ( True, None )
211 def update_cover(self, force=False):
212 if self.cover_file is None or not os.path.exists(self.cover_file) or force:
213 if self.image is not None:
214 services.cover_downloader.request_cover(self)
216 def delete(self):
217 db.delete_channel(self)
219 def save(self):
220 db.save_channel(self)
222 def stat(self, state=None, is_played=None, is_locked=None):
223 return db.get_channel_stat(self.url, state=state, is_played=is_played, is_locked=is_locked)
225 def __init__( self, url = "", title = "", link = "", description = ""):
226 self.id = None
227 self.url = url
228 self.title = title
229 self.link = link
230 self.description = description
231 self.image = None
232 self.pubDate = 0
233 self.parse_error = None
234 self.newest_pubdate_cached = None
235 self.update_flag = False # channel is updating or to be updated
236 self.iter = None
238 # should this channel be synced to devices? (ex: iPod)
239 self.sync_to_devices = True
240 # to which playlist should be synced
241 self.device_playlist_name = 'gPodder'
242 # if set, this overrides the channel-provided title
243 self.override_title = ''
244 self.username = ''
245 self.password = ''
247 self.last_modified = None
248 self.etag = None
250 self.save_dir_size = 0
251 self.__save_dir_size_set = False
253 self.count_downloaded = 0
254 self.count_new = 0
255 self.count_unplayed = 0
257 self.channel_is_locked = False
259 def request_save_dir_size(self):
260 if not self.__save_dir_size_set:
261 self.update_save_dir_size()
262 self.__save_dir_size_set = True
264 def update_save_dir_size(self):
265 self.save_dir_size = util.calculate_size(self.save_dir)
267 def get_filename( self):
268 """Return the MD5 sum of the channel URL"""
269 return md5.new( self.url).hexdigest()
271 filename = property(fget=get_filename)
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 if custom_title != self.__title:
291 self.override_title = custom_title
292 else:
293 self.override_title = ''
295 def get_downloaded_episodes(self):
296 return db.load_episodes(self, factory=lambda c: podcastItem.create_from_dict(c, self), state=db.STATE_DOWNLOADED)
298 def save_settings(self):
299 db.save_channel(self)
301 def get_new_episodes( self):
302 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]
304 def update_m3u_playlist(self):
305 if gl.config.create_m3u_playlists:
306 downloaded_episodes = self.get_downloaded_episodes()
307 fn = util.sanitize_filename(self.title)
308 if len(fn) == 0:
309 fn = os.path.basename(self.save_dir)
310 m3u_filename = os.path.join(gl.downloaddir, fn+'.m3u')
311 log('Writing playlist to %s', m3u_filename, sender=self)
312 f = open(m3u_filename, 'w')
313 f.write('#EXTM3U\n')
315 for episode in downloaded_episodes:
316 filename = episode.local_filename()
317 if os.path.dirname(filename).startswith(os.path.dirname(m3u_filename)):
318 filename = filename[len(os.path.dirname(m3u_filename)+os.sep):]
319 f.write('#EXTINF:0,'+self.title+' - '+episode.title+' ('+episode.cute_pubdate()+')\n')
320 f.write(filename+'\n')
321 f.close()
323 def addDownloadedItem(self, item):
324 log('addDownloadedItem(%s)', item.url)
326 if not item.was_downloaded():
327 item.mark_downloaded(save=True)
329 # Update metadata on file (if possible and wanted)
330 if gl.config.update_tags and libtagupdate.tagging_supported():
331 filename = item.local_filename()
332 try:
333 libtagupdate.update_metadata_on_file(filename, title=item.title, artist=self.title, genre='Podcast')
334 except Exception, e:
335 log('Error while calling update_metadata_on_file(): %s', e)
337 self.update_m3u_playlist()
339 def get_all_episodes(self):
340 return db.load_episodes(self, factory = lambda d: podcastItem.create_from_dict(d, self))
342 # not used anymore
343 def update_model( self):
344 self.update_save_dir_size()
345 model = self.tree_model
347 iter = model.get_iter_first()
348 while iter is not None:
349 self.iter_set_downloading_columns(model, iter)
350 iter = model.iter_next( iter)
352 @property
353 def tree_model( self):
354 log('Returning TreeModel for %s', self.url, sender = self)
355 return self.items_liststore()
357 def iter_set_downloading_columns( self, model, iter, episode=None):
358 global ICON_AUDIO_FILE, ICON_VIDEO_FILE
359 global ICON_DOWNLOADING, ICON_DELETED, ICON_NEW
361 if episode is None:
362 url = model.get_value( iter, 0)
363 episode = db.load_episode(url, factory=lambda x: podcastItem.create_from_dict(x, self))
364 else:
365 url = episode.url
367 if gl.config.episode_list_descriptions:
368 icon_size = 32
369 else:
370 icon_size = 16
372 if services.download_status_manager.is_download_in_progress(url):
373 status_icon = util.get_tree_icon(ICON_DOWNLOADING, icon_cache=self.icon_cache, icon_size=icon_size)
374 else:
375 if episode.state == db.STATE_NORMAL:
376 if episode.is_played:
377 status_icon = None
378 else:
379 status_icon = util.get_tree_icon(ICON_NEW, icon_cache=self.icon_cache, icon_size=icon_size)
380 elif episode.was_downloaded():
381 missing = not episode.file_exists()
383 if missing:
384 log('Episode missing: %s (before drawing an icon)', episode.url, sender=self)
386 file_type = util.file_type_by_extension( model.get_value( iter, 9))
387 if file_type == 'audio':
388 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)
389 elif file_type == 'video':
390 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)
391 else:
392 status_icon = util.get_tree_icon('unknown', not episode.is_played, episode.is_locked, not episode.file_exists(), self.icon_cache, icon_size)
393 elif episode.state == db.STATE_DELETED or episode.state == db.STATE_DOWNLOADED:
394 status_icon = util.get_tree_icon(ICON_DELETED, not episode.is_played, icon_cache=self.icon_cache, icon_size=icon_size)
395 else:
396 log('Warning: Cannot determine status icon.', sender=self)
397 status_icon = None
399 model.set( iter, 4, status_icon)
401 def items_liststore( self):
403 Return a gtk.ListStore containing episodes for this channel
405 new_model = gtk.ListStore( gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING,
406 gobject.TYPE_BOOLEAN, gtk.gdk.Pixbuf, gobject.TYPE_STRING, gobject.TYPE_STRING,
407 gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING )
409 for item in self.get_all_episodes():
410 description = item.title_and_description
412 if item.length:
413 filelength = gl.format_filesize(item.length, 1)
414 else:
415 filelength = None
417 new_iter = new_model.append((item.url, item.title, filelength,
418 True, None, item.cute_pubdate(), description, util.remove_html_tags(item.description),
419 item.local_filename(), item.extension()))
420 self.iter_set_downloading_columns( new_model, new_iter, episode=item)
422 self.update_save_dir_size()
423 return new_model
425 def find_episode( self, url):
426 return db.load_episode(url, factory=lambda x: podcastItem.create_from_dict(x, self))
428 def get_save_dir(self):
429 save_dir = os.path.join(gl.downloaddir, self.filename, '')
431 # Create save_dir if it does not yet exist
432 if not util.make_directory( save_dir):
433 log( 'Could not create save_dir: %s', save_dir, sender = self)
435 return save_dir
437 save_dir = property(fget=get_save_dir)
439 def remove_downloaded( self):
440 shutil.rmtree( self.save_dir, True)
442 def get_index_file(self):
443 # gets index xml filename for downloaded channels list
444 return os.path.join( self.save_dir, 'index.xml')
446 index_file = property(fget=get_index_file)
448 def get_cover_file( self):
449 # gets cover filename for cover download cache
450 return os.path.join( self.save_dir, 'cover')
452 cover_file = property(fget=get_cover_file)
454 def delete_episode_by_url(self, url):
455 episode = db.load_episode(url, lambda c: podcastItem.create_from_dict(c, self))
457 if episode is not None:
458 util.delete_file(episode.local_filename())
459 episode.set_state(db.STATE_DELETED)
461 self.update_m3u_playlist()
464 class podcastItem(object):
465 """holds data for one object in a channel"""
467 @staticmethod
468 def load(url, channel):
469 e = podcastItem(channel)
470 d = db.load_episode(url)
471 if d is not None:
472 for k, v in d.iteritems():
473 if hasattr(e, k):
474 setattr(e, k, v)
475 return e
477 @staticmethod
478 def from_feedparser_entry( entry, channel):
479 episode = podcastItem( channel)
481 episode.title = entry.get( 'title', util.get_first_line( util.remove_html_tags( entry.get( 'summary', ''))))
482 episode.link = entry.get( 'link', '')
483 episode.description = entry.get( 'summary', entry.get( 'link', entry.get( 'title', '')))
484 episode.guid = entry.get( 'id', '')
485 if entry.get( 'updated_parsed', None):
486 episode.pubDate = rfc822.mktime_tz(entry.updated_parsed+(0,))
488 if episode.title == '':
489 log( 'Warning: Episode has no title, adding anyways.. (Feed Is Buggy!)', sender = episode)
491 enclosure = None
492 if hasattr(entry, 'enclosures') and len(entry.enclosures) > 0:
493 enclosure = entry.enclosures[0]
494 if len(entry.enclosures) > 1:
495 for e in entry.enclosures:
496 if hasattr( e, 'href') and hasattr( e, 'length') and hasattr( e, 'type') and (e.type.startswith('audio/') or e.type.startswith('video/')):
497 if util.normalize_feed_url(e.href) is not None:
498 log( 'Selected enclosure: %s', e.href, sender = episode)
499 enclosure = e
500 break
501 episode.url = util.normalize_feed_url( enclosure.get( 'href', ''))
502 elif hasattr(entry, 'link'):
503 (filename, extension) = util.filename_from_url(entry.link)
504 if extension == '' and hasattr( entry, 'type'):
505 extension = util.extension_from_mimetype(e.type)
506 file_type = util.file_type_by_extension(extension)
507 if file_type is not None:
508 log('Adding episode with link to file type "%s".', file_type, sender=episode)
509 episode.url = entry.link
511 if not episode.url:
512 # This item in the feed has no downloadable enclosure
513 return None
515 if not episode.pubDate:
516 metainfo = util.get_episode_info_from_url(episode.url)
517 if 'pubdate' in metainfo:
518 try:
519 episode.pubDate = int(float(metainfo['pubdate']))
520 except:
521 log('Cannot convert pubDate "%s" in from_feedparser_entry.', str(metainfo['pubdate']), traceback=True)
523 if hasattr( enclosure, 'length') and episode.length:
524 try:
525 episode.length = int(enclosure.length)
526 except:
527 episode.length = -1
529 if hasattr( enclosure, 'type'):
530 episode.mimetype = enclosure.type
532 if episode.title == '':
533 ( filename, extension ) = os.path.splitext( os.path.basename( episode.url))
534 episode.title = filename
536 return episode
539 def __init__( self, channel):
540 # Used by Storage for faster saving
541 self.id = None
542 self.url = ''
543 self.title = ''
544 self.length = 0
545 self.mimetype = 'application/octet-stream'
546 self.guid = ''
547 self.description = ''
548 self.link = ''
549 self.channel = channel
550 self.pubDate = None
552 self.state = db.STATE_NORMAL
553 self.is_played = False
554 self.is_locked = channel.channel_is_locked
556 def save(self, bulk=False):
557 if self.state != db.STATE_DOWNLOADED and self.file_exists():
558 self.state = db.STATE_DOWNLOADED
559 db.save_episode(self, bulk=bulk)
561 def set_state(self, state):
562 self.state = state
563 db.mark_episode(self.url, state=self.state, is_played=self.is_played, is_locked=self.is_locked)
565 def mark(self, state=None, is_played=None, is_locked=None):
566 if state is not None:
567 self.state = state
568 if is_played is not None:
569 self.is_played = is_played
570 if is_locked is not None:
571 self.is_locked = is_locked
572 db.mark_episode(self.url, state=state, is_played=is_played, is_locked=is_locked)
574 def mark_downloaded(self, save=False):
575 self.state = db.STATE_DOWNLOADED
576 self.is_played = False
577 if save:
578 self.save()
580 @staticmethod
581 def create_from_dict(d, channel):
582 e = podcastItem(channel)
583 for key in d:
584 if hasattr(e, key):
585 setattr(e, key, d[key])
586 return e
588 @property
589 def title_and_description(self):
591 Returns Pango markup for displaying in a TreeView, and
592 disables the description when the config variable
593 "episode_list_descriptions" is not set.
595 if gl.config.episode_list_descriptions:
596 return '%s\n<small>%s</small>' % (saxutils.escape(self.title), saxutils.escape(self.one_line_description()))
597 else:
598 return saxutils.escape(self.title)
600 def age_in_days(self):
601 return util.file_age_in_days(self.local_filename())
603 def is_old(self):
604 return self.age_in_days() > gl.config.episode_old_age
606 def get_age_string(self):
607 return util.file_age_to_string(self.age_in_days())
609 age_prop = property(fget=get_age_string)
611 def one_line_description( self):
612 lines = util.remove_html_tags(self.description).strip().splitlines()
613 if not lines or lines[0] == '':
614 return _('No description available')
615 else:
616 return ' '.join((l.strip() for l in lines if l.strip() != ''))
618 def delete_from_disk(self):
619 try:
620 self.channel.delete_episode_by_url(self.url)
621 except:
622 log('Cannot delete episode from disk: %s', self.title, traceback=True, sender=self)
624 def local_filename( self):
625 ext = self.extension()
627 # For compatibility with already-downloaded episodes,
628 # we accept md5 filenames if they are downloaded now.
629 md5_filename = os.path.join(self.channel.save_dir, md5.new(self.url).hexdigest()+ext)
630 if os.path.exists(md5_filename) or not gl.config.experimental_file_naming:
631 return md5_filename
633 # If the md5 filename does not exist,
634 ( episode, e ) = util.filename_from_url(self.url)
635 episode = util.sanitize_filename(episode) + ext
637 # If the episode filename looks suspicious,
638 # we still return the md5 filename to be on
639 # the safe side of the fence ;)
640 if len(episode) == 0 or episode.startswith('redirect.'):
641 return md5_filename
642 filename = os.path.join(self.channel.save_dir, episode)
643 return filename
645 def extension( self):
646 ( filename, ext ) = util.filename_from_url(self.url)
647 # if we can't detect the extension from the url fallback on the mimetype
648 if ext == '' or util.file_type_by_extension(ext) is None:
649 ext = util.extension_from_mimetype(self.mimetype)
650 #log('Getting extension from mimetype for: %s (mimetype: %s)' % (self.title, ext), sender=self)
651 return ext
653 def mark_new(self):
654 self.state = db.STATE_NORMAL
655 self.is_played = False
656 db.mark_episode(self.url, state=self.state, is_played=self.is_played)
658 def mark_old(self):
659 self.is_played = True
660 db.mark_episode(self.url, is_played=True)
662 def file_exists(self):
663 return os.path.exists(self.local_filename())
665 def was_downloaded(self, and_exists=False):
666 if self.state != db.STATE_DOWNLOADED:
667 return False
668 if and_exists and not self.file_exists():
669 return False
670 return True
672 def sync_filename( self):
673 if gl.config.custom_sync_name_enabled:
674 if '{channel' in gl.config.custom_sync_name:
675 log('Fixing OLD syntax {channel.*} => {podcast.*} in custom_sync_name.', sender=self)
676 gl.config.custom_sync_name = gl.config.custom_sync_name.replace('{channel.', '{podcast.')
677 return util.object_string_formatter(gl.config.custom_sync_name, episode=self, podcast=self.channel)
678 else:
679 return self.title
681 def file_type( self):
682 return util.file_type_by_extension( self.extension() )
684 @property
685 def basename( self):
686 return os.path.splitext( os.path.basename( self.url))[0]
688 @property
689 def published( self):
690 try:
691 return datetime.datetime.fromtimestamp(self.pubDate).strftime('%Y%m%d')
692 except:
693 log( 'Cannot format pubDate for "%s".', self.title, sender = self)
694 return '00000000'
696 def cute_pubdate(self):
697 result = util.format_date(self.pubDate)
698 if result is None:
699 return '(%s)' % _('unknown')
700 else:
701 return result
703 pubdate_prop = property(fget=cute_pubdate)
705 def calculate_filesize( self):
706 try:
707 self.length = os.path.getsize(self.local_filename())
708 except:
709 log( 'Could not get filesize for %s.', self.url)
711 def get_filesize_string( self):
712 return gl.format_filesize( self.length)
714 filesize_prop = property(fget=get_filesize_string)
716 def get_channel_title( self):
717 return self.channel.title
719 channel_prop = property(fget=get_channel_title)
721 def get_played_string( self):
722 if not self.is_played:
723 return _('Unplayed')
725 return ''
727 played_prop = property(fget=get_played_string)
731 def update_channel_model_by_iter( model, iter, channel, color_dict,
732 cover_cache=None, max_width=0, max_height=0 ):
734 count_downloaded = channel.stat(state=db.STATE_DOWNLOADED)
735 count_new = channel.stat(state=db.STATE_NORMAL, is_played=False)
736 count_unplayed = channel.stat(state=db.STATE_DOWNLOADED, is_played=False)
738 channel.iter = iter
739 model.set(iter, 0, channel.url)
740 model.set(iter, 1, channel.title)
742 title_markup = saxutils.escape(channel.title)
743 description_markup = saxutils.escape(util.get_first_line(channel.description) or _('No description available'))
744 d = []
745 if count_new:
746 d.append('<span weight="bold">')
747 d.append(title_markup)
748 if count_new:
749 d.append('</span>')
751 description = ''.join(d+['\n', '<small>', description_markup, '</small>'])
752 model.set(iter, 2, description)
754 if channel.parse_error is not None:
755 model.set(iter, 6, channel.parse_error)
756 color = color_dict['parse_error']
757 else:
758 color = color_dict['default']
760 if channel.update_flag:
761 color = color_dict['updating']
763 model.set(iter, 8, color)
765 if count_unplayed > 0 or count_downloaded > 0:
766 model.set(iter, 3, draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded)))
767 model.set(iter, 7, True)
768 else:
769 model.set(iter, 7, False)
771 # Load the cover if we have it, but don't download
772 # it if it's not available (to avoid blocking here)
773 pixbuf = services.cover_downloader.get_cover(channel, avoid_downloading=True)
774 new_pixbuf = None
775 if pixbuf is not None:
776 new_pixbuf = util.resize_pixbuf_keep_ratio(pixbuf, max_width, max_height, channel.url, cover_cache)
777 model.set(iter, 5, new_pixbuf or pixbuf)
779 def channels_to_model(channels, color_dict, cover_cache=None, max_width=0, max_height=0):
780 new_model = gtk.ListStore( str, str, str, gtk.gdk.Pixbuf, int,
781 gtk.gdk.Pixbuf, str, bool, str )
783 for channel in channels:
784 update_channel_model_by_iter( new_model, new_model.append(), channel,
785 color_dict, cover_cache, max_width, max_height )
787 return new_model
790 def load_channels():
791 return db.load_channels(lambda d: podcastChannel.create_from_dict(d))
793 def update_channels(callback_proc=None, callback_error=None, is_cancelled_cb=None):
794 log('Updating channels....')
796 channels = load_channels()
797 count = 0
799 for channel in channels:
800 if is_cancelled_cb is not None and is_cancelled_cb():
801 return channels
802 callback_proc and callback_proc(count, len(channels))
803 channel.update()
804 count += 1
806 return channels
808 def save_channels( channels):
809 exporter = opml.Exporter(gl.channel_opml_file)
810 return exporter.write(channels)
812 def can_restore_from_opml():
813 try:
814 if len(opml.Importer(gl.channel_opml_file).items):
815 return gl.channel_opml_file
816 except:
817 return None
821 class LocalDBReader( object):
823 DEPRECATED - Only used for migration to SQLite
825 def __init__( self, url):
826 self.url = url
828 def get_text( self, nodelist):
829 return ''.join( [ node.data for node in nodelist if node.nodeType == node.TEXT_NODE ])
831 def get_text_by_first_node( self, element, name):
832 return self.get_text( element.getElementsByTagName( name)[0].childNodes)
834 def get_episode_from_element( self, channel, element):
835 episode = podcastItem( channel)
836 episode.title = self.get_text_by_first_node( element, 'title')
837 episode.description = self.get_text_by_first_node( element, 'description')
838 episode.url = self.get_text_by_first_node( element, 'url')
839 episode.link = self.get_text_by_first_node( element, 'link')
840 episode.guid = self.get_text_by_first_node( element, 'guid')
842 if not episode.guid:
843 for k in ('url', 'link'):
844 if getattr(episode, k) is not None:
845 episode.guid = getattr(episode, k)
846 log('Notice: episode has no guid, using %s', episode.guid)
847 break
848 try:
849 episode.pubDate = float(self.get_text_by_first_node(element, 'pubDate'))
850 except:
851 log('Looks like you have an old pubDate in your LocalDB -> converting it')
852 episode.pubDate = self.get_text_by_first_node(element, 'pubDate')
853 log('FYI: pubDate value is: "%s"', episode.pubDate, sender=self)
854 pubdate = feedparser._parse_date(episode.pubDate)
855 if pubdate is None:
856 log('Error converting the old pubDate - sorry!', sender=self)
857 episode.pubDate = 0
858 else:
859 log('PubDate converted successfully - yay!', sender=self)
860 episode.pubDate = time.mktime(pubdate)
861 try:
862 episode.mimetype = self.get_text_by_first_node( element, 'mimetype')
863 except:
864 log('No mimetype info for %s', episode.url, sender=self)
865 episode.calculate_filesize()
866 return episode
868 def load_and_clean( self, filename):
870 Clean-up a LocalDB XML file that could potentially contain
871 "unbound prefix" XML elements (generated by the old print-based
872 LocalDB code). The code removes those lines to make the new
873 DOM parser happy.
875 This should be removed in a future version.
877 lines = []
878 for line in open(filename).read().split('\n'):
879 if not line.startswith('<gpodder:info'):
880 lines.append( line)
882 return '\n'.join( lines)
884 def read( self, filename):
885 doc = xml.dom.minidom.parseString( self.load_and_clean( filename))
886 rss = doc.getElementsByTagName('rss')[0]
888 channel_element = rss.getElementsByTagName('channel')[0]
890 channel = podcastChannel( url = self.url)
891 channel.title = self.get_text_by_first_node( channel_element, 'title')
892 channel.description = self.get_text_by_first_node( channel_element, 'description')
893 channel.link = self.get_text_by_first_node( channel_element, 'link')
895 episodes = []
896 for episode_element in rss.getElementsByTagName('item'):
897 episode = self.get_episode_from_element( channel, episode_element)
898 episodes.append(episode)
900 return episodes