1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 # gpodder.gtkui.model - GUI model classes for gPodder (2009-08-13)
23 # Based on code from libpodcasts.py (thp, 2005-10-29)
30 from gpodder
import util
32 from gpodder
.gtkui
import draw
35 import xml
.sax
.saxutils
37 class EpisodeListModel(gtk
.ListStore
):
38 C_URL
, C_TITLE
, C_FILESIZE_TEXT
, C_EPISODE
, C_STATUS_ICON
, \
39 C_PUBLISHED_TEXT
, C_DESCRIPTION
, C_TOOLTIP
, \
40 C_VIEW_SHOW_UNDELETED
, C_VIEW_SHOW_DOWNLOADED
, \
41 C_VIEW_SHOW_UNPLAYED
= range(11)
43 SEARCH_COLUMNS
= (C_TITLE
, C_DESCRIPTION
)
45 VIEW_ALL
, VIEW_UNDELETED
, VIEW_DOWNLOADED
, VIEW_UNPLAYED
= range(4)
48 gtk
.ListStore
.__init
__(self
, str, str, str, object, \
49 gtk
.gdk
.Pixbuf
, str, str, str, bool, bool, bool)
51 # Filter to allow hiding some episodes
52 self
._filter
= self
.filter_new()
53 self
._view
_mode
= self
.VIEW_ALL
54 self
._search
_term
= None
55 self
._filter
.set_visible_func(self
._filter
_visible
_func
)
57 # "ICON" is used to mark icon names in source files
61 self
.ICON_AUDIO_FILE
= ICON('audio-x-generic')
62 self
.ICON_VIDEO_FILE
= ICON('video-x-generic')
63 self
.ICON_IMAGE_FILE
= ICON('image-x-generic')
64 self
.ICON_GENERIC_FILE
= ICON('text-x-generic')
65 self
.ICON_DOWNLOADING
= gtk
.STOCK_GO_DOWN
66 self
.ICON_DELETED
= gtk
.STOCK_DELETE
67 self
.ICON_NEW
= gtk
.STOCK_ABOUT
68 self
.ICON_UNPLAYED
= ICON('emblem-new')
69 self
.ICON_LOCKED
= ICON('emblem-readonly')
70 self
.ICON_MISSING
= ICON('emblem-unreadable')
73 def _format_filesize(self
, episode
):
74 if episode
.length
> 0:
75 return util
.format_filesize(episode
.length
, 1)
80 def _filter_visible_func(self
, model
, iter):
81 # If searching is active, set visibility based on search text
82 if self
._search
_term
is not None:
83 key
= self
._search
_term
.lower()
84 return any((key
in (model
.get_value(iter, column
) or '').lower()) for column
in self
.SEARCH_COLUMNS
)
86 if self
._view
_mode
== self
.VIEW_ALL
:
88 elif self
._view
_mode
== self
.VIEW_UNDELETED
:
89 return model
.get_value(iter, self
.C_VIEW_SHOW_UNDELETED
)
90 elif self
._view
_mode
== self
.VIEW_DOWNLOADED
:
91 return model
.get_value(iter, self
.C_VIEW_SHOW_DOWNLOADED
)
92 elif self
._view
_mode
== self
.VIEW_UNPLAYED
:
93 return model
.get_value(iter, self
.C_VIEW_SHOW_UNPLAYED
)
97 def get_filtered_model(self
):
98 """Returns a filtered version of this episode model
100 The filtered version should be displayed in the UI,
101 as this model can have some filters set that should
102 be reflected in the UI.
106 def set_view_mode(self
, new_mode
):
107 """Sets a new view mode for this model
109 After setting the view mode, the filtered model
110 might be updated to reflect the new mode."""
111 if self
._view
_mode
!= new_mode
:
112 self
._view
_mode
= new_mode
113 self
._filter
.refilter()
115 def get_view_mode(self
):
116 """Returns the currently-set view mode"""
117 return self
._view
_mode
119 def set_search_term(self
, new_term
):
120 if self
._search
_term
!= new_term
:
121 self
._search
_term
= new_term
122 self
._filter
.refilter()
124 def get_search_term(self
):
125 return self
._search
_term
127 def _format_description(self
, episode
, include_description
=False, is_downloading
=None):
128 if include_description
:
129 return '%s\n<small>%s</small>' % (xml
.sax
.saxutils
.escape(episode
.title
),
130 xml
.sax
.saxutils
.escape(episode
.one_line_description()))
132 return xml
.sax
.saxutils
.escape(episode
.title
)
134 def add_from_channel(self
, channel
, downloading
=None, \
135 include_description
=False):
137 Add episode from the given channel to this model.
138 Downloading should be a callback.
139 include_description should be a boolean value (True if description
140 is to be added to the episode row, or False if not)
142 def insert_and_update(episode
):
143 description_stripped
= util
.remove_html_tags(episode
.description
)
147 self
.C_URL
, episode
.url
, \
148 self
.C_TITLE
, episode
.title
, \
149 self
.C_FILESIZE_TEXT
, self
._format
_filesize
(episode
), \
150 self
.C_EPISODE
, episode
, \
151 self
.C_PUBLISHED_TEXT
, episode
.cute_pubdate(), \
152 self
.C_TOOLTIP
, description_stripped
)
154 self
.update_by_iter(iter, downloading
, include_description
)
156 for episode
in channel
.get_all_episodes():
157 util
.idle_add(insert_and_update
, episode
)
159 def update_all(self
, downloading
=None, include_description
=False):
161 self
.update_by_iter(row
.iter, downloading
, include_description
)
163 def update_by_urls(self
, urls
, downloading
=None, include_description
=False):
165 if row
[self
.C_URL
] in urls
:
166 self
.update_by_iter(row
.iter, downloading
, include_description
)
168 def update_by_filter_iter(self
, iter, downloading
=None, \
169 include_description
=False):
170 # Convenience function for use by "outside" methods that use iters
171 # from the filtered episode list model (i.e. all UI things normally)
172 self
.update_by_iter(self
._filter
.convert_iter_to_child_iter(iter), \
173 downloading
, include_description
)
175 def update_by_iter(self
, iter, downloading
=None, include_description
=False):
176 episode
= self
.get_value(iter, self
.C_EPISODE
)
177 episode
.reload_from_db()
179 if include_description
or gpodder
.ui
.maemo
:
189 view_show_undeleted
= True
190 view_show_downloaded
= False
191 view_show_unplayed
= False
193 if downloading
is not None and downloading(episode
):
194 tooltip
= _('Downloading')
195 status_icon
= self
.ICON_DOWNLOADING
196 view_show_downloaded
= True
197 view_show_unplayed
= True
199 if episode
.state
== gpodder
.STATE_DELETED
:
200 tooltip
= _('Deleted')
201 status_icon
= self
.ICON_DELETED
202 view_show_undeleted
= False
203 elif episode
.state
== gpodder
.STATE_NORMAL
and \
204 not episode
.is_played
:
205 tooltip
= _('New episode')
206 status_icon
= self
.ICON_NEW
207 view_show_downloaded
= True
208 view_show_unplayed
= True
209 elif episode
.state
== gpodder
.STATE_DOWNLOADED
:
211 view_show_downloaded
= True
212 view_show_unplayed
= not episode
.is_played
213 show_bullet
= not episode
.is_played
214 show_padlock
= episode
.is_locked
215 show_missing
= not episode
.file_exists()
217 file_type
= episode
.file_type()
218 if file_type
== 'audio':
219 tooltip
.append(_('Downloaded episode'))
220 status_icon
= self
.ICON_AUDIO_FILE
221 elif file_type
== 'video':
222 tooltip
.append(_('Downloaded video episode'))
223 status_icon
= self
.ICON_VIDEO_FILE
224 elif file_type
== 'image':
225 tooltip
.append(_('Downloaded image'))
226 status_icon
= self
.ICON_IMAGE_FILE
228 tooltip
.append(_('Downloaded file'))
229 status_icon
= self
.ICON_GENERIC_FILE
232 tooltip
.append(_('missing file'))
235 if file_type
== 'image':
236 tooltip
.append(_('never displayed'))
238 tooltip
.append(_('never played'))
240 if file_type
== 'image':
241 tooltip
.append(_('displayed'))
243 tooltip
.append(_('played'))
245 tooltip
.append(_('deletion prevented'))
247 tooltip
= ', '.join(tooltip
)
249 if status_icon
is not None:
250 status_icon
= self
._get
_tree
_icon
(status_icon
, show_bullet
, \
251 show_padlock
, show_missing
, icon_size
)
253 description
= self
._format
_description
(episode
, include_description
, downloading
)
255 self
.C_STATUS_ICON
, status_icon
, \
256 self
.C_VIEW_SHOW_UNDELETED
, view_show_undeleted
, \
257 self
.C_VIEW_SHOW_DOWNLOADED
, view_show_downloaded
, \
258 self
.C_VIEW_SHOW_UNPLAYED
, view_show_unplayed
, \
259 self
.C_DESCRIPTION
, description
, \
260 self
.C_TOOLTIP
, tooltip
)
262 def _get_tree_icon(self
, icon_name
, add_bullet
=False, \
263 add_padlock
=False, add_missing
=False, icon_size
=32):
265 Loads an icon from the current icon theme at the specified
266 size, suitable for display in a gtk.TreeView. Additional
267 emblems can be added on top of the icon.
269 Caching is used to speed up the icon lookup.
272 # Add all variables that modify the appearance of the icon, so
273 # our cache does not return the same icons for different requests
274 cache_id
= (icon_name
, add_bullet
, add_padlock
, add_missing
, icon_size
)
276 if cache_id
in self
._icon
_cache
:
277 return self
._icon
_cache
[cache_id
]
279 icon_theme
= gtk
.icon_theme_get_default()
282 icon
= icon_theme
.load_icon(icon_name
, icon_size
, 0)
284 icon
= icon_theme
.load_icon(gtk
.STOCK_DIALOG_QUESTION
, icon_size
, 0)
286 if icon
and (add_bullet
or add_padlock
or add_missing
):
287 # We'll modify the icon, so use .copy()
291 # Desaturate the icon so it looks even more "missing"
292 icon
.saturate_and_pixelate(icon
, 0.0, False)
293 emblem
= icon_theme
.load_icon(self
.ICON_MISSING
, icon_size
/2, 0)
294 (width
, height
) = (emblem
.get_width(), emblem
.get_height())
295 xpos
= icon
.get_width() - width
296 ypos
= icon
.get_height() - height
297 emblem
.composite(icon
, xpos
, ypos
, width
, height
, xpos
, ypos
, 1, 1, gtk
.gdk
.INTERP_BILINEAR
, 255)
303 emblem
= icon_theme
.load_icon(self
.ICON_UNPLAYED
, icon_size
/2, 0)
304 (width
, height
) = (emblem
.get_width(), emblem
.get_height())
305 xpos
= icon
.get_width() - width
306 ypos
= icon
.get_height() - height
307 emblem
.composite(icon
, xpos
, ypos
, width
, height
, xpos
, ypos
, 1, 1, gtk
.gdk
.INTERP_BILINEAR
, 255)
313 emblem
= icon_theme
.load_icon(self
.ICON_LOCKED
, icon_size
/2, 0)
314 (width
, height
) = (emblem
.get_width(), emblem
.get_height())
315 emblem
.composite(icon
, 0, 0, width
, height
, 0, 0, 1, 1, gtk
.gdk
.INTERP_BILINEAR
, 255)
319 self
._icon
_cache
[cache_id
] = icon
323 class PodcastChannelProxy(object):
324 def __init__(self
, db
, config
, channels
):
326 self
._config
= config
327 self
.channels
= channels
328 self
.title
= _('All episodes')
329 self
.description
= _('from all podcasts')
330 self
.parse_error
= ''
333 self
._save
_dir
_size
_set
= False
334 self
.save_dir_size
= 0L
337 def __getattribute__(self
, name
):
339 return object.__getattribute
__(self
, name
)
340 except AttributeError:
341 log('Unsupported method call (%s)', name
, sender
=self
)
343 def get_statistics(self
):
344 # Get the total statistics for all channels from the database
345 return self
._db
.get_total_count()
347 def get_all_episodes(self
):
348 """Returns a generator that yields every episode"""
349 for channel
in self
.channels
:
350 for episode
in channel
.get_all_episodes():
353 def request_save_dir_size(self
):
354 if not self
._save
_dir
_size
_set
:
355 self
.update_save_dir_size()
356 self
._save
_dir
_size
_set
= True
358 def update_save_dir_size(self
):
359 self
.save_dir_size
= util
.calculate_size(self
._config
.download_dir
)
362 class PodcastListModel(gtk
.ListStore
):
363 C_URL
, C_TITLE
, C_DESCRIPTION
, C_PILL
, C_CHANNEL
, \
364 C_COVER
, C_ERROR
, C_PILL_VISIBLE
, \
365 C_VIEW_SHOW_UNDELETED
, C_VIEW_SHOW_DOWNLOADED
, \
366 C_VIEW_SHOW_UNPLAYED
, C_HAS_EPISODES
, C_SEPARATOR
= range(13)
368 SEARCH_COLUMNS
= (C_TITLE
, C_DESCRIPTION
)
371 def row_separator_func(cls
, model
, iter):
372 return model
.get_value(iter, cls
.C_SEPARATOR
)
374 def __init__(self
, max_image_side
, cover_downloader
):
375 gtk
.ListStore
.__init
__(self
, str, str, str, gtk
.gdk
.Pixbuf
, \
376 object, gtk
.gdk
.Pixbuf
, str, bool, bool, bool, bool, bool, bool)
378 # Filter to allow hiding some episodes
379 self
._filter
= self
.filter_new()
381 self
._search
_term
= None
382 self
._filter
.set_visible_func(self
._filter
_visible
_func
)
384 self
._cover
_cache
= {}
385 if gpodder
.ui
.fremantle
:
386 self
._max
_image
_side
= 64
388 self
._max
_image
_side
= max_image_side
389 self
._cover
_downloader
= cover_downloader
391 def _filter_visible_func(self
, model
, iter):
392 # If searching is active, set visibility based on search text
393 if self
._search
_term
is not None:
394 key
= self
._search
_term
.lower()
395 return any((key
in model
.get_value(iter, column
).lower()) for column
in self
.SEARCH_COLUMNS
)
397 if model
.get_value(iter, self
.C_SEPARATOR
):
399 if self
._view
_mode
== EpisodeListModel
.VIEW_ALL
:
400 return model
.get_value(iter, self
.C_HAS_EPISODES
)
401 elif self
._view
_mode
== EpisodeListModel
.VIEW_UNDELETED
:
402 return model
.get_value(iter, self
.C_VIEW_SHOW_UNDELETED
)
403 elif self
._view
_mode
== EpisodeListModel
.VIEW_DOWNLOADED
:
404 return model
.get_value(iter, self
.C_VIEW_SHOW_DOWNLOADED
)
405 elif self
._view
_mode
== EpisodeListModel
.VIEW_UNPLAYED
:
406 return model
.get_value(iter, self
.C_VIEW_SHOW_UNPLAYED
)
410 def get_filtered_model(self
):
411 """Returns a filtered version of this episode model
413 The filtered version should be displayed in the UI,
414 as this model can have some filters set that should
415 be reflected in the UI.
419 def set_view_mode(self
, new_mode
):
420 """Sets a new view mode for this model
422 After setting the view mode, the filtered model
423 might be updated to reflect the new mode."""
424 if self
._view
_mode
!= new_mode
:
425 self
._view
_mode
= new_mode
426 self
._filter
.refilter()
428 def get_view_mode(self
):
429 """Returns the currently-set view mode"""
430 return self
._view
_mode
432 def set_search_term(self
, new_term
):
433 if self
._search
_term
!= new_term
:
434 self
._search
_term
= new_term
435 self
._filter
.refilter()
437 def get_search_term(self
):
438 return self
._search
_term
440 def enable_separators(self
, channeltree
):
441 channeltree
.set_row_separator_func(self
._show
_row
_separator
)
443 def _show_row_separator(self
, model
, iter):
444 return model
.get_value(iter, self
.C_SEPARATOR
)
446 def _resize_pixbuf_keep_ratio(self
, url
, pixbuf
):
448 Resizes a GTK Pixbuf but keeps its aspect ratio.
449 Returns None if the pixbuf does not need to be
450 resized or the newly resized pixbuf if it does.
455 if url
in self
._cover
_cache
:
456 return self
._cover
_cache
[url
]
459 if pixbuf
.get_width() > self
._max
_image
_side
:
460 f
= float(self
._max
_image
_side
)/pixbuf
.get_width()
461 (width
, height
) = (int(pixbuf
.get_width()*f
), int(pixbuf
.get_height()*f
))
462 pixbuf
= pixbuf
.scale_simple(width
, height
, gtk
.gdk
.INTERP_BILINEAR
)
466 if pixbuf
.get_height() > self
._max
_image
_side
:
467 f
= float(self
._max
_image
_side
)/pixbuf
.get_height()
468 (width
, height
) = (int(pixbuf
.get_width()*f
), int(pixbuf
.get_height()*f
))
469 pixbuf
= pixbuf
.scale_simple(width
, height
, gtk
.gdk
.INTERP_BILINEAR
)
473 self
._cover
_cache
[url
] = pixbuf
478 def _resize_pixbuf(self
, url
, pixbuf
):
482 return self
._resize
_pixbuf
_keep
_ratio
(url
, pixbuf
) or pixbuf
484 def _get_cover_image(self
, channel
):
485 if self
._cover
_downloader
is None:
488 pixbuf
= self
._cover
_downloader
.get_cover(channel
, avoid_downloading
=True)
489 return self
._resize
_pixbuf
(channel
.url
, pixbuf
)
491 def _get_pill_image(self
, channel
, count_downloaded
, count_unplayed
):
492 if count_unplayed
> 0 or count_downloaded
> 0:
493 return draw
.draw_pill_pixbuf(str(count_unplayed
), str(count_downloaded
))
497 def _format_description(self
, channel
, total
, deleted
, \
498 new
, downloaded
, unplayed
):
499 title_markup
= xml
.sax
.saxutils
.escape(channel
.title
)
500 description_markup
= xml
.sax
.saxutils
.escape(util
.get_first_line(channel
.description
) or ' ')
503 d
.append('<span weight="bold">')
504 d
.append(title_markup
)
507 return ''.join(d
+['\n', '<small>', description_markup
, '</small>'])
509 def _format_error(self
, channel
):
510 if channel
.parse_error
:
511 return str(channel
.parse_error
)
515 def set_channels(self
, db
, config
, channels
):
516 # Clear the model and update the list of podcasts
519 if config
.podcast_list_view_all
:
520 all_episodes
= PodcastChannelProxy(db
, config
, channels
)
523 self
.C_URL
, all_episodes
.url
, \
524 self
.C_CHANNEL
, all_episodes
, \
525 self
.C_COVER
, all_episodes
.icon
, \
526 self
.C_SEPARATOR
, False)
527 self
.update_by_iter(iter)
530 self
.set(iter, self
.C_SEPARATOR
, True)
532 for channel
in channels
:
535 self
.C_URL
, channel
.url
, \
536 self
.C_CHANNEL
, channel
, \
537 self
.C_COVER
, self
._get
_cover
_image
(channel
), \
538 self
.C_SEPARATOR
, False)
539 self
.update_by_iter(iter)
541 def get_filter_path_from_url(self
, url
):
542 # Return the path of the filtered model for a given URL
543 child_path
= self
.get_path_from_url(url
)
544 if child_path
is None:
547 return self
._filter
.convert_child_path_to_path(child_path
)
549 def get_path_from_url(self
, url
):
550 # Return the tree model path for a given URL
555 if row
[self
.C_URL
] == url
:
559 def update_by_urls(self
, urls
):
560 # Given a list of URLs, update each matching row
562 if row
[self
.C_URL
] in urls
:
563 self
.update_by_iter(row
.iter)
565 def update_by_filter_iter(self
, iter):
566 self
.update_by_iter(self
._filter
.convert_iter_to_child_iter(iter))
568 def update_all(self
):
570 self
.update_by_iter(row
.iter)
572 def update_by_iter(self
, iter):
573 # Given a GtkTreeIter, update volatile information
574 channel
= self
.get_value(iter, self
.C_CHANNEL
)
577 total
, deleted
, new
, downloaded
, unplayed
= channel
.get_statistics()
578 description
= self
._format
_description
(channel
, total
, deleted
, new
, \
579 downloaded
, unplayed
)
581 pill_image
= self
._get
_pill
_image
(channel
, downloaded
, unplayed
)
583 self
.C_TITLE
, channel
.title
, \
584 self
.C_DESCRIPTION
, description
, \
585 self
.C_ERROR
, self
._format
_error
(channel
), \
586 self
.C_PILL
, pill_image
, \
587 self
.C_PILL_VISIBLE
, pill_image
!= None, \
588 self
.C_VIEW_SHOW_UNDELETED
, total
- deleted
> 0, \
589 self
.C_VIEW_SHOW_DOWNLOADED
, downloaded
+ new
> 0, \
590 self
.C_VIEW_SHOW_UNPLAYED
, unplayed
+ new
> 0, \
591 self
.C_HAS_EPISODES
, total
> 0)
593 def add_cover_by_url(self
, url
, pixbuf
):
594 # Resize and add the new cover image
595 pixbuf
= self
._resize
_pixbuf
(url
, pixbuf
)
597 if row
[self
.C_URL
] == url
:
598 row
[self
.C_COVER
] = pixbuf
601 def delete_cover_by_url(self
, url
):
602 # Remove the cover from the model
604 if row
[self
.C_URL
] == url
:
605 row
[self
.C_COVER
] = None
608 # Remove the cover from the cache
609 if url
in self
._cover
_cache
:
610 del self
._cover
_cache
[url
]