# -*- coding: utf-8 -*- # # gPodder - A media aggregator and podcast client # Copyright (c) 2005-2018 The gPodder Team # # gPodder is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # gPodder is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # # gpodder.gtkui.model - GUI model classes for gPodder (2009-08-13) # Based on code from libpodcasts.py (thp, 2005-10-29) # import html import logging import os import re import time from itertools import groupby from gi.repository import GdkPixbuf, GObject, Gtk import gpodder from gpodder import coverart, model, query, util from gpodder.gtkui import draw _ = gpodder.gettext logger = logging.getLogger(__name__) try: from gi.repository import Gio have_gio = True except ImportError: have_gio = False # ---------------------------------------------------------- class GEpisode(model.PodcastEpisode): __slots__ = () @property def title_markup(self): return '%s\n%s' % (html.escape(self.title), html.escape(self.channel.title)) @property def markup_new_episodes(self): if self.file_size > 0: length_str = '%s; ' % util.format_filesize(self.file_size) else: length_str = '' return ('%s\n%s' + _('released %s') + '; ' + _('from %s') + '') % ( html.escape(re.sub(r'\s+', ' ', self.title)), html.escape(length_str), html.escape(self.pubdate_prop), html.escape(re.sub(r'\s+', ' ', self.channel.title))) @property def markup_delete_episodes(self): if self.total_time and self.current_position: played_string = self.get_play_info_string() elif not self.is_new: played_string = _('played') else: played_string = _('unplayed') downloaded_string = self.get_age_string() if not downloaded_string: downloaded_string = _('today') return ('%s\n%s; %s; ' + _('downloaded %s') + '; ' + _('from %s') + '') % ( html.escape(self.title), html.escape(util.format_filesize(self.file_size)), html.escape(played_string), html.escape(downloaded_string), html.escape(self.channel.title)) class GPodcast(model.PodcastChannel): __slots__ = () EpisodeClass = GEpisode @property def title_markup(self): """ escaped title for the mass unsubscribe dialog """ return html.escape(self.title) class Model(model.Model): PodcastClass = GPodcast # ---------------------------------------------------------- # Singleton indicator if a row is a section class SeparatorMarker(object): pass class BackgroundUpdate(object): def __init__(self, model, episodes, include_description): self.model = model self.episodes = episodes self.include_description = include_description self.index = 0 def update(self): model = self.model include_description = self.include_description started = time.time() while self.episodes: episode = self.episodes.pop(0) base_fields = ( (model.C_URL, episode.url), (model.C_TITLE, episode.title), (model.C_EPISODE, episode), (model.C_PUBLISHED_TEXT, episode.cute_pubdate()), (model.C_PUBLISHED, episode.published), ) update_fields = model.get_update_fields(episode, include_description) try: it = model.get_iter((self.index,)) # fix #727 the tree might be invalid when trying to update so discard the exception except ValueError: break model.set(it, *(x for fields in (base_fields, update_fields) for pair in fields for x in pair)) self.index += 1 # Check for the time limit of 20 ms after each 50 rows processed if self.index % 50 == 0 and (time.time() - started) > 0.02: break return bool(self.episodes) class EpisodeListModel(Gtk.ListStore): C_URL, C_TITLE, C_FILESIZE_TEXT, C_EPISODE, C_STATUS_ICON, \ C_PUBLISHED_TEXT, C_DESCRIPTION, C_TOOLTIP, \ C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \ C_VIEW_SHOW_UNPLAYED, C_FILESIZE, C_PUBLISHED, \ C_TIME, C_TIME_VISIBLE, C_TOTAL_TIME, \ C_LOCKED, \ C_TIME_AND_SIZE, C_TOTAL_TIME_AND_SIZE, C_FILESIZE_AND_TIME_TEXT, C_FILESIZE_AND_TIME = list(range(21)) VIEW_ALL, VIEW_UNDELETED, VIEW_DOWNLOADED, VIEW_UNPLAYED = list(range(4)) VIEWS = ['VIEW_ALL', 'VIEW_UNDELETED', 'VIEW_DOWNLOADED', 'VIEW_UNPLAYED'] # In which steps the UI is updated for "loading" animations _UI_UPDATE_STEP = .03 # Steps for the "downloading" icon progress PROGRESS_STEPS = 20 def __init__(self, config, on_filter_changed=lambda has_episodes: None): Gtk.ListStore.__init__(self, str, str, str, object, str, str, str, str, bool, bool, bool, GObject.TYPE_INT64, GObject.TYPE_INT64, str, bool, GObject.TYPE_INT64, bool, str, GObject.TYPE_INT64, str, GObject.TYPE_INT64) self._config = config # Callback for when the filter / list changes, gets one parameter # (has_episodes) that is True if the list has any episodes self._on_filter_changed = on_filter_changed # Filter to allow hiding some episodes self._filter = self.filter_new() self._sorter = Gtk.TreeModelSort(self._filter) self._view_mode = self.VIEW_ALL self._search_term = None self._search_term_eql = None self._filter.set_visible_func(self._filter_visible_func) # Are we currently showing "all episodes"/section or a single channel? self._section_view = False self.ICON_WEB_BROWSER = 'web-browser' self.ICON_AUDIO_FILE = 'audio-x-generic' self.ICON_VIDEO_FILE = 'video-x-generic' self.ICON_IMAGE_FILE = 'image-x-generic' self.ICON_GENERIC_FILE = 'text-x-generic' self.ICON_DOWNLOADING = 'go-down' self.ICON_DELETED = 'edit-delete' self.ICON_ERROR = 'dialog-error' self.background_update = None self.background_update_tag = None if 'KDE_FULL_SESSION' in os.environ: # Workaround until KDE adds all the freedesktop icons # See https://bugs.kde.org/show_bug.cgi?id=233505 and # http://gpodder.org/bug/553 self.ICON_DELETED = 'archive-remove' def _format_filesize(self, episode): if episode.file_size > 0: return util.format_filesize(episode.file_size, digits=1) else: return None def _filter_visible_func(self, model, iter, misc): # If searching is active, set visibility based on search text if self._search_term is not None and self._search_term != '': episode = model.get_value(iter, self.C_EPISODE) if episode is None: return False try: return self._search_term_eql.match(episode) except Exception as e: return True if self._view_mode == self.VIEW_ALL: return True elif self._view_mode == self.VIEW_UNDELETED: return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED) elif self._view_mode == self.VIEW_DOWNLOADED: return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED) elif self._view_mode == self.VIEW_UNPLAYED: return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED) return True def get_filtered_model(self): """Returns a filtered version of this episode model The filtered version should be displayed in the UI, as this model can have some filters set that should be reflected in the UI. """ return self._sorter def has_episodes(self): """Returns True if episodes are visible (filtered) If episodes are visible with the current filter applied, return True (otherwise return False). """ return bool(len(self._filter)) def set_view_mode(self, new_mode): """Sets a new view mode for this model After setting the view mode, the filtered model might be updated to reflect the new mode.""" if self._view_mode != new_mode: self._view_mode = new_mode self._filter.refilter() self._on_filter_changed(self.has_episodes()) def get_view_mode(self): """Returns the currently-set view mode""" return self._view_mode def set_search_term(self, new_term): if self._search_term != new_term: self._search_term = new_term self._search_term_eql = query.UserEQL(new_term) self._filter.refilter() self._on_filter_changed(self.has_episodes()) def get_search_term(self): return self._search_term def _format_description(self, episode, include_description=False): title = episode.trimmed_title if episode.state != gpodder.STATE_DELETED and episode.is_new: yield '' yield html.escape(title) yield '' else: yield html.escape(title) if include_description: yield '\n' if self._section_view: yield _('from %s') % html.escape(episode.channel.title) else: description = episode.one_line_description() if description.startswith(title): description = description[len(title):].strip() yield html.escape(description) def replace_from_channel(self, channel, include_description=False): """ Add episode from the given channel to this model. Downloading should be a callback. include_description should be a boolean value (True if description is to be added to the episode row, or False if not) """ # Remove old episodes in the list store self.clear() self._section_view = isinstance(channel, PodcastChannelProxy) # Avoid gPodder bug 1291 if channel is None: episodes = [] else: episodes = channel.get_all_episodes() # Always make a copy, so we can pass the episode list to BackgroundUpdate episodes = list(episodes) for _ in range(len(episodes)): self.append() self._update_from_episodes(episodes, include_description) def _update_from_episodes(self, episodes, include_description): if self.background_update_tag is not None: GObject.source_remove(self.background_update_tag) self.background_update = BackgroundUpdate(self, episodes, include_description) self.background_update_tag = GObject.idle_add(self._update_background) def _update_background(self): if self.background_update is not None: if self.background_update.update(): return True self.background_update = None self.background_update_tag = None self._on_filter_changed(self.has_episodes()) return False def update_all(self, include_description=False): if self.background_update is None: episodes = [row[self.C_EPISODE] for row in self] else: # Update all episodes that have already been initialized... episodes = [row[self.C_EPISODE] for index, row in enumerate(self) if index < self.background_update.index] # ...and also include episodes that still need to be initialized episodes.extend(self.background_update.episodes) self._update_from_episodes(episodes, include_description) def update_by_urls(self, urls, include_description=False): for row in self: if row[self.C_URL] in urls: self.update_by_iter(row.iter, include_description) def update_by_filter_iter(self, iter, include_description=False): # Convenience function for use by "outside" methods that use iters # from the filtered episode list model (i.e. all UI things normally) iter = self._sorter.convert_iter_to_child_iter(iter) self.update_by_iter(self._filter.convert_iter_to_child_iter(iter), include_description) def get_update_fields(self, episode, include_description): show_bullet = False show_padlock = False show_missing = False status_icon = None tooltip = [] view_show_undeleted = True view_show_downloaded = False view_show_unplayed = False icon_theme = Gtk.IconTheme.get_default() task = episode.download_task if task is not None and task.status in (task.PAUSING, task.PAUSED): tooltip.append('%s %d%%' % (_('Paused'), int(task.progress * 100))) status_icon = 'media-playback-pause' view_show_downloaded = True view_show_unplayed = True elif episode.downloading: tooltip.append('%s %d%%' % (_('Downloading'), int(task.progress * 100))) index = int(self.PROGRESS_STEPS * task.progress) status_icon = 'gpodder-progress-%d' % index view_show_downloaded = True view_show_unplayed = True else: if episode.state == gpodder.STATE_DELETED: tooltip.append(_('Deleted')) status_icon = self.ICON_DELETED view_show_undeleted = False elif episode.state == gpodder.STATE_DOWNLOADED: tooltip = [] view_show_downloaded = True view_show_unplayed = episode.is_new show_bullet = episode.is_new show_padlock = episode.archive show_missing = not episode.file_exists() filename = episode.local_filename(create=False, check_only=True) file_type = episode.file_type() if file_type == 'audio': tooltip.append(_('Downloaded episode')) status_icon = self.ICON_AUDIO_FILE elif file_type == 'video': tooltip.append(_('Downloaded video episode')) status_icon = self.ICON_VIDEO_FILE elif file_type == 'image': tooltip.append(_('Downloaded image')) status_icon = self.ICON_IMAGE_FILE else: tooltip.append(_('Downloaded file')) status_icon = self.ICON_GENERIC_FILE # Try to find a themed icon for this file # doesn't work on win32 (opus files are showed as text) if filename is not None and have_gio and not gpodder.ui.win32: file = Gio.File.new_for_path(filename) if file.query_exists(): file_info = file.query_info('*', Gio.FileQueryInfoFlags.NONE, None) icon = file_info.get_icon() for icon_name in icon.get_names(): if icon_theme.has_icon(icon_name): status_icon = icon_name break if show_missing: tooltip.append(_('missing file')) else: if show_bullet: if file_type == 'image': tooltip.append(_('never displayed')) elif file_type in ('audio', 'video'): tooltip.append(_('never played')) else: tooltip.append(_('never opened')) else: if file_type == 'image': tooltip.append(_('displayed')) elif file_type in ('audio', 'video'): tooltip.append(_('played')) else: tooltip.append(_('opened')) if show_padlock: tooltip.append(_('deletion prevented')) if episode.total_time > 0 and episode.current_position: tooltip.append('%d%%' % (100. * float(episode.current_position) / float(episode.total_time),)) elif episode._download_error is not None: tooltip.append(_('ERROR: %s') % episode._download_error) status_icon = self.ICON_ERROR if episode.state == gpodder.STATE_NORMAL and episode.is_new: view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new view_show_unplayed = True elif not episode.url: tooltip.append(_('No downloadable content')) status_icon = self.ICON_WEB_BROWSER if episode.state == gpodder.STATE_NORMAL and episode.is_new: view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new view_show_unplayed = True elif episode.state == gpodder.STATE_NORMAL and episode.is_new: tooltip.append(_('New episode')) view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new view_show_unplayed = True if episode.total_time: total_time = util.format_time(episode.total_time) if total_time: tooltip.append(total_time) tooltip = ', '.join(tooltip) description = ''.join(self._format_description(episode, include_description)) return ( (self.C_STATUS_ICON, status_icon), (self.C_VIEW_SHOW_UNDELETED, view_show_undeleted), (self.C_VIEW_SHOW_DOWNLOADED, view_show_downloaded), (self.C_VIEW_SHOW_UNPLAYED, view_show_unplayed), (self.C_DESCRIPTION, description), (self.C_TOOLTIP, tooltip), (self.C_TIME, episode.get_play_info_string()), (self.C_TIME_VISIBLE, bool(episode.total_time)), (self.C_TOTAL_TIME, episode.total_time), (self.C_LOCKED, episode.archive), (self.C_FILESIZE_TEXT, self._format_filesize(episode)), (self.C_FILESIZE, episode.file_size), (self.C_TIME_AND_SIZE, "%s\n%s" % (episode.get_play_info_string(), self._format_filesize(episode) if episode.file_size > 0 else "")), (self.C_TOTAL_TIME_AND_SIZE, episode.total_time), (self.C_FILESIZE_AND_TIME_TEXT, "%s\n%s" % (self._format_filesize(episode) if episode.file_size > 0 else "", episode.get_play_info_string())), (self.C_FILESIZE_AND_TIME, episode.file_size), ) def update_by_iter(self, iter, include_description=False): episode = self.get_value(iter, self.C_EPISODE) if episode is not None: self.set(iter, *(x for pair in self.get_update_fields(episode, include_description) for x in pair)) class PodcastChannelProxy: """ a bag of podcasts: 'All Episodes' or each section """ def __init__(self, db, config, channels, section, model): self.ALL_EPISODES_PROXY = not bool(section) self._db = db self._config = config self.channels = channels if self.ALL_EPISODES_PROXY: self.title = _('All episodes') self.description = _('from all podcasts') self.url = '' self.cover_file = coverart.CoverDownloader.ALL_EPISODES_ID else: self.title = section self.description = '' self.url = '-' self.cover_file = None # self.parse_error = '' self.section = section self.id = None self.cover_url = None self.auth_username = None self.auth_password = None self.pause_subscription = False self.sync_to_mp3_player = False self.cover_thumb = None self.auto_archive_episodes = False self.model = model self._update_error = None def get_statistics(self): if self.ALL_EPISODES_PROXY: # Get the total statistics for all channels from the database return self._db.get_podcast_statistics() else: # Calculate the stats over all podcasts of this section if len(self.channels) == 0: total = deleted = new = downloaded = unplayed = 0 else: total, deleted, new, downloaded, unplayed = list(map(sum, list(zip(*[c.get_statistics() for c in self.channels])))) return total, deleted, new, downloaded, unplayed def get_all_episodes(self): """Returns a generator that yields every episode""" if self.model._search_term is not None: def matches(channel): columns = (getattr(channel, c) for c in PodcastListModel.SEARCH_ATTRS) return any((key in c.lower() for c in columns if c is not None)) key = self.model._search_term else: def matches(e): return True return Model.sort_episodes_by_pubdate((e for c in self.channels if matches(c) for e in c.get_all_episodes()), True) def save(self): pass class PodcastListModel(Gtk.ListStore): C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \ C_COVER, C_ERROR, C_PILL_VISIBLE, \ C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \ C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR, \ C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION = list(range(16)) SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION, C_SECTION) SEARCH_ATTRS = ('title', 'description', 'group_by') @classmethod def row_separator_func(cls, model, iter): return model.get_value(iter, cls.C_SEPARATOR) def __init__(self, cover_downloader): Gtk.ListStore.__init__(self, str, str, str, GdkPixbuf.Pixbuf, object, GdkPixbuf.Pixbuf, str, bool, bool, bool, bool, bool, bool, int, bool, str) # Filter to allow hiding some episodes self._filter = self.filter_new() self._view_mode = -1 self._search_term = None self._filter.set_visible_func(self._filter_visible_func) self._cover_cache = {} self._max_image_side = 40 self._scale = 1 self._cover_downloader = cover_downloader self.ICON_DISABLED = 'media-playback-pause' self.ICON_ERROR = 'dialog-warning' def _filter_visible_func(self, model, iter, misc): channel = model.get_value(iter, self.C_CHANNEL) # If searching is active, set visibility based on search text if self._search_term is not None and self._search_term != '': key = self._search_term.lower() if isinstance(channel, PodcastChannelProxy): if channel.ALL_EPISODES_PROXY: return False return any(key in getattr(ch, c).lower() for c in PodcastListModel.SEARCH_ATTRS for ch in channel.channels) columns = (model.get_value(iter, c) for c in self.SEARCH_COLUMNS) return any((key in c.lower() for c in columns if c is not None)) # Show section if any of its channels have an update error if isinstance(channel, PodcastChannelProxy) and not channel.ALL_EPISODES_PROXY: if any(c._update_error is not None for c in channel.channels): return True if model.get_value(iter, self.C_SEPARATOR): return True elif getattr(channel, '_update_error', None) is not None: return True elif self._view_mode == EpisodeListModel.VIEW_ALL: return model.get_value(iter, self.C_HAS_EPISODES) elif self._view_mode == EpisodeListModel.VIEW_UNDELETED: return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED) elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED: return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED) elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED: return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED) return True def get_filtered_model(self): """Returns a filtered version of this episode model The filtered version should be displayed in the UI, as this model can have some filters set that should be reflected in the UI. """ return self._filter def set_view_mode(self, new_mode): """Sets a new view mode for this model After setting the view mode, the filtered model might be updated to reflect the new mode.""" if self._view_mode != new_mode: self._view_mode = new_mode self._filter.refilter() def get_view_mode(self): """Returns the currently-set view mode""" return self._view_mode def set_search_term(self, new_term): if self._search_term != new_term: self._search_term = new_term self._filter.refilter() def get_search_term(self): return self._search_term def set_max_image_size(self, size, scale): self._max_image_side = size * scale self._scale = scale self._cover_cache = {} def _resize_pixbuf_keep_ratio(self, url, pixbuf): """ Resizes a GTK Pixbuf but keeps its aspect ratio. Returns None if the pixbuf does not need to be resized or the newly resized pixbuf if it does. """ if url in self._cover_cache: return self._cover_cache[url] max_side = self._max_image_side w_cur = pixbuf.get_width() h_cur = pixbuf.get_height() if w_cur <= max_side and h_cur <= max_side: return None f = max_side / (w_cur if w_cur >= h_cur else h_cur) w_new = int(w_cur * f) h_new = int(h_cur * f) logger.debug("Scaling cover image: url=%s from %ix%i to %ix%i", url, w_cur, h_cur, w_new, h_new) pixbuf = pixbuf.scale_simple(w_new, h_new, GdkPixbuf.InterpType.BILINEAR) self._cover_cache[url] = pixbuf return pixbuf def _resize_pixbuf(self, url, pixbuf): if pixbuf is None: return None return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf def _overlay_pixbuf(self, pixbuf, icon): try: icon_theme = Gtk.IconTheme.get_default() emblem = icon_theme.load_icon(icon, self._max_image_side / 2, 0) (width, height) = (emblem.get_width(), emblem.get_height()) xpos = pixbuf.get_width() - width ypos = pixbuf.get_height() - height if ypos < 0: # need to resize overlay for none standard icon size emblem = icon_theme.load_icon(icon, pixbuf.get_height() - 1, 0) (width, height) = (emblem.get_width(), emblem.get_height()) xpos = pixbuf.get_width() - width ypos = pixbuf.get_height() - height emblem.composite(pixbuf, xpos, ypos, width, height, xpos, ypos, 1, 1, GdkPixbuf.InterpType.BILINEAR, 255) except: pass return pixbuf def _get_cached_thumb(self, channel): if channel.cover_thumb is None: return None try: loader = GdkPixbuf.PixbufLoader() loader.write(channel.cover_thumb) loader.close() pixbuf = loader.get_pixbuf() if self._max_image_side not in (pixbuf.get_width(), pixbuf.get_height()): logger.debug("cached thumb wrong size: %r != %i", (pixbuf.get_width(), pixbuf.get_height()), self._max_image_side) return None return pixbuf except Exception as e: logger.warning('Could not load cached cover art for %s', channel.url, exc_info=True) channel.cover_thumb = None channel.save() return None def _save_cached_thumb(self, channel, pixbuf): bufs = [] def save_callback(buf, length, user_data): user_data.append(buf) return True pixbuf.save_to_callbackv(save_callback, bufs, 'png', [None], []) channel.cover_thumb = bytes(b''.join(bufs)) channel.save() def _get_cover_image(self, channel, add_overlay=False, pixbuf_overlay=None): """ get channel's cover image. Callable from gtk thread. :param channel: channel model :param bool add_overlay: True to add a pause/error overlay :param GdkPixbuf.Pixbux pixbuf_overlay: existing pixbuf if already loaded, as an optimization :return GdkPixbuf.Pixbux: channel's cover image as pixbuf """ if self._cover_downloader is None: return pixbuf_overlay if pixbuf_overlay is None: # optimization: we can pass existing pixbuf pixbuf_overlay = self._get_cached_thumb(channel) if pixbuf_overlay is None: # load cover if it's not in cache pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True) if pixbuf is None: return None pixbuf_overlay = self._resize_pixbuf(channel.url, pixbuf) self._save_cached_thumb(channel, pixbuf_overlay) if add_overlay: if getattr(channel, '_update_error', None) is not None: pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_ERROR) elif channel.pause_subscription: pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_DISABLED) pixbuf_overlay.saturate_and_pixelate(pixbuf_overlay, 0.0, False) return pixbuf_overlay def _get_pill_image(self, channel, count_downloaded, count_unplayed): if count_unplayed > 0 or count_downloaded > 0: return draw.draw_pill_pixbuf('{:n}'.format(count_unplayed), '{:n}'.format(count_downloaded), widget=self.widget, scale=self._scale) else: return None def _format_description(self, channel, total, deleted, new, downloaded, unplayed): title_markup = html.escape(channel.title) if channel._update_error is not None: description_markup = html.escape(_('ERROR: %s') % channel._update_error) elif not channel.pause_subscription: description_markup = html.escape( util.get_first_line(util.remove_html_tags(channel.description)) or ' ') else: description_markup = html.escape(_('Subscription paused')) d = [] if new: d.append('') d.append(title_markup) if new: d.append('') if channel._update_error is not None: return ''.join(d + ['\n', '', description_markup, '']) elif description_markup.strip(): return ''.join(d + ['\n', '', description_markup, '']) else: return ''.join(d) def _format_error(self, channel): # if channel.parse_error: # return str(channel.parse_error) # else: # return None return None def set_channels(self, db, config, channels): # Clear the model and update the list of podcasts self.clear() def channel_to_row(channel, add_overlay=False): # C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL return (channel.url, '', '', None, channel, # C_COVER, C_ERROR, C_PILL_VISIBLE, self._get_cover_image(channel, add_overlay), '', True, # C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, True, True, # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR True, True, False, # C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION 0, True, '') def section_to_row(section): # C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL return (section.url, '', '', None, section, # C_COVER, C_ERROR, C_PILL_VISIBLE, None, '', True, # C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, True, True, # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR True, True, False, # C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION 0, False, section.title) if config.podcast_list_view_all and channels: all_episodes = PodcastChannelProxy(db, config, channels, '', self) iter = self.append(channel_to_row(all_episodes)) self.update_by_iter(iter) # Separator item if not config.podcast_list_sections: self.append(('', '', '', None, SeparatorMarker, None, '', True, True, True, True, True, True, 0, False, '')) def groupby_func(channel): return channel.group_by def key_func(channel): return (channel.group_by, model.Model.podcast_sort_key(channel)) if config.podcast_list_sections: groups = groupby(sorted(channels, key=key_func), groupby_func) else: groups = [(None, sorted(channels, key=model.Model.podcast_sort_key))] for section, section_channels in groups: if config.podcast_list_sections and section is not None: section_channels = list(section_channels) section_obj = PodcastChannelProxy(db, config, section_channels, section, self) iter = self.append(section_to_row(section_obj)) self.update_by_iter(iter) for channel in section_channels: iter = self.append(channel_to_row(channel, True)) self.update_by_iter(iter) def get_filter_path_from_url(self, url): # Return the path of the filtered model for a given URL child_path = self.get_path_from_url(url) if child_path is None: return None else: return self._filter.convert_child_path_to_path(child_path) def get_path_from_url(self, url): # Return the tree model path for a given URL if url is None: return None for row in self: if row[self.C_URL] == url: return row.path return None def update_first_row(self): # Update the first row in the model (for "all episodes" updates) self.update_by_iter(self.get_iter_first()) def update_by_urls(self, urls): # Given a list of URLs, update each matching row for row in self: if row[self.C_URL] in urls: self.update_by_iter(row.iter) def iter_is_first_row(self, iter): iter = self._filter.convert_iter_to_child_iter(iter) path = self.get_path(iter) return (path == Gtk.TreePath.new_first()) def update_by_filter_iter(self, iter): self.update_by_iter(self._filter.convert_iter_to_child_iter(iter)) def update_all(self): for row in self: self.update_by_iter(row.iter) def update_sections(self): for row in self: if isinstance(row[self.C_CHANNEL], PodcastChannelProxy) and not row[self.C_CHANNEL].ALL_EPISODES_PROXY: self.update_by_iter(row.iter) def update_by_iter(self, iter): if iter is None: return # Given a GtkTreeIter, update volatile information channel = self.get_value(iter, self.C_CHANNEL) if channel is SeparatorMarker: return total, deleted, new, downloaded, unplayed = channel.get_statistics() if isinstance(channel, PodcastChannelProxy) and not channel.ALL_EPISODES_PROXY: section = channel.title # We could customized the section header here with the list # of channels and their stats (i.e. add some "new" indicator) description = '%s' % ( html.escape(section)) pill_image = None cover_image = None else: description = self._format_description(channel, total, deleted, new, downloaded, unplayed) pill_image = self._get_pill_image(channel, downloaded, unplayed) cover_image = self._get_cover_image(channel, True) self.set(iter, self.C_TITLE, channel.title, self.C_DESCRIPTION, description, self.C_COVER, cover_image, self.C_SECTION, channel.section, self.C_ERROR, self._format_error(channel), self.C_PILL, pill_image, self.C_PILL_VISIBLE, pill_image is not None, self.C_VIEW_SHOW_UNDELETED, total - deleted > 0, self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0, self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0, self.C_HAS_EPISODES, total > 0, self.C_DOWNLOADS, downloaded) def clear_cover_cache(self, podcast_url): if podcast_url in self._cover_cache: logger.info('Clearing cover from cache: %s', podcast_url) del self._cover_cache[podcast_url] def add_cover_by_channel(self, channel, pixbuf): if pixbuf is None: return # Remove older images from cache self.clear_cover_cache(channel.url) # Resize and add the new cover image pixbuf = self._resize_pixbuf(channel.url, pixbuf) self._save_cached_thumb(channel, pixbuf) pixbuf = self._get_cover_image(channel, add_overlay=True, pixbuf_overlay=pixbuf) for row in self: if row[self.C_URL] == channel.url: row[self.C_COVER] = pixbuf break