Get default icon theme once, instead of for each call.
[gpodder.git] / src / gpodder / gtkui / model.py
blobaad7ed259cd7b114af2eb72ebb0ef450994b5bec
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2018 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 html
27 import logging
28 import os
29 import re
30 import time
31 from itertools import groupby
33 from gi.repository import GdkPixbuf, GLib, GObject, Gtk
35 import gpodder
36 from gpodder import coverart, model, query, util
37 from gpodder.gtkui import draw
39 _ = gpodder.gettext
41 logger = logging.getLogger(__name__)
44 try:
45 from gi.repository import Gio
46 have_gio = True
47 except ImportError:
48 have_gio = False
50 # ----------------------------------------------------------
53 class GEpisode(model.PodcastEpisode):
54 __slots__ = ()
56 @property
57 def title_markup(self):
58 return '%s\n<small>%s</small>' % (html.escape(self.title),
59 html.escape(self.channel.title))
61 @property
62 def markup_new_episodes(self):
63 if self.file_size > 0:
64 length_str = '%s; ' % util.format_filesize(self.file_size)
65 else:
66 length_str = ''
67 return ('<b>%s</b>\n<small>%s' + _('released %s') +
68 '; ' + _('from %s') + '</small>') % (
69 html.escape(re.sub(r'\s+', ' ', self.title)),
70 html.escape(length_str),
71 html.escape(self.pubdate_prop),
72 html.escape(re.sub(r'\s+', ' ', self.channel.title)))
74 @property
75 def markup_delete_episodes(self):
76 if self.total_time and self.current_position:
77 played_string = self.get_play_info_string()
78 elif not self.is_new:
79 played_string = _('played')
80 else:
81 played_string = _('unplayed')
82 downloaded_string = self.get_age_string()
83 if not downloaded_string:
84 downloaded_string = _('today')
85 return ('<b>%s</b>\n<small>%s; %s; ' + _('downloaded %s') +
86 '; ' + _('from %s') + '</small>') % (
87 html.escape(self.title),
88 html.escape(util.format_filesize(self.file_size)),
89 html.escape(played_string),
90 html.escape(downloaded_string),
91 html.escape(self.channel.title))
94 class GPodcast(model.PodcastChannel):
95 __slots__ = ()
97 EpisodeClass = GEpisode
99 @property
100 def title_markup(self):
101 """ escaped title for the mass unsubscribe dialog """
102 return html.escape(self.title)
105 class Model(model.Model):
106 PodcastClass = GPodcast
108 # ----------------------------------------------------------
111 # Singleton indicator if a row is a section
112 class SeparatorMarker(object): pass
115 class BackgroundUpdate(object):
116 def __init__(self, model, episodes, include_description):
117 self.model = model
118 self.episodes = episodes
119 self.include_description = include_description
120 self.index = 0
122 def update(self):
123 model = self.model
124 include_description = self.include_description
126 started = time.time()
127 while self.episodes:
128 episode = self.episodes.pop(0)
129 base_fields = (
130 (model.C_URL, episode.url),
131 (model.C_TITLE, episode.title),
132 (model.C_EPISODE, episode),
133 (model.C_PUBLISHED_TEXT, episode.cute_pubdate()),
134 (model.C_PUBLISHED, episode.published),
136 update_fields = model.get_update_fields(episode, include_description)
137 try:
138 it = model.get_iter((self.index,))
139 # fix #727 the tree might be invalid when trying to update so discard the exception
140 except ValueError:
141 break
142 model.set(it, *(x for fields in (base_fields, update_fields)
143 for pair in fields for x in pair))
144 self.index += 1
146 # Check for the time limit of 20 ms after each 50 rows processed
147 if self.index % 50 == 0 and (time.time() - started) > 0.02:
148 break
150 return bool(self.episodes)
153 class EpisodeListModel(Gtk.ListStore):
154 C_URL, C_TITLE, C_FILESIZE_TEXT, C_EPISODE, C_STATUS_ICON, \
155 C_PUBLISHED_TEXT, C_DESCRIPTION, C_TOOLTIP, \
156 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
157 C_VIEW_SHOW_UNPLAYED, C_FILESIZE, C_PUBLISHED, \
158 C_TIME, C_TIME_VISIBLE, C_TOTAL_TIME, \
159 C_LOCKED, \
160 C_TIME_AND_SIZE, C_TOTAL_TIME_AND_SIZE, C_FILESIZE_AND_TIME_TEXT, C_FILESIZE_AND_TIME = list(range(21))
162 VIEW_ALL, VIEW_UNDELETED, VIEW_DOWNLOADED, VIEW_UNPLAYED = list(range(4))
164 VIEWS = ['VIEW_ALL', 'VIEW_UNDELETED', 'VIEW_DOWNLOADED', 'VIEW_UNPLAYED']
166 # In which steps the UI is updated for "loading" animations
167 _UI_UPDATE_STEP = .03
169 # Steps for the "downloading" icon progress
170 PROGRESS_STEPS = 20
172 def __init__(self, config, on_filter_changed=lambda has_episodes: None):
173 Gtk.ListStore.__init__(self, str, str, str, object, str, str, str,
174 str, bool, bool, bool, GObject.TYPE_INT64,
175 GObject.TYPE_INT64, str, bool,
176 GObject.TYPE_INT64, bool, str, GObject.TYPE_INT64, str, GObject.TYPE_INT64)
178 self._config = config
180 # Callback for when the filter / list changes, gets one parameter
181 # (has_episodes) that is True if the list has any episodes
182 self._on_filter_changed = on_filter_changed
184 # Filter to allow hiding some episodes
185 self._filter = self.filter_new()
186 self._sorter = Gtk.TreeModelSort(self._filter)
187 self._view_mode = self.VIEW_ALL
188 self._search_term = None
189 self._search_term_eql = None
190 self._filter.set_visible_func(self._filter_visible_func)
192 # Are we currently showing "all episodes"/section or a single channel?
193 self._section_view = False
195 self.icon_theme = Gtk.IconTheme.get_default()
196 self.ICON_WEB_BROWSER = 'web-browser'
197 self.ICON_AUDIO_FILE = 'audio-x-generic'
198 self.ICON_VIDEO_FILE = 'video-x-generic'
199 self.ICON_IMAGE_FILE = 'image-x-generic'
200 self.ICON_GENERIC_FILE = 'text-x-generic'
201 self.ICON_DOWNLOADING = 'go-down'
202 self.ICON_DELETED = 'edit-delete'
203 self.ICON_ERROR = 'dialog-error'
205 self.background_update = None
206 self.background_update_tag = None
208 if 'KDE_FULL_SESSION' in os.environ:
209 # Workaround until KDE adds all the freedesktop icons
210 # See https://bugs.kde.org/show_bug.cgi?id=233505 and
211 # http://gpodder.org/bug/553
212 self.ICON_DELETED = 'archive-remove'
214 def _format_filesize(self, episode):
215 if episode.file_size > 0:
216 return util.format_filesize(episode.file_size, digits=1)
217 else:
218 return None
220 def _filter_visible_func(self, model, iter, misc):
221 # If searching is active, set visibility based on search text
222 if self._search_term is not None and self._search_term != '':
223 episode = model.get_value(iter, self.C_EPISODE)
224 if episode is None:
225 return False
227 try:
228 return self._search_term_eql.match(episode)
229 except Exception as e:
230 return True
232 if self._view_mode == self.VIEW_ALL:
233 return True
234 elif self._view_mode == self.VIEW_UNDELETED:
235 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
236 elif self._view_mode == self.VIEW_DOWNLOADED:
237 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
238 elif self._view_mode == self.VIEW_UNPLAYED:
239 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
241 return True
243 def get_filtered_model(self):
244 """Returns a filtered version of this episode model
246 The filtered version should be displayed in the UI,
247 as this model can have some filters set that should
248 be reflected in the UI.
250 return self._sorter
252 def has_episodes(self):
253 """Returns True if episodes are visible (filtered)
255 If episodes are visible with the current filter
256 applied, return True (otherwise return False).
258 return bool(len(self._filter))
260 def set_view_mode(self, new_mode):
261 """Sets a new view mode for this model
263 After setting the view mode, the filtered model
264 might be updated to reflect the new mode."""
265 if self._view_mode != new_mode:
266 self._view_mode = new_mode
267 self._filter.refilter()
268 self._on_filter_changed(self.has_episodes())
270 def get_view_mode(self):
271 """Returns the currently-set view mode"""
272 return self._view_mode
274 def set_search_term(self, new_term):
275 if self._search_term != new_term:
276 self._search_term = new_term
277 self._search_term_eql = query.UserEQL(new_term)
278 self._filter.refilter()
279 self._on_filter_changed(self.has_episodes())
281 def get_search_term(self):
282 return self._search_term
284 def _format_description(self, episode, include_description=False):
285 title = episode.trimmed_title
287 if episode.state != gpodder.STATE_DELETED and episode.is_new:
288 yield '<b>'
289 yield html.escape(title)
290 yield '</b>'
291 else:
292 yield html.escape(title)
294 if include_description:
295 yield '\n'
296 if self._section_view:
297 yield _('from %s') % html.escape(episode.channel.title)
298 else:
299 description = episode.one_line_description()
300 if description.startswith(title):
301 description = description[len(title):].strip()
302 yield html.escape(description)
304 def replace_from_channel(self, channel, include_description=False):
306 Add episode from the given channel to this model.
307 Downloading should be a callback.
308 include_description should be a boolean value (True if description
309 is to be added to the episode row, or False if not)
312 # Remove old episodes in the list store
313 self.clear()
315 self._section_view = isinstance(channel, PodcastChannelProxy)
317 # Avoid gPodder bug 1291
318 if channel is None:
319 episodes = []
320 else:
321 episodes = channel.get_all_episodes()
323 # Always make a copy, so we can pass the episode list to BackgroundUpdate
324 episodes = list(episodes)
326 for _ in range(len(episodes)):
327 self.append()
329 self._update_from_episodes(episodes, include_description)
331 def _update_from_episodes(self, episodes, include_description):
332 if self.background_update_tag is not None:
333 GLib.source_remove(self.background_update_tag)
335 self.background_update = BackgroundUpdate(self, episodes, include_description)
336 self.background_update_tag = GLib.idle_add(self._update_background)
338 def _update_background(self):
339 if self.background_update is not None:
340 if self.background_update.update():
341 return True
343 self.background_update = None
344 self.background_update_tag = None
345 self._on_filter_changed(self.has_episodes())
347 return False
349 def update_all(self, include_description=False):
350 if self.background_update is None:
351 episodes = [row[self.C_EPISODE] for row in self]
352 else:
353 # Update all episodes that have already been initialized...
354 episodes = [row[self.C_EPISODE] for index, row in enumerate(self) if index < self.background_update.index]
355 # ...and also include episodes that still need to be initialized
356 episodes.extend(self.background_update.episodes)
358 self._update_from_episodes(episodes, include_description)
360 def update_by_urls(self, urls, include_description=False):
361 for row in self:
362 if row[self.C_URL] in urls:
363 self.update_by_iter(row.iter, include_description)
365 def update_by_filter_iter(self, iter, include_description=False):
366 # Convenience function for use by "outside" methods that use iters
367 # from the filtered episode list model (i.e. all UI things normally)
368 iter = self._sorter.convert_iter_to_child_iter(iter)
369 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter),
370 include_description)
372 def get_update_fields(self, episode, include_description):
373 show_bullet = False
374 show_padlock = False
375 show_missing = False
376 status_icon = None
377 tooltip = []
378 view_show_undeleted = True
379 view_show_downloaded = False
380 view_show_unplayed = False
382 task = episode.download_task
384 if task is not None and task.status in (task.PAUSING, task.PAUSED):
385 tooltip.append('%s %d%%' % (_('Paused'),
386 int(task.progress * 100)))
388 status_icon = 'media-playback-pause'
390 view_show_downloaded = True
391 view_show_unplayed = True
392 elif episode.downloading:
393 tooltip.append('%s %d%%' % (_('Downloading'),
394 int(task.progress * 100)))
396 index = int(self.PROGRESS_STEPS * task.progress)
397 status_icon = 'gpodder-progress-%d' % index
399 view_show_downloaded = True
400 view_show_unplayed = True
401 else:
402 if episode.state == gpodder.STATE_DELETED:
403 tooltip.append(_('Deleted'))
404 status_icon = self.ICON_DELETED
405 view_show_undeleted = False
406 elif episode.state == gpodder.STATE_DOWNLOADED:
407 tooltip = []
408 view_show_downloaded = True
409 view_show_unplayed = episode.is_new
410 show_bullet = episode.is_new
411 show_padlock = episode.archive
412 show_missing = not episode.file_exists()
413 filename = episode.local_filename(create=False, check_only=True)
415 file_type = episode.file_type()
416 if file_type == 'audio':
417 tooltip.append(_('Downloaded episode'))
418 status_icon = self.ICON_AUDIO_FILE
419 elif file_type == 'video':
420 tooltip.append(_('Downloaded video episode'))
421 status_icon = self.ICON_VIDEO_FILE
422 elif file_type == 'image':
423 tooltip.append(_('Downloaded image'))
424 status_icon = self.ICON_IMAGE_FILE
425 else:
426 tooltip.append(_('Downloaded file'))
427 status_icon = self.ICON_GENERIC_FILE
429 # Try to find a themed icon for this file
430 # doesn't work on win32 (opus files are showed as text)
431 if filename is not None and have_gio and not gpodder.ui.win32:
432 file = Gio.File.new_for_path(filename)
433 if file.query_exists():
434 file_info = file.query_info('*', Gio.FileQueryInfoFlags.NONE, None)
435 icon = file_info.get_icon()
436 for icon_name in icon.get_names():
437 if self.icon_theme.has_icon(icon_name):
438 status_icon = icon_name
439 break
441 if show_missing:
442 tooltip.append(_('missing file'))
443 else:
444 if show_bullet:
445 if file_type == 'image':
446 tooltip.append(_('never displayed'))
447 elif file_type in ('audio', 'video'):
448 tooltip.append(_('never played'))
449 else:
450 tooltip.append(_('never opened'))
451 else:
452 if file_type == 'image':
453 tooltip.append(_('displayed'))
454 elif file_type in ('audio', 'video'):
455 tooltip.append(_('played'))
456 else:
457 tooltip.append(_('opened'))
458 if show_padlock:
459 tooltip.append(_('deletion prevented'))
461 if episode.total_time > 0 and episode.current_position:
462 tooltip.append('%d%%' % (100. * float(episode.current_position) /
463 float(episode.total_time),))
464 elif episode._download_error is not None:
465 tooltip.append(_('ERROR: %s') % episode._download_error)
466 status_icon = self.ICON_ERROR
467 if episode.state == gpodder.STATE_NORMAL and episode.is_new:
468 view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new
469 view_show_unplayed = True
470 elif not episode.url:
471 tooltip.append(_('No downloadable content'))
472 status_icon = self.ICON_WEB_BROWSER
473 if episode.state == gpodder.STATE_NORMAL and episode.is_new:
474 view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new
475 view_show_unplayed = True
476 elif episode.state == gpodder.STATE_NORMAL and episode.is_new:
477 tooltip.append(_('New episode'))
478 view_show_downloaded = self._config.ui.gtk.episode_list.always_show_new
479 view_show_unplayed = True
481 if episode.total_time:
482 total_time = util.format_time(episode.total_time)
483 if total_time:
484 tooltip.append(total_time)
486 tooltip = ', '.join(tooltip)
488 description = ''.join(self._format_description(episode, include_description))
489 return (
490 (self.C_STATUS_ICON, status_icon),
491 (self.C_VIEW_SHOW_UNDELETED, view_show_undeleted),
492 (self.C_VIEW_SHOW_DOWNLOADED, view_show_downloaded),
493 (self.C_VIEW_SHOW_UNPLAYED, view_show_unplayed),
494 (self.C_DESCRIPTION, description),
495 (self.C_TOOLTIP, tooltip),
496 (self.C_TIME, episode.get_play_info_string()),
497 (self.C_TIME_VISIBLE, bool(episode.total_time)),
498 (self.C_TOTAL_TIME, episode.total_time),
499 (self.C_LOCKED, episode.archive),
500 (self.C_FILESIZE_TEXT, self._format_filesize(episode)),
501 (self.C_FILESIZE, episode.file_size),
503 (self.C_TIME_AND_SIZE, "%s\n<small>%s</small>"
504 % (episode.get_play_info_string(), self._format_filesize(episode) if episode.file_size > 0 else "")),
505 (self.C_TOTAL_TIME_AND_SIZE, episode.total_time),
506 (self.C_FILESIZE_AND_TIME_TEXT, "%s\n<small>%s</small>"
507 % (self._format_filesize(episode) if episode.file_size > 0 else "", episode.get_play_info_string())),
508 (self.C_FILESIZE_AND_TIME, episode.file_size),
511 def update_by_iter(self, iter, include_description=False):
512 episode = self.get_value(iter, self.C_EPISODE)
513 if episode is not None:
514 self.set(iter, *(x for pair in self.get_update_fields(episode, include_description) for x in pair))
517 class PodcastChannelProxy:
518 """ a bag of podcasts: 'All Episodes' or each section """
519 def __init__(self, db, config, channels, section, model):
520 self.ALL_EPISODES_PROXY = not bool(section)
521 self._db = db
522 self._config = config
523 self.channels = channels
524 if self.ALL_EPISODES_PROXY:
525 self.title = _('All episodes')
526 self.description = _('from all podcasts')
527 self.url = ''
528 self.cover_file = coverart.CoverDownloader.ALL_EPISODES_ID
529 else:
530 self.title = section
531 self.description = ''
532 self.url = '-'
533 self.cover_file = None
534 # self.parse_error = ''
535 self.section = section
536 self.id = None
537 self.cover_url = None
538 self.auth_username = None
539 self.auth_password = None
540 self.pause_subscription = False
541 self.sync_to_mp3_player = False
542 self.cover_thumb = None
543 self.auto_archive_episodes = False
544 self.model = model
546 self._update_error = None
548 def get_statistics(self):
549 if self.ALL_EPISODES_PROXY:
550 # Get the total statistics for all channels from the database
551 return self._db.get_podcast_statistics()
552 else:
553 # Calculate the stats over all podcasts of this section
554 if len(self.channels) == 0:
555 total = deleted = new = downloaded = unplayed = 0
556 else:
557 total, deleted, new, downloaded, unplayed = list(map(sum,
558 list(zip(*[c.get_statistics() for c in self.channels]))))
559 return total, deleted, new, downloaded, unplayed
561 def get_all_episodes(self):
562 """Returns a generator that yields every episode"""
563 if self.model._search_term is not None:
564 def matches(channel):
565 columns = (getattr(channel, c) for c in PodcastListModel.SEARCH_ATTRS)
566 return any((key in c.lower() for c in columns if c is not None))
567 key = self.model._search_term
568 else:
569 def matches(e):
570 return True
571 return Model.sort_episodes_by_pubdate((e for c in self.channels if matches(c)
572 for e in c.get_all_episodes()), True)
574 def save(self):
575 pass
578 class PodcastListModel(Gtk.ListStore):
579 C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
580 C_COVER, C_ERROR, C_PILL_VISIBLE, \
581 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
582 C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR, \
583 C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION = list(range(16))
585 SEARCH_COLUMNS = (C_TITLE, C_DESCRIPTION, C_SECTION)
586 SEARCH_ATTRS = ('title', 'description', 'group_by')
588 @classmethod
589 def row_separator_func(cls, model, iter):
590 return model.get_value(iter, cls.C_SEPARATOR)
592 def __init__(self, cover_downloader):
593 Gtk.ListStore.__init__(self, str, str, str, GdkPixbuf.Pixbuf,
594 object, GdkPixbuf.Pixbuf, str, bool, bool, bool, bool,
595 bool, bool, int, bool, str)
597 # Filter to allow hiding some episodes
598 self._filter = self.filter_new()
599 self._view_mode = -1
600 self._search_term = None
601 self._filter.set_visible_func(self._filter_visible_func)
603 self._cover_cache = {}
604 self._max_image_side = 40
605 self._scale = 1
606 self._cover_downloader = cover_downloader
608 self.icon_theme = Gtk.IconTheme.get_default()
609 self.ICON_DISABLED = 'media-playback-pause'
610 self.ICON_ERROR = 'dialog-warning'
612 def _filter_visible_func(self, model, iter, misc):
613 channel = model.get_value(iter, self.C_CHANNEL)
615 # If searching is active, set visibility based on search text
616 if self._search_term is not None and self._search_term != '':
617 key = self._search_term.lower()
618 if isinstance(channel, PodcastChannelProxy):
619 if channel.ALL_EPISODES_PROXY:
620 return False
621 return any(key in getattr(ch, c).lower() for c in PodcastListModel.SEARCH_ATTRS for ch in channel.channels)
622 columns = (model.get_value(iter, c) for c in self.SEARCH_COLUMNS)
623 return any((key in c.lower() for c in columns if c is not None))
625 # Show section if any of its channels have an update error
626 if isinstance(channel, PodcastChannelProxy) and not channel.ALL_EPISODES_PROXY:
627 if any(c._update_error is not None for c in channel.channels):
628 return True
630 if model.get_value(iter, self.C_SEPARATOR):
631 return True
632 elif getattr(channel, '_update_error', None) is not None:
633 return True
634 elif self._view_mode == EpisodeListModel.VIEW_ALL:
635 return model.get_value(iter, self.C_HAS_EPISODES)
636 elif self._view_mode == EpisodeListModel.VIEW_UNDELETED:
637 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
638 elif self._view_mode == EpisodeListModel.VIEW_DOWNLOADED:
639 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
640 elif self._view_mode == EpisodeListModel.VIEW_UNPLAYED:
641 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
643 return True
645 def get_filtered_model(self):
646 """Returns a filtered version of this episode model
648 The filtered version should be displayed in the UI,
649 as this model can have some filters set that should
650 be reflected in the UI.
652 return self._filter
654 def set_view_mode(self, new_mode):
655 """Sets a new view mode for this model
657 After setting the view mode, the filtered model
658 might be updated to reflect the new mode."""
659 if self._view_mode != new_mode:
660 self._view_mode = new_mode
661 self._filter.refilter()
663 def get_view_mode(self):
664 """Returns the currently-set view mode"""
665 return self._view_mode
667 def set_search_term(self, new_term):
668 if self._search_term != new_term:
669 self._search_term = new_term
670 self._filter.refilter()
672 def get_search_term(self):
673 return self._search_term
675 def set_max_image_size(self, size, scale):
676 self._max_image_side = size * scale
677 self._scale = scale
678 self._cover_cache = {}
680 def _resize_pixbuf_keep_ratio(self, url, pixbuf):
682 Resizes a GTK Pixbuf but keeps its aspect ratio.
683 Returns None if the pixbuf does not need to be
684 resized or the newly resized pixbuf if it does.
686 if url in self._cover_cache:
687 return self._cover_cache[url]
689 max_side = self._max_image_side
690 w_cur = pixbuf.get_width()
691 h_cur = pixbuf.get_height()
693 if w_cur <= max_side and h_cur <= max_side:
694 return None
696 f = max_side / (w_cur if w_cur >= h_cur else h_cur)
697 w_new = int(w_cur * f)
698 h_new = int(h_cur * f)
700 logger.debug("Scaling cover image: url=%s from %ix%i to %ix%i",
701 url, w_cur, h_cur, w_new, h_new)
702 pixbuf = pixbuf.scale_simple(w_new, h_new,
703 GdkPixbuf.InterpType.BILINEAR)
705 self._cover_cache[url] = pixbuf
706 return pixbuf
708 def _resize_pixbuf(self, url, pixbuf):
709 if pixbuf is None:
710 return None
712 return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf
714 def _overlay_pixbuf(self, pixbuf, icon):
715 try:
716 emblem = self.icon_theme.load_icon(icon, self._max_image_side / 2, 0)
717 (width, height) = (emblem.get_width(), emblem.get_height())
718 xpos = pixbuf.get_width() - width
719 ypos = pixbuf.get_height() - height
720 if ypos < 0:
721 # need to resize overlay for none standard icon size
722 emblem = self.icon_theme.load_icon(icon, pixbuf.get_height() - 1, 0)
723 (width, height) = (emblem.get_width(), emblem.get_height())
724 xpos = pixbuf.get_width() - width
725 ypos = pixbuf.get_height() - height
726 emblem.composite(pixbuf, xpos, ypos, width, height, xpos, ypos, 1, 1, GdkPixbuf.InterpType.BILINEAR, 255)
727 except:
728 pass
730 return pixbuf
732 def _get_cached_thumb(self, channel):
733 if channel.cover_thumb is None:
734 return None
736 try:
737 loader = GdkPixbuf.PixbufLoader()
738 loader.write(channel.cover_thumb)
739 loader.close()
740 pixbuf = loader.get_pixbuf()
741 if self._max_image_side not in (pixbuf.get_width(), pixbuf.get_height()):
742 logger.debug("cached thumb wrong size: %r != %i", (pixbuf.get_width(), pixbuf.get_height()), self._max_image_side)
743 return None
744 return pixbuf
745 except Exception as e:
746 logger.warning('Could not load cached cover art for %s', channel.url, exc_info=True)
747 channel.cover_thumb = None
748 channel.save()
749 return None
751 def _save_cached_thumb(self, channel, pixbuf):
752 bufs = []
754 def save_callback(buf, length, user_data):
755 user_data.append(buf)
756 return True
757 pixbuf.save_to_callbackv(save_callback, bufs, 'png', [None], [])
758 channel.cover_thumb = bytes(b''.join(bufs))
759 channel.save()
761 def _get_cover_image(self, channel, add_overlay=False, pixbuf_overlay=None):
762 """ get channel's cover image. Callable from gtk thread.
763 :param channel: channel model
764 :param bool add_overlay: True to add a pause/error overlay
765 :param GdkPixbuf.Pixbux pixbuf_overlay: existing pixbuf if already loaded, as an optimization
766 :return GdkPixbuf.Pixbux: channel's cover image as pixbuf
768 if self._cover_downloader is None:
769 return pixbuf_overlay
771 if pixbuf_overlay is None: # optimization: we can pass existing pixbuf
772 pixbuf_overlay = self._get_cached_thumb(channel)
774 if pixbuf_overlay is None:
775 # load cover if it's not in cache
776 pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
777 if pixbuf is None:
778 return None
779 pixbuf_overlay = self._resize_pixbuf(channel.url, pixbuf)
780 self._save_cached_thumb(channel, pixbuf_overlay)
782 if add_overlay:
783 if getattr(channel, '_update_error', None) is not None:
784 pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_ERROR)
785 elif channel.pause_subscription:
786 pixbuf_overlay = self._overlay_pixbuf(pixbuf_overlay, self.ICON_DISABLED)
787 pixbuf_overlay.saturate_and_pixelate(pixbuf_overlay, 0.0, False)
789 return pixbuf_overlay
791 def _get_pill_image(self, channel, count_downloaded, count_unplayed):
792 if count_unplayed > 0 or count_downloaded > 0:
793 return draw.draw_pill_pixbuf('{:n}'.format(count_unplayed),
794 '{:n}'.format(count_downloaded),
795 widget=self.widget,
796 scale=self._scale)
797 else:
798 return None
800 def _format_description(self, channel, total, deleted,
801 new, downloaded, unplayed):
802 title_markup = html.escape(channel.title)
803 if channel._update_error is not None:
804 description_markup = html.escape(_('ERROR: %s') % channel._update_error)
805 elif not channel.pause_subscription:
806 description_markup = html.escape(
807 util.get_first_line(util.remove_html_tags(channel.description)) or ' ')
808 else:
809 description_markup = html.escape(_('Subscription paused'))
810 d = []
811 if new:
812 d.append('<span weight="bold">')
813 d.append(title_markup)
814 if new:
815 d.append('</span>')
817 if channel._update_error is not None:
818 return ''.join(d + ['\n', '<span weight="bold">', description_markup, '</span>'])
819 elif description_markup.strip():
820 return ''.join(d + ['\n', '<small>', description_markup, '</small>'])
821 else:
822 return ''.join(d)
824 def _format_error(self, channel):
825 # if channel.parse_error:
826 # return str(channel.parse_error)
827 # else:
828 # return None
829 return None
831 def set_channels(self, db, config, channels):
832 # Clear the model and update the list of podcasts
833 self.clear()
835 def channel_to_row(channel, add_overlay=False):
836 # C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL
837 return (channel.url, '', '', None, channel,
838 # C_COVER, C_ERROR, C_PILL_VISIBLE,
839 self._get_cover_image(channel, add_overlay), '', True,
840 # C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED,
841 True, True,
842 # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR
843 True, True, False,
844 # C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION
845 0, True, '')
847 def section_to_row(section):
848 # C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL
849 return (section.url, '', '', None, section,
850 # C_COVER, C_ERROR, C_PILL_VISIBLE,
851 None, '', True,
852 # C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED,
853 True, True,
854 # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR
855 True, True, False,
856 # C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION
857 0, False, section.title)
859 if config.podcast_list_view_all and channels:
860 all_episodes = PodcastChannelProxy(db, config, channels, '', self)
861 iter = self.append(channel_to_row(all_episodes))
862 self.update_by_iter(iter)
864 # Separator item
865 if not config.podcast_list_sections:
866 self.append(('', '', '', None, SeparatorMarker, None, '',
867 True, True, True, True, True, True, 0, False, ''))
869 def groupby_func(channel):
870 return channel.group_by
872 def key_func(channel):
873 return (channel.group_by, model.Model.podcast_sort_key(channel))
875 if config.podcast_list_sections:
876 groups = groupby(sorted(channels, key=key_func), groupby_func)
877 else:
878 groups = [(None, sorted(channels, key=model.Model.podcast_sort_key))]
880 for section, section_channels in groups:
881 if config.podcast_list_sections and section is not None:
882 section_channels = list(section_channels)
883 section_obj = PodcastChannelProxy(db, config, section_channels, section, self)
884 iter = self.append(section_to_row(section_obj))
885 self.update_by_iter(iter)
886 for channel in section_channels:
887 iter = self.append(channel_to_row(channel, True))
888 self.update_by_iter(iter)
890 def get_filter_path_from_url(self, url):
891 # Return the path of the filtered model for a given URL
892 child_path = self.get_path_from_url(url)
893 if child_path is None:
894 return None
895 else:
896 return self._filter.convert_child_path_to_path(child_path)
898 def get_path_from_url(self, url):
899 # Return the tree model path for a given URL
900 if url is None:
901 return None
903 for row in self:
904 if row[self.C_URL] == url:
905 return row.path
906 return None
908 def update_first_row(self):
909 # Update the first row in the model (for "all episodes" updates)
910 self.update_by_iter(self.get_iter_first())
912 def update_by_urls(self, urls):
913 # Given a list of URLs, update each matching row
914 for row in self:
915 if row[self.C_URL] in urls:
916 self.update_by_iter(row.iter)
918 def iter_is_first_row(self, iter):
919 iter = self._filter.convert_iter_to_child_iter(iter)
920 path = self.get_path(iter)
921 return (path == Gtk.TreePath.new_first())
923 def update_by_filter_iter(self, iter):
924 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter))
926 def update_all(self):
927 for row in self:
928 self.update_by_iter(row.iter)
930 def update_sections(self):
931 for row in self:
932 if isinstance(row[self.C_CHANNEL], PodcastChannelProxy) and not row[self.C_CHANNEL].ALL_EPISODES_PROXY:
933 self.update_by_iter(row.iter)
935 def update_by_iter(self, iter):
936 if iter is None:
937 return
939 # Given a GtkTreeIter, update volatile information
940 channel = self.get_value(iter, self.C_CHANNEL)
942 if channel is SeparatorMarker:
943 return
945 total, deleted, new, downloaded, unplayed = channel.get_statistics()
947 if isinstance(channel, PodcastChannelProxy) and not channel.ALL_EPISODES_PROXY:
948 section = channel.title
950 # We could customized the section header here with the list
951 # of channels and their stats (i.e. add some "new" indicator)
952 description = '<b>%s</b>' % (
953 html.escape(section))
954 pill_image = None
955 cover_image = None
956 else:
957 description = self._format_description(channel, total, deleted, new,
958 downloaded, unplayed)
960 pill_image = self._get_pill_image(channel, downloaded, unplayed)
961 cover_image = self._get_cover_image(channel, True)
963 self.set(iter,
964 self.C_TITLE, channel.title,
965 self.C_DESCRIPTION, description,
966 self.C_COVER, cover_image,
967 self.C_SECTION, channel.section,
968 self.C_ERROR, self._format_error(channel),
969 self.C_PILL, pill_image,
970 self.C_PILL_VISIBLE, pill_image is not None,
971 self.C_VIEW_SHOW_UNDELETED, total - deleted > 0,
972 self.C_VIEW_SHOW_DOWNLOADED, downloaded + new > 0,
973 self.C_VIEW_SHOW_UNPLAYED, unplayed + new > 0,
974 self.C_HAS_EPISODES, total > 0,
975 self.C_DOWNLOADS, downloaded)
977 def clear_cover_cache(self, podcast_url):
978 if podcast_url in self._cover_cache:
979 logger.info('Clearing cover from cache: %s', podcast_url)
980 del self._cover_cache[podcast_url]
982 def add_cover_by_channel(self, channel, pixbuf):
983 if pixbuf is None:
984 return
985 # Remove older images from cache
986 self.clear_cover_cache(channel.url)
988 # Resize and add the new cover image
989 pixbuf = self._resize_pixbuf(channel.url, pixbuf)
990 self._save_cached_thumb(channel, pixbuf)
992 pixbuf = self._get_cover_image(channel, add_overlay=True, pixbuf_overlay=pixbuf)
994 for row in self:
995 if row[self.C_URL] == channel.url:
996 row[self.C_COVER] = pixbuf
997 break