Update copyright years for 2013
[gpodder.git] / src / gpodder / gtkui / model.py
blob89f4bf5b1273b04ff29d5023dbd28743124cffca
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2013 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.gtkui.model - GUI model classes for gPodder (2009-08-13)
23 # Based on code from libpodcasts.py (thp, 2005-10-29)
26 import gpodder
28 _ = gpodder.gettext
30 from gpodder import util
31 from gpodder import model
32 from gpodder import query
33 from gpodder import coverart
35 import logging
36 logger = logging.getLogger(__name__)
38 from gpodder.gtkui import draw
39 from gpodder.gtkui import flattr
41 import os
42 import gtk
43 import gobject
44 import cgi
45 import re
47 try:
48 import gio
49 have_gio = True
50 except ImportError:
51 have_gio = False
53 # ----------------------------------------------------------
55 class GEpisode(model.PodcastEpisode):
56 __slots__ = ()
58 @property
59 def title_markup(self):
60 return '%s\n<small>%s</small>' % (cgi.escape(self.title),
61 cgi.escape(self.channel.title))
63 @property
64 def markup_new_episodes(self):
65 if self.file_size > 0:
66 length_str = '%s; ' % util.format_filesize(self.file_size)
67 else:
68 length_str = ''
69 return ('<b>%s</b>\n<small>%s'+_('released %s')+ \
70 '; '+_('from %s')+'</small>') % (\
71 cgi.escape(re.sub('\s+', ' ', self.title)), \
72 cgi.escape(length_str), \
73 cgi.escape(self.pubdate_prop), \
74 cgi.escape(re.sub('\s+', ' ', self.channel.title)))
76 @property
77 def markup_delete_episodes(self):
78 if self.total_time and self.current_position:
79 played_string = self.get_play_info_string()
80 elif not self.is_new:
81 played_string = _('played')
82 else:
83 played_string = _('unplayed')
84 downloaded_string = self.get_age_string()
85 if not downloaded_string:
86 downloaded_string = _('today')
87 return ('<b>%s</b>\n<small>%s; %s; '+_('downloaded %s')+ \
88 '; '+_('from %s')+'</small>') % (\
89 cgi.escape(self.title), \
90 cgi.escape(util.format_filesize(self.file_size)), \
91 cgi.escape(played_string), \
92 cgi.escape(downloaded_string), \
93 cgi.escape(self.channel.title))
95 class GPodcast(model.PodcastChannel):
96 __slots__ = ()
98 EpisodeClass = GEpisode
100 class Model(model.Model):
101 PodcastClass = GPodcast
103 # ----------------------------------------------------------
105 # Singleton indicator if a row is a section
106 class SeparatorMarker(object): pass
107 class SectionMarker(object): pass
109 class EpisodeListModel(gtk.ListStore):
110 C_URL, C_TITLE, C_FILESIZE_TEXT, C_EPISODE, C_STATUS_ICON, \
111 C_PUBLISHED_TEXT, C_DESCRIPTION, C_TOOLTIP, \
112 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
113 C_VIEW_SHOW_UNPLAYED, C_FILESIZE, C_PUBLISHED, \
114 C_TIME, C_TIME_VISIBLE, C_TOTAL_TIME, \
115 C_LOCKED = range(17)
117 VIEW_ALL, VIEW_UNDELETED, VIEW_DOWNLOADED, VIEW_UNPLAYED = range(4)
119 # In which steps the UI is updated for "loading" animations
120 _UI_UPDATE_STEP = .03
122 # Steps for the "downloading" icon progress
123 PROGRESS_STEPS = 20
125 def __init__(self, config, on_filter_changed=lambda has_episodes: None):
126 gtk.ListStore.__init__(self, str, str, str, object, \
127 str, str, str, str, bool, bool, bool, \
128 gobject.TYPE_INT64, int, str, bool, int, bool)
130 self._config = config
132 # Callback for when the filter / list changes, gets one parameter
133 # (has_episodes) that is True if the list has any episodes
134 self._on_filter_changed = on_filter_changed
136 # Filter to allow hiding some episodes
137 self._filter = self.filter_new()
138 self._sorter = gtk.TreeModelSort(self._filter)
139 self._view_mode = self.VIEW_ALL
140 self._search_term = None
141 self._search_term_eql = None
142 self._filter.set_visible_func(self._filter_visible_func)
144 # Are we currently showing the "all episodes" view?
145 self._all_episodes_view = False
147 self.ICON_AUDIO_FILE = 'audio-x-generic'
148 self.ICON_VIDEO_FILE = 'video-x-generic'
149 self.ICON_IMAGE_FILE = 'image-x-generic'
150 self.ICON_GENERIC_FILE = 'text-x-generic'
151 self.ICON_DOWNLOADING = gtk.STOCK_GO_DOWN
152 self.ICON_DELETED = gtk.STOCK_DELETE
154 if 'KDE_FULL_SESSION' in os.environ:
155 # Workaround until KDE adds all the freedesktop icons
156 # See https://bugs.kde.org/show_bug.cgi?id=233505 and
157 # http://gpodder.org/bug/553
158 self.ICON_DELETED = 'archive-remove'
161 def _format_filesize(self, episode):
162 if episode.file_size > 0:
163 return util.format_filesize(episode.file_size, digits=1)
164 else:
165 return None
167 def _filter_visible_func(self, model, iter):
168 # If searching is active, set visibility based on search text
169 if self._search_term is not None:
170 episode = model.get_value(iter, self.C_EPISODE)
171 if episode is None:
172 return False
174 try:
175 return self._search_term_eql.match(episode)
176 except Exception, e:
177 return True
179 if self._view_mode == self.VIEW_ALL:
180 return True
181 elif self._view_mode == self.VIEW_UNDELETED:
182 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
183 elif self._view_mode == self.VIEW_DOWNLOADED:
184 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
185 elif self._view_mode == self.VIEW_UNPLAYED:
186 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
188 return True
190 def get_filtered_model(self):
191 """Returns a filtered version of this episode model
193 The filtered version should be displayed in the UI,
194 as this model can have some filters set that should
195 be reflected in the UI.
197 return self._sorter
199 def has_episodes(self):
200 """Returns True if episodes are visible (filtered)
202 If episodes are visible with the current filter
203 applied, return True (otherwise return False).
205 return bool(len(self._filter))
207 def set_view_mode(self, new_mode):
208 """Sets a new view mode for this model
210 After setting the view mode, the filtered model
211 might be updated to reflect the new mode."""
212 if self._view_mode != new_mode:
213 self._view_mode = new_mode
214 self._filter.refilter()
215 self._on_filter_changed(self.has_episodes())
217 def get_view_mode(self):
218 """Returns the currently-set view mode"""
219 return self._view_mode
221 def set_search_term(self, new_term):
222 if self._search_term != new_term:
223 self._search_term = new_term
224 self._search_term_eql = query.UserEQL(new_term)
225 self._filter.refilter()
226 self._on_filter_changed(self.has_episodes())
228 def get_search_term(self):
229 return self._search_term
231 def _format_description(self, episode, include_description=False):
232 title = episode.trimmed_title
233 a, b = '', ''
234 if episode.state != gpodder.STATE_DELETED and episode.is_new:
235 a, b = '<b>', '</b>'
236 if include_description and self._all_episodes_view:
237 return '%s%s%s\n%s' % (a, cgi.escape(title), b,
238 _('from %s') % cgi.escape(episode.channel.title))
239 elif include_description:
240 description = episode.one_line_description()
241 if description.startswith(title):
242 description = description[len(title):].strip()
243 return '%s%s%s\n%s' % (a, cgi.escape(title), b,
244 cgi.escape(description))
245 else:
246 return ''.join((a, cgi.escape(title), b))
248 def replace_from_channel(self, channel, include_description=False,
249 treeview=None):
251 Add episode from the given channel to this model.
252 Downloading should be a callback.
253 include_description should be a boolean value (True if description
254 is to be added to the episode row, or False if not)
257 # Remove old episodes in the list store
258 self.clear()
260 if treeview is not None:
261 util.idle_add(treeview.queue_draw)
263 self._all_episodes_view = getattr(channel, 'ALL_EPISODES_PROXY', False)
265 # Avoid gPodder bug 1291
266 if channel is None:
267 episodes = []
268 else:
269 episodes = channel.get_all_episodes()
271 if not isinstance(episodes, list):
272 episodes = list(episodes)
273 count = len(episodes)
275 for position, episode in enumerate(episodes):
276 iter = self.append((episode.url, \
277 episode.title, \
278 self._format_filesize(episode), \
279 episode, \
280 None, \
281 episode.cute_pubdate(), \
282 '', \
283 '', \
284 True, \
285 True, \
286 True, \
287 episode.file_size, \
288 episode.published, \
289 episode.get_play_info_string(), \
290 bool(episode.total_time), \
291 episode.total_time, \
292 episode.archive))
294 self.update_by_iter(iter, include_description)
296 self._on_filter_changed(self.has_episodes())
298 def update_all(self, include_description=False):
299 for row in self:
300 self.update_by_iter(row.iter, include_description)
302 def update_by_urls(self, urls, include_description=False):
303 for row in self:
304 if row[self.C_URL] in urls:
305 self.update_by_iter(row.iter, include_description)
307 def update_by_filter_iter(self, iter, include_description=False):
308 # Convenience function for use by "outside" methods that use iters
309 # from the filtered episode list model (i.e. all UI things normally)
310 iter = self._sorter.convert_iter_to_child_iter(None, iter)
311 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter),
312 include_description)
314 def update_by_iter(self, iter, include_description=False):
315 episode = self.get_value(iter, self.C_EPISODE)
317 show_bullet = False
318 show_padlock = False
319 show_missing = False
320 status_icon = None
321 tooltip = []
322 view_show_undeleted = True
323 view_show_downloaded = False
324 view_show_unplayed = False
325 icon_theme = gtk.icon_theme_get_default()
327 if episode.downloading:
328 tooltip.append('%s %d%%' % (_('Downloading'),
329 int(episode.download_task.progress*100)))
331 index = int(self.PROGRESS_STEPS*episode.download_task.progress)
332 status_icon = 'gpodder-progress-%d' % index
334 view_show_downloaded = True
335 view_show_unplayed = True
336 else:
337 if episode.state == gpodder.STATE_DELETED:
338 tooltip.append(_('Deleted'))
339 status_icon = self.ICON_DELETED
340 view_show_undeleted = False
341 elif episode.state == gpodder.STATE_NORMAL and \
342 episode.is_new:
343 tooltip.append(_('New episode'))
344 view_show_downloaded = True
345 view_show_unplayed = True
346 elif episode.state == gpodder.STATE_DOWNLOADED:
347 tooltip = []
348 view_show_downloaded = True
349 view_show_unplayed = episode.is_new
350 show_bullet = episode.is_new
351 show_padlock = episode.archive
352 show_missing = not episode.file_exists()
353 filename = episode.local_filename(create=False, check_only=True)
355 file_type = episode.file_type()
356 if file_type == 'audio':
357 tooltip.append(_('Downloaded episode'))
358 status_icon = self.ICON_AUDIO_FILE
359 elif file_type == 'video':
360 tooltip.append(_('Downloaded video episode'))
361 status_icon = self.ICON_VIDEO_FILE
362 elif file_type == 'image':
363 tooltip.append(_('Downloaded image'))
364 status_icon = self.ICON_IMAGE_FILE
365 else:
366 tooltip.append(_('Downloaded file'))
367 status_icon = self.ICON_GENERIC_FILE
369 # Try to find a themed icon for this file
370 if filename is not None and have_gio:
371 file = gio.File(filename)
372 if file.query_exists():
373 file_info = file.query_info('*')
374 icon = file_info.get_icon()
375 for icon_name in icon.get_names():
376 if icon_theme.has_icon(icon_name):
377 status_icon = icon_name
378 break
380 if show_missing:
381 tooltip.append(_('missing file'))
382 else:
383 if show_bullet:
384 if file_type == 'image':
385 tooltip.append(_('never displayed'))
386 elif file_type in ('audio', 'video'):
387 tooltip.append(_('never played'))
388 else:
389 tooltip.append(_('never opened'))
390 else:
391 if file_type == 'image':
392 tooltip.append(_('displayed'))
393 elif file_type in ('audio', 'video'):
394 tooltip.append(_('played'))
395 else:
396 tooltip.append(_('opened'))
397 if show_padlock:
398 tooltip.append(_('deletion prevented'))
400 if episode.total_time > 0 and episode.current_position:
401 tooltip.append('%d%%' % (100.*float(episode.current_position)/float(episode.total_time),))
403 if episode.total_time:
404 total_time = util.format_time(episode.total_time)
405 if total_time:
406 tooltip.append(total_time)
408 tooltip = ', '.join(tooltip)
410 description = self._format_description(episode, include_description)
411 self.set(iter, \
412 self.C_STATUS_ICON, status_icon, \
413 self.C_VIEW_SHOW_UNDELETED, view_show_undeleted, \
414 self.C_VIEW_SHOW_DOWNLOADED, view_show_downloaded, \
415 self.C_VIEW_SHOW_UNPLAYED, view_show_unplayed, \
416 self.C_DESCRIPTION, description, \
417 self.C_TOOLTIP, tooltip, \
418 self.C_TIME, episode.get_play_info_string(duration_only=True), \
419 self.C_TIME_VISIBLE, bool(episode.total_time), \
420 self.C_TOTAL_TIME, episode.total_time, \
421 self.C_LOCKED, episode.archive, \
422 self.C_FILESIZE_TEXT, self._format_filesize(episode), \
423 self.C_FILESIZE, episode.file_size)
426 class PodcastChannelProxy(object):
427 ALL_EPISODES_PROXY = True
429 def __init__(self, db, config, channels):
430 self._db = db
431 self._config = config
432 self.channels = channels
433 self.title = _('All episodes')
434 self.description = _('from all podcasts')
435 #self.parse_error = ''
436 self.url = ''
437 self.section = ''
438 self.id = None
439 self.cover_file = coverart.CoverDownloader.ALL_EPISODES_ID
440 self.cover_url = None
441 self.auth_username = None
442 self.auth_password = None
443 self.pause_subscription = False
444 self.sync_to_mp3_player = False
445 self.auto_archive_episodes = False
447 def get_statistics(self):
448 # Get the total statistics for all channels from the database
449 return self._db.get_podcast_statistics()
451 def get_all_episodes(self):
452 """Returns a generator that yields every episode"""
453 return Model.sort_episodes_by_pubdate((e for c in self.channels
454 for e in c.get_all_episodes()), True)
457 class PodcastListModel(gtk.ListStore):
458 C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
459 C_COVER, C_ERROR, C_PILL_VISIBLE, \
460 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
461 C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR, \
462 C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION = range(16)
464 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION, C_SECTION)
466 @classmethod
467 def row_separator_func(cls, model, iter):
468 return model.get_value(iter, cls.C_SEPARATOR)
470 def __init__(self, cover_downloader):
471 gtk.ListStore.__init__(self, str, str, str, gtk.gdk.Pixbuf, \
472 object, gtk.gdk.Pixbuf, str, bool, bool, bool, bool, \
473 bool, bool, int, bool, str)
475 # Filter to allow hiding some episodes
476 self._filter = self.filter_new()
477 self._view_mode = -1
478 self._search_term = None
479 self._filter.set_visible_func(self._filter_visible_func)
481 self._cover_cache = {}
482 self._max_image_side = 40
483 self._cover_downloader = cover_downloader
485 self.ICON_DISABLED = 'gtk-media-pause'
487 def _filter_visible_func(self, model, iter):
488 # If searching is active, set visibility based on search text
489 if self._search_term is not None:
490 if model.get_value(iter, self.C_CHANNEL) == SectionMarker:
491 return True
492 key = self._search_term.lower()
493 columns = (model.get_value(iter, c) for c in self.SEARCH_COLUMNS)
494 return any((key in c.lower() for c in columns if c is not None))
496 if model.get_value(iter, self.C_SEPARATOR):
497 return True
498 elif self._view_mode == EpisodeListModel.VIEW_ALL:
499 return model.get_value(iter, self.C_HAS_EPISODES)
500 elif self._view_mode == EpisodeListModel.VIEW_UNDELETED:
501 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
502 elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED:
503 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
504 elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED:
505 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
507 return True
509 def get_filtered_model(self):
510 """Returns a filtered version of this episode model
512 The filtered version should be displayed in the UI,
513 as this model can have some filters set that should
514 be reflected in the UI.
516 return self._filter
518 def set_view_mode(self, new_mode):
519 """Sets a new view mode for this model
521 After setting the view mode, the filtered model
522 might be updated to reflect the new mode."""
523 if self._view_mode != new_mode:
524 self._view_mode = new_mode
525 self._filter.refilter()
527 def get_view_mode(self):
528 """Returns the currently-set view mode"""
529 return self._view_mode
531 def set_search_term(self, new_term):
532 if self._search_term != new_term:
533 self._search_term = new_term
534 self._filter.refilter()
536 def get_search_term(self):
537 return self._search_term
539 def enable_separators(self, channeltree):
540 channeltree.set_row_separator_func(self._show_row_separator)
542 def _show_row_separator(self, model, iter):
543 return model.get_value(iter, self.C_SEPARATOR)
545 def _resize_pixbuf_keep_ratio(self, url, pixbuf):
547 Resizes a GTK Pixbuf but keeps its aspect ratio.
548 Returns None if the pixbuf does not need to be
549 resized or the newly resized pixbuf if it does.
551 changed = False
552 result = None
554 if url in self._cover_cache:
555 return self._cover_cache[url]
557 # Resize if too wide
558 if pixbuf.get_width() > self._max_image_side:
559 f = float(self._max_image_side)/pixbuf.get_width()
560 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
561 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
562 changed = True
564 # Resize if too high
565 if pixbuf.get_height() > self._max_image_side:
566 f = float(self._max_image_side)/pixbuf.get_height()
567 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
568 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
569 changed = True
571 if changed:
572 self._cover_cache[url] = pixbuf
573 result = pixbuf
575 return result
577 def _resize_pixbuf(self, url, pixbuf):
578 if pixbuf is None:
579 return None
581 return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf
583 def _overlay_pixbuf(self, pixbuf, icon):
584 try:
585 icon_theme = gtk.icon_theme_get_default()
586 emblem = icon_theme.load_icon(icon, self._max_image_side/2, 0)
587 (width, height) = (emblem.get_width(), emblem.get_height())
588 xpos = pixbuf.get_width() - width
589 ypos = pixbuf.get_height() - height
590 if ypos < 0:
591 # need to resize overlay for none standard icon size
592 emblem = icon_theme.load_icon(icon, pixbuf.get_height() - 1, 0)
593 (width, height) = (emblem.get_width(), emblem.get_height())
594 xpos = pixbuf.get_width() - width
595 ypos = pixbuf.get_height() - height
596 emblem.composite(pixbuf, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
597 except:
598 pass
600 return pixbuf
602 def _get_cover_image(self, channel, add_overlay=False):
603 if self._cover_downloader is None:
604 return None
606 pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
607 pixbuf_overlay = self._resize_pixbuf(channel.url, pixbuf)
608 if add_overlay and channel.pause_subscription:
609 pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_DISABLED)
610 pixbuf_overlay.saturate_and_pixelate(pixbuf_overlay, 0.0, False)
612 return pixbuf_overlay
614 def _get_pill_image(self, channel, count_downloaded, count_unplayed):
615 if count_unplayed > 0 or count_downloaded > 0:
616 return draw.draw_pill_pixbuf(str(count_unplayed), str(count_downloaded))
617 else:
618 return None
620 def _format_description(self, channel, total, deleted, \
621 new, downloaded, unplayed):
622 title_markup = cgi.escape(channel.title)
623 if not channel.pause_subscription:
624 description_markup = cgi.escape(util.get_first_line(channel.description) or ' ')
625 else:
626 description_markup = cgi.escape(_('Subscription paused'))
627 d = []
628 if new:
629 d.append('<span weight="bold">')
630 d.append(title_markup)
631 if new:
632 d.append('</span>')
634 if description_markup.strip():
635 return ''.join(d+['\n', '<small>', description_markup, '</small>'])
636 else:
637 return ''.join(d)
639 def _format_error(self, channel):
640 #if channel.parse_error:
641 # return str(channel.parse_error)
642 #else:
643 # return None
644 return None
646 def set_channels(self, db, config, channels):
647 # Clear the model and update the list of podcasts
648 self.clear()
650 def channel_to_row(channel, add_overlay=False):
651 return (channel.url, '', '', None, channel,
652 self._get_cover_image(channel, add_overlay), '', True,
653 True, True, True, True, False, 0, True, '')
655 if config.podcast_list_view_all and channels:
656 all_episodes = PodcastChannelProxy(db, config, channels)
657 iter = self.append(channel_to_row(all_episodes))
658 self.update_by_iter(iter)
660 # Separator item
661 if not config.podcast_list_sections:
662 self.append(('', '', '', None, SeparatorMarker, None, '',
663 True, True, True, True, True, True, 0, False, ''))
665 def key_func(pair):
666 section, podcast = pair
667 return (section, model.Model.podcast_sort_key(podcast))
669 if config.podcast_list_sections:
670 def convert(channels):
671 for channel in channels:
672 yield (channel.group_by, channel)
673 else:
674 def convert(channels):
675 for channel in channels:
676 yield (None, channel)
678 added_sections = []
679 old_section = None
680 for section, channel in sorted(convert(channels), key=key_func):
681 if old_section != section:
682 it = self.append(('-', section, '', None, SectionMarker, None,
683 '', True, True, True, True, True, False, 0, False, section))
684 added_sections.append(it)
685 old_section = section
687 iter = self.append(channel_to_row(channel, True))
688 self.update_by_iter(iter)
690 # Update section header stats only after all podcasts
691 # have been added to the list to get the stats right
692 for it in added_sections:
693 self.update_by_iter(it)
695 def get_filter_path_from_url(self, url):
696 # Return the path of the filtered model for a given URL
697 child_path = self.get_path_from_url(url)
698 if child_path is None:
699 return None
700 else:
701 return self._filter.convert_child_path_to_path(child_path)
703 def get_path_from_url(self, url):
704 # Return the tree model path for a given URL
705 if url is None:
706 return None
708 for row in self:
709 if row[self.C_URL] == url:
710 return row.path
711 return None
713 def update_first_row(self):
714 # Update the first row in the model (for "all episodes" updates)
715 self.update_by_iter(self.get_iter_first())
717 def update_by_urls(self, urls):
718 # Given a list of URLs, update each matching row
719 for row in self:
720 if row[self.C_URL] in urls:
721 self.update_by_iter(row.iter)
723 def iter_is_first_row(self, iter):
724 iter = self._filter.convert_iter_to_child_iter(iter)
725 path = self.get_path(iter)
726 return (path == (0,))
728 def update_by_filter_iter(self, iter):
729 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter))
731 def update_all(self):
732 for row in self:
733 self.update_by_iter(row.iter)
735 def update_sections(self):
736 for row in self:
737 if row[self.C_CHANNEL] is SectionMarker:
738 self.update_by_iter(row.iter)
740 def update_by_iter(self, iter):
741 if iter is None:
742 return
744 # Given a GtkTreeIter, update volatile information
745 channel = self.get_value(iter, self.C_CHANNEL)
747 if channel is SectionMarker:
748 section = self.get_value(iter, self.C_TITLE)
750 # This row is a section header - update its visibility flags
751 channels = [c for c in (row[self.C_CHANNEL] for row in self)
752 if isinstance(c, GPodcast) and c.section == section]
754 # Calculate the stats over all podcasts of this section
755 total, deleted, new, downloaded, unplayed = map(sum,
756 zip(*[c.get_statistics() for c in channels]))
758 # We could customized the section header here with the list
759 # of channels and their stats (i.e. add some "new" indicator)
760 description = '<span size="16000"> </span><b>%s</b>' % (
761 cgi.escape(section))
763 self.set(iter,
764 self.C_DESCRIPTION, description,
765 self.C_SECTION, section,
766 self.C_VIEW_SHOW_UNDELETED, total - deleted > 0,
767 self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0,
768 self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0)
770 if (not isinstance(channel, GPodcast) and
771 not isinstance(channel, PodcastChannelProxy)):
772 return
774 total, deleted, new, downloaded, unplayed = channel.get_statistics()
775 description = self._format_description(channel, total, deleted, new, \
776 downloaded, unplayed)
778 pill_image = self._get_pill_image(channel, downloaded, unplayed)
780 self.set(iter, \
781 self.C_TITLE, channel.title, \
782 self.C_DESCRIPTION, description, \
783 self.C_SECTION, channel.section, \
784 self.C_ERROR, self._format_error(channel), \
785 self.C_PILL, pill_image, \
786 self.C_PILL_VISIBLE, pill_image != None, \
787 self.C_VIEW_SHOW_UNDELETED, total - deleted > 0, \
788 self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0, \
789 self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0, \
790 self.C_HAS_EPISODES, total > 0, \
791 self.C_DOWNLOADS, downloaded)
793 def clear_cover_cache(self, podcast_url):
794 if podcast_url in self._cover_cache:
795 logger.info('Clearing cover from cache: %s', podcast_url)
796 del self._cover_cache[podcast_url]
798 def add_cover_by_channel(self, channel, pixbuf):
799 # Resize and add the new cover image
800 pixbuf = self._resize_pixbuf(channel.url, pixbuf)
801 if channel.pause_subscription:
802 pixbuf = self._overlay_pixbuf(pixbuf, self.ICON_DISABLED)
803 pixbuf.saturate_and_pixelate(pixbuf, 0.0, False)
805 for row in self:
806 if row[self.C_URL] == channel.url:
807 row[self.C_COVER] = pixbuf
808 break