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)
31 from itertools
import groupby
33 from gi
.repository
import GdkPixbuf
, GLib
, GObject
, Gtk
36 from gpodder
import coverart
, model
, query
, util
37 from gpodder
.gtkui
import draw
41 logger
= logging
.getLogger(__name__
)
45 from gi
.repository
import Gio
50 # ----------------------------------------------------------
53 class GEpisode(model
.PodcastEpisode
):
57 def title_markup(self
):
58 return '%s\n<small>%s</small>' % (html
.escape(self
.title
),
59 html
.escape(self
.channel
.title
))
62 def markup_new_episodes(self
):
63 if self
.file_size
> 0:
64 length_str
= '%s; ' % util
.format_filesize(self
.file_size
)
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
)))
75 def markup_delete_episodes(self
):
76 if self
.total_time
and self
.current_position
:
77 played_string
= self
.get_play_info_string()
79 played_string
= _('played')
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
):
97 EpisodeClass
= GEpisode
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
):
118 self
.episodes
= episodes
119 self
.include_description
= include_description
124 include_description
= self
.include_description
126 started
= time
.time()
128 episode
= self
.episodes
.pop(0)
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
)
138 it
= model
.get_iter((self
.index
,))
139 # fix #727 the tree might be invalid when trying to update so discard the exception
142 model
.set(it
, *(x
for fields
in (base_fields
, update_fields
)
143 for pair
in fields
for x
in pair
))
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:
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
, \
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
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)
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
)
228 return self
._search
_term
_eql
.match(episode
)
229 except Exception as e
:
232 if self
._view
_mode
== self
.VIEW_ALL
:
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
)
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.
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
:
289 yield html
.escape(title
)
292 yield html
.escape(title
)
294 if include_description
:
296 if self
._section
_view
:
297 yield _('from %s') % html
.escape(episode
.channel
.title
)
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
315 self
._section
_view
= isinstance(channel
, PodcastChannelProxy
)
317 # Avoid gPodder bug 1291
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
)):
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():
343 self
.background_update
= None
344 self
.background_update_tag
= None
345 self
._on
_filter
_changed
(self
.has_episodes())
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
]
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):
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),
372 def get_update_fields(self
, episode
, include_description
):
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
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
:
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
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
442 tooltip
.append(_('missing file'))
445 if file_type
== 'image':
446 tooltip
.append(_('never displayed'))
447 elif file_type
in ('audio', 'video'):
448 tooltip
.append(_('never played'))
450 tooltip
.append(_('never opened'))
452 if file_type
== 'image':
453 tooltip
.append(_('displayed'))
454 elif file_type
in ('audio', 'video'):
455 tooltip
.append(_('played'))
457 tooltip
.append(_('opened'))
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
)
484 tooltip
.append(total_time
)
486 tooltip
= ', '.join(tooltip
)
488 description
= ''.join(self
._format
_description
(episode
, include_description
))
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
)
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')
528 self
.cover_file
= coverart
.CoverDownloader
.ALL_EPISODES_ID
531 self
.description
= ''
533 self
.cover_file
= None
534 # self.parse_error = ''
535 self
.section
= section
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
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()
553 # Calculate the stats over all podcasts of this section
554 if len(self
.channels
) == 0:
555 total
= deleted
= new
= downloaded
= unplayed
= 0
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
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)
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')
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()
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
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
:
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
):
630 if model
.get_value(iter, self
.C_SEPARATOR
):
632 elif getattr(channel
, '_update_error', None) is not None:
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
)
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.
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
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
:
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
708 def _resize_pixbuf(self
, url
, pixbuf
):
712 return self
._resize
_pixbuf
_keep
_ratio
(url
, pixbuf
) or pixbuf
714 def _overlay_pixbuf(self
, pixbuf
, icon
):
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
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)
732 def _get_cached_thumb(self
, channel
):
733 if channel
.cover_thumb
is None:
737 loader
= GdkPixbuf
.PixbufLoader()
738 loader
.write(channel
.cover_thumb
)
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
)
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
751 def _save_cached_thumb(self
, channel
, pixbuf
):
754 def save_callback(buf
, length
, user_data
):
755 user_data
.append(buf
)
757 pixbuf
.save_to_callbackv(save_callback
, bufs
, 'png', [None], [])
758 channel
.cover_thumb
= bytes(b
''.join(bufs
))
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)
779 pixbuf_overlay
= self
._resize
_pixbuf
(channel
.url
, pixbuf
)
780 self
._save
_cached
_thumb
(channel
, pixbuf_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
),
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 ' ')
809 description_markup
= html
.escape(_('Subscription paused'))
812 d
.append('<span weight="bold">')
813 d
.append(title_markup
)
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>'])
824 def _format_error(self
, channel
):
825 # if channel.parse_error:
826 # return str(channel.parse_error)
831 def set_channels(self
, db
, config
, channels
):
832 # Clear the model and update the list of podcasts
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,
842 # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR
844 # C_DOWNLOADS, C_COVER_VISIBLE, C_SECTION
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,
852 # C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED,
854 # C_VIEW_SHOW_UNPLAYED, C_HAS_EPISODES, C_SEPARATOR
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)
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
)
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:
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
904 if row
[self
.C_URL
] == url
:
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
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
):
928 self
.update_by_iter(row
.iter)
930 def update_sections(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):
939 # Given a GtkTreeIter, update volatile information
940 channel
= self
.get_value(iter, self
.C_CHANNEL
)
942 if channel
is SeparatorMarker
:
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
))
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)
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
):
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
)
995 if row
[self
.C_URL
] == channel
.url
:
996 row
[self
.C_COVER
] = pixbuf