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/>.
33 import requests
.exceptions
34 import urllib3
.exceptions
37 from gpodder
import (common
, download
, extensions
, feedcore
, my
, opml
, player
,
39 from gpodder
.dbusproxy
import DBusPodcastsProxy
40 from gpodder
.model
import Model
, PodcastEpisode
41 from gpodder
.syncui
import gPodderSyncUI
43 from . import shownotes
44 from .desktop
.channel
import gPodderChannel
45 from .desktop
.episodeselector
import gPodderEpisodeSelector
46 from .desktop
.exportlocal
import gPodderExportToLocalFolder
47 from .desktop
.podcastdirectory
import gPodderPodcastDirectory
48 from .desktop
.welcome
import gPodderWelcome
49 from .desktopfile
import UserAppsReader
50 from .download
import DownloadStatusModel
51 from .draw
import (cake_size_from_widget
, draw_cake_pixbuf
,
52 draw_iconcell_scale
, draw_text_box_centered
)
53 from .interface
.addpodcast
import gPodderAddPodcast
54 from .interface
.common
import BuilderWidget
, TreeViewHelper
55 from .interface
.progress
import ProgressIndicator
56 from .interface
.searchtree
import SearchTree
57 from .model
import EpisodeListModel
, PodcastChannelProxy
, PodcastListModel
58 from .services
import CoverDownloader
59 from .widgets
import SimpleMessageArea
61 import gi
# isort:skip
62 gi
.require_version('Gtk', '3.0') # isort:skip
63 from gi
.repository
import Gdk
, GdkPixbuf
, Gio
, GLib
, GObject
, Gtk
, Pango
# isort:skip
66 logger
= logging
.getLogger(__name__
)
72 class gPodder(BuilderWidget
, dbus
.service
.Object
):
74 def __init__(self
, app
, bus_name
, gpodder_core
, options
):
75 dbus
.service
.Object
.__init
__(self
, object_path
=gpodder
.dbus_gui_object_path
, bus_name
=bus_name
)
76 self
.podcasts_proxy
= DBusPodcastsProxy(lambda: self
.channels
,
77 self
.on_itemUpdate_activate
,
78 self
.playback_episodes
,
79 self
.download_episode_list
,
80 self
.episode_object_by_uri
,
82 self
.application
= app
83 self
.core
= gpodder_core
84 self
.config
= self
.core
.config
85 self
.db
= self
.core
.db
86 self
.model
= self
.core
.model
87 self
.options
= options
88 self
.extensions_menu
= None
89 self
.extensions_actions
= []
90 self
._search
_podcasts
= None
91 self
._search
_episodes
= None
92 BuilderWidget
.__init
__(self
, None,
93 _gtk_properties
={('gPodder', 'application'): app
})
95 self
.last_episode_date_refresh
= None
96 self
.refresh_episode_dates()
99 if self
.application
.want_headerbar
:
100 self
.header_bar
= Gtk
.HeaderBar()
101 self
.header_bar
.pack_end(self
.application
.header_bar_menu_button
)
102 self
.header_bar
.pack_start(self
.application
.header_bar_refresh_button
)
103 self
.header_bar
.set_show_close_button(True)
104 self
.header_bar
.show_all()
106 # Tweaks to the UI since we moved the refresh button into the header bar
107 self
.vboxChannelNavigator
.set_row_spacing(0)
109 self
.main_window
.set_titlebar(self
.header_bar
)
111 gpodder
.user_extensions
.on_ui_object_available('gpodder-gtk', self
)
112 self
.toolbar
.set_property('visible', self
.config
.show_toolbar
)
114 self
.bluetooth_available
= util
.bluetooth_available()
116 self
.config
.connect_gtk_window(self
.main_window
, 'main_window')
118 self
.config
.connect_gtk_paned('ui.gtk.state.main_window.paned_position', self
.channelPaned
)
120 self
.main_window
.show()
122 self
.player_receiver
= player
.MediaPlayerDBusReceiver(self
.on_played
)
124 self
.gPodder
.connect('key-press-event', self
.on_key_press
)
126 self
.episode_columns_menu
= None
127 self
.config
.add_observer(self
.on_config_changed
)
129 self
.shownotes_pane
= Gtk
.Box()
130 self
.shownotes_object
= shownotes
.get_shownotes(self
.config
.ui
.gtk
.html_shownotes
, self
.shownotes_pane
)
132 # Vertical paned for the episode list and shownotes
133 self
.vpaned
= Gtk
.Paned(orientation
=Gtk
.Orientation
.VERTICAL
)
134 paned
= self
.vbox_episode_list
.get_parent()
135 self
.vbox_episode_list
.reparent(self
.vpaned
)
136 self
.vpaned
.child_set_property(self
.vbox_episode_list
, 'resize', True)
137 self
.vpaned
.child_set_property(self
.vbox_episode_list
, 'shrink', False)
138 self
.vpaned
.pack2(self
.shownotes_pane
, resize
=False, shrink
=False)
141 # Minimum height for both episode list and shownotes
142 self
.vbox_episode_list
.set_size_request(-1, 100)
143 self
.shownotes_pane
.set_size_request(-1, 100)
145 self
.config
.connect_gtk_paned('ui.gtk.state.main_window.episode_list_size',
147 paned
.add2(self
.vpaned
)
149 self
.new_episodes_window
= None
151 self
.download_status_model
= DownloadStatusModel()
152 self
.download_queue_manager
= download
.DownloadQueueManager(self
.config
, self
.download_status_model
)
154 self
.config
.connect_gtk_spinbutton('limit.downloads.concurrent', self
.spinMaxDownloads
,
155 self
.config
.limit
.downloads
.concurrent_max
)
156 self
.config
.connect_gtk_togglebutton('max_downloads_enabled', self
.cbMaxDownloads
)
157 self
.config
.connect_gtk_spinbutton('limit_rate_value', self
.spinLimitDownloads
)
158 self
.config
.connect_gtk_togglebutton('limit_rate', self
.cbLimitDownloads
)
160 # When the amount of maximum downloads changes, notify the queue manager
161 def changed_cb(spinbutton
):
162 return self
.download_queue_manager
.update_max_downloads()
164 self
.spinMaxDownloads
.connect('value-changed', changed_cb
)
165 self
.cbMaxDownloads
.connect('toggled', changed_cb
)
167 # Keep a reference to the last add podcast dialog instance
168 self
._add
_podcast
_dialog
= None
170 self
.default_title
= None
171 self
.set_title(_('gPodder'))
173 self
.cover_downloader
= CoverDownloader()
175 # Generate list models for podcasts and their episodes
176 self
.podcast_list_model
= PodcastListModel(self
.cover_downloader
)
177 self
.apply_podcast_list_hide_boring()
179 self
.cover_downloader
.register('cover-available', self
.cover_download_finished
)
181 # Source IDs for timeouts for search-as-you-type
182 self
._podcast
_list
_search
_timeout
= None
183 self
._episode
_list
_search
_timeout
= None
185 # Subscribed channels
186 self
.active_channel
= None
187 self
.channels
= self
.model
.get_podcasts()
189 # For loading the list model
190 self
.episode_list_model
= EpisodeListModel(self
.config
, self
.on_episode_list_filter_changed
)
192 self
.create_actions()
194 # Init the treeviews that we use
195 self
.init_podcast_list_treeview()
196 self
.init_episode_list_treeview()
197 self
.init_download_list_treeview()
199 self
.download_tasks_seen
= set()
200 self
.download_list_update_enabled
= False
201 self
.things_adding_tasks
= 0
202 self
.download_task_monitors
= set()
204 # Set up the first instance of MygPoClient
205 self
.mygpo_client
= my
.MygPoClient(self
.config
)
207 self
.inject_extensions_menu()
209 gpodder
.user_extensions
.on_ui_initialized(self
.model
,
210 self
.extensions_podcast_update_cb
,
211 self
.extensions_episode_download_cb
)
213 gpodder
.user_extensions
.on_application_started()
215 # load list of user applications for audio playback
216 self
.user_apps_reader
= UserAppsReader(['audio', 'video'])
217 util
.run_in_background(self
.user_apps_reader
.read
)
219 # Now, update the feed cache, when everything's in place
220 if not self
.application
.want_headerbar
:
221 self
.btnUpdateFeeds
.show()
222 self
.feed_cache_update_cancelled
= False
223 self
.update_podcast_list_model()
225 self
.message_area
= None
227 self
.partial_downloads_indicator
= None
228 util
.run_in_background(self
.find_partial_downloads
)
230 # Start the auto-update procedure
231 self
._auto
_update
_timer
_source
_id
= None
232 if self
.config
.auto_update_feeds
:
233 self
.restart_auto_update_timer()
235 # Find expired (old) episodes and delete them
236 old_episodes
= list(common
.get_expired_episodes(self
.channels
, self
.config
))
237 if len(old_episodes
) > 0:
238 self
.delete_episode_list(old_episodes
, confirm
=False)
239 updated_urls
= set(e
.channel
.url
for e
in old_episodes
)
240 self
.update_podcast_list_model(updated_urls
)
242 # Do the initial sync with the web service
243 if self
.mygpo_client
.can_access_webservice():
244 util
.idle_add(self
.mygpo_client
.flush
, True)
246 # First-time users should be asked if they want to see the OPML
247 if self
.options
.subscribe
:
248 util
.idle_add(self
.subscribe_to_url
, self
.options
.subscribe
)
249 elif not self
.channels
:
250 self
.on_itemUpdate_activate()
251 elif self
.config
.software_update
.check_on_startup
:
252 # Check for software updates from gpodder.org
253 diff
= time
.time() - self
.config
.software_update
.last_check
254 if diff
> (60 * 60 * 24) * self
.config
.software_update
.interval
:
255 self
.config
.software_update
.last_check
= int(time
.time())
256 if not os
.path
.exists(gpodder
.no_update_check_file
):
257 self
.check_for_updates(silent
=True)
259 if self
.options
.close_after_startup
:
260 logger
.warning("Startup done, closing (--close-after-startup)")
264 def create_actions(self
):
267 action
= Gio
.SimpleAction
.new_stateful(
268 'showEpisodeDescription', None, GLib
.Variant
.new_boolean(self
.config
.episode_list_descriptions
))
269 action
.connect('activate', self
.on_itemShowDescription_activate
)
272 action
= Gio
.SimpleAction
.new_stateful(
273 'viewHideBoringPodcasts', None, GLib
.Variant
.new_boolean(self
.config
.podcast_list_hide_boring
))
274 action
.connect('activate', self
.on_item_view_hide_boring_podcasts_toggled
)
277 action
= Gio
.SimpleAction
.new_stateful(
278 'viewAlwaysShowNewEpisodes', None, GLib
.Variant
.new_boolean(self
.config
.ui
.gtk
.episode_list
.always_show_new
))
279 action
.connect('activate', self
.on_item_view_always_show_new_episodes_toggled
)
282 action
= Gio
.SimpleAction
.new_stateful(
283 'viewCtrlClickToSortEpisodes', None, GLib
.Variant
.new_boolean(self
.config
.ui
.gtk
.episode_list
.ctrl_click_to_sort
))
284 action
.connect('activate', self
.on_item_view_ctrl_click_to_sort_episodes_toggled
)
287 action
= Gio
.SimpleAction
.new_stateful(
288 'searchAlwaysVisible', None, GLib
.Variant
.new_boolean(self
.config
.ui
.gtk
.search_always_visible
))
289 action
.connect('activate', self
.on_item_view_search_always_visible_toggled
)
292 value
= EpisodeListModel
.VIEWS
[
293 self
.config
.episode_list_view_mode
or EpisodeListModel
.VIEW_ALL
]
294 action
= Gio
.SimpleAction
.new_stateful(
295 'viewEpisodes', GLib
.VariantType
.new('s'),
296 GLib
.Variant
.new_string(value
))
297 action
.connect('activate', self
.on_item_view_episodes_changed
)
301 ('update', self
.on_itemUpdate_activate
),
302 ('downloadAllNew', self
.on_itemDownloadAllNew_activate
),
303 ('removeOldEpisodes', self
.on_itemRemoveOldEpisodes_activate
),
304 ('discover', self
.on_itemImportChannels_activate
),
305 ('addChannel', self
.on_itemAddChannel_activate
),
306 ('massUnsubscribe', self
.on_itemMassUnsubscribe_activate
),
307 ('updateChannel', self
.on_itemUpdateChannel_activate
),
308 ('editChannel', self
.on_itemEditChannel_activate
),
309 ('importFromFile', self
.on_item_import_from_file_activate
),
310 ('exportChannels', self
.on_itemExportChannels_activate
),
311 ('play', self
.on_playback_selected_episodes
),
312 ('open', self
.on_playback_selected_episodes
),
313 ('download', self
.on_download_selected_episodes
),
314 ('pause', self
.on_pause_selected_episodes
),
315 ('cancel', self
.on_item_cancel_download_activate
),
316 ('delete', self
.on_btnDownloadedDelete_clicked
),
317 ('toggleEpisodeNew', self
.on_item_toggle_played_activate
),
318 ('toggleEpisodeLock', self
.on_item_toggle_lock_activate
),
319 ('toggleShownotes', self
.on_shownotes_selected_episodes
),
320 ('sync', self
.on_sync_to_device_activate
),
321 ('findPodcast', self
.on_find_podcast_activate
),
322 ('findEpisode', self
.on_find_episode_activate
),
325 for name
, callback
in action_defs
:
326 action
= Gio
.SimpleAction
.new(name
, None)
327 action
.connect('activate', callback
)
330 self
.update_action
= g
.lookup_action('update')
331 self
.update_channel_action
= g
.lookup_action('updateChannel')
332 self
.edit_channel_action
= g
.lookup_action('editChannel')
333 self
.play_action
= g
.lookup_action('play')
334 self
.open_action
= g
.lookup_action('open')
335 self
.download_action
= g
.lookup_action('download')
336 self
.pause_action
= g
.lookup_action('pause')
337 self
.cancel_action
= g
.lookup_action('cancel')
338 self
.delete_action
= g
.lookup_action('delete')
339 self
.toggle_episode_new_action
= g
.lookup_action('toggleEpisodeNew')
340 self
.toggle_episode_lock_action
= g
.lookup_action('toggleEpisodeLock')
342 action
= Gio
.SimpleAction
.new_stateful(
343 'showToolbar', None, GLib
.Variant
.new_boolean(self
.config
.show_toolbar
))
344 action
.connect('activate', self
.on_itemShowToolbar_activate
)
347 def inject_extensions_menu(self
):
349 Update Extras/Extensions menu.
350 Called at startup and when en/dis-abling extenstions.
352 def gen_callback(label
, callback
):
353 return lambda action
, param
: callback()
355 for a
in self
.extensions_actions
:
356 self
.gPodder
.remove_action(a
.get_property('name'))
357 self
.extensions_actions
= []
359 if self
.extensions_menu
is None:
360 # insert menu section at startup (hides when empty)
361 self
.extensions_menu
= Gio
.Menu
.new()
362 menubar
= self
.application
.get_menubar()
363 for i
in range(0, menubar
.get_n_items()):
364 menu
= menubar
.do_get_item_link(menubar
, i
, Gio
.MENU_LINK_SUBMENU
)
365 menuname
= menubar
.get_item_attribute_value(i
, Gio
.MENU_ATTRIBUTE_LABEL
, None)
366 if menuname
is not None and menuname
.get_string() == _('E_xtras'):
367 menu
.append_section(_('Extensions'), self
.extensions_menu
)
369 self
.extensions_menu
.remove_all()
371 extension_entries
= gpodder
.user_extensions
.on_create_menu()
372 if extension_entries
:
374 for i
, (label
, callback
) in enumerate(extension_entries
):
375 action_id
= 'extensions.action_%d' % i
376 action
= Gio
.SimpleAction
.new(action_id
)
377 action
.connect('activate', gen_callback(label
, callback
))
378 self
.extensions_actions
.append(action
)
379 self
.gPodder
.add_action(action
)
380 itm
= Gio
.MenuItem
.new(label
, 'win.' + action_id
)
381 self
.extensions_menu
.append_item(itm
)
383 def find_partial_downloads(self
):
384 def start_progress_callback(count
):
386 self
.partial_downloads_indicator
= ProgressIndicator(
387 _('Loading incomplete downloads'),
388 _('Some episodes have not finished downloading in a previous session.'),
389 False, self
.get_dialog_parent())
390 self
.partial_downloads_indicator
.on_message(N_(
391 '%(count)d partial file', '%(count)d partial files',
392 count
) % {'count': count
})
394 util
.idle_add(self
.wNotebook
.set_current_page
, 1)
396 def progress_callback(title
, progress
):
397 self
.partial_downloads_indicator
.on_message(title
)
398 self
.partial_downloads_indicator
.on_progress(progress
)
400 def finish_progress_callback(resumable_episodes
):
401 def offer_resuming():
402 if resumable_episodes
:
403 self
.download_episode_list_paused(resumable_episodes
)
404 resume_all
= Gtk
.Button(_('Resume all'))
406 def on_resume_all(button
):
407 selection
= self
.treeDownloads
.get_selection()
408 selection
.select_all()
409 selected_tasks
, _
, _
, _
, _
, _
= self
.downloads_list_get_selection()
410 selection
.unselect_all()
411 self
._for
_each
_task
_set
_status
(selected_tasks
, download
.DownloadTask
.QUEUED
)
412 self
.message_area
.hide()
413 resume_all
.connect('clicked', on_resume_all
)
415 self
.message_area
= SimpleMessageArea(
416 _('Incomplete downloads from a previous session were found.'),
418 self
.vboxDownloadStatusWidgets
.attach(self
.message_area
, 0, -1, 1, 1)
419 self
.message_area
.show_all()
421 util
.idle_add(self
.wNotebook
.set_current_page
, 0)
422 logger
.debug("find_partial_downloads done, calling extensions")
423 gpodder
.user_extensions
.on_find_partial_downloads_done()
425 if self
.partial_downloads_indicator
:
426 util
.idle_add(self
.partial_downloads_indicator
.on_finished
)
427 self
.partial_downloads_indicator
= None
428 util
.idle_add(offer_resuming
)
430 common
.find_partial_downloads(self
.channels
,
431 start_progress_callback
,
433 finish_progress_callback
)
435 def episode_object_by_uri(self
, uri
):
436 """Get an episode object given a local or remote URI
438 This can be used to quickly access an episode object
439 when all we have is its download filename or episode
440 URL (e.g. from external D-Bus calls / signals, etc..)
442 if uri
.startswith('/'):
443 uri
= 'file://' + urllib
.parse
.quote(uri
)
445 prefix
= 'file://' + urllib
.parse
.quote(gpodder
.downloads
)
447 # By default, assume we can't pre-select any channel
448 # but can match episodes simply via the download URL
456 if uri
.startswith(prefix
):
457 # File is on the local filesystem in the download folder
458 # Try to reduce search space by pre-selecting the channel
459 # based on the folder name of the local file
461 filename
= urllib
.parse
.unquote(uri
[len(prefix
):])
462 file_parts
= [_f
for _f
in filename
.split(os
.sep
) if _f
]
464 if len(file_parts
) != 2:
467 foldername
, filename
= file_parts
470 return c
.download_folder
== foldername
473 return e
.download_filename
== filename
475 # Deep search through channels and episodes for a match
476 for channel
in filter(is_channel
, self
.channels
):
477 for episode
in filter(is_episode
, channel
.get_all_episodes()):
482 def on_played(self
, start
, end
, total
, file_uri
):
483 """Handle the "played" signal from a media player"""
484 if start
== 0 and end
== 0 and total
== 0:
485 # Ignore bogus play event
487 elif end
< start
+ 5:
488 # Ignore "less than five seconds" segments,
489 # as they can happen with seeking, etc...
492 logger
.debug('Received play action: %s (%d, %d, %d)', file_uri
, start
, end
, total
)
493 episode
= self
.episode_object_by_uri(file_uri
)
495 if episode
is not None:
496 file_type
= episode
.file_type()
500 episode
.total_time
= total
502 # Assume the episode's total time for the action
503 total
= episode
.total_time
505 assert (episode
.current_position_updated
is None or
506 now
>= episode
.current_position_updated
)
508 episode
.current_position
= end
509 episode
.current_position_updated
= now
510 episode
.mark(is_played
=True)
512 self
.episode_list_status_changed([episode
])
514 # Submit this action to the webservice
515 self
.mygpo_client
.on_playback_full(episode
, start
, end
, total
)
517 def on_add_remove_podcasts_mygpo(self
):
518 actions
= self
.mygpo_client
.get_received_actions()
522 existing_urls
= [c
.url
for c
in self
.channels
]
524 # Columns for the episode selector window - just one...
526 ('description', None, None, _('Action')),
529 # A list of actions that have to be chosen from
532 # Actions that are ignored (already carried out)
535 for action
in actions
:
536 if action
.is_add
and action
.url
not in existing_urls
:
537 changes
.append(my
.Change(action
))
538 elif action
.is_remove
and action
.url
in existing_urls
:
539 podcast_object
= None
540 for podcast
in self
.channels
:
541 if podcast
.url
== action
.url
:
542 podcast_object
= podcast
544 changes
.append(my
.Change(action
, podcast_object
))
546 ignored
.append(action
)
548 # Confirm all ignored changes
549 self
.mygpo_client
.confirm_received_actions(ignored
)
551 def execute_podcast_actions(selected
):
552 # In the future, we might retrieve the title from gpodder.net here,
553 # but for now, we just use "None" to use the feed-provided title
555 add_list
= [(title
, c
.action
.url
)
556 for c
in selected
if c
.action
.is_add
]
557 remove_list
= [c
.podcast
for c
in selected
if c
.action
.is_remove
]
559 # Apply the accepted changes locally
560 self
.add_podcast_list(add_list
)
561 self
.remove_podcast_list(remove_list
, confirm
=False)
563 # All selected items are now confirmed
564 self
.mygpo_client
.confirm_received_actions(c
.action
for c
in selected
)
566 # Revert the changes on the server
567 rejected
= [c
.action
for c
in changes
if c
not in selected
]
568 self
.mygpo_client
.reject_received_actions(rejected
)
571 # We're abusing the Episode Selector again ;) -- thp
572 gPodderEpisodeSelector(self
.main_window
,
573 title
=_('Confirm changes from gpodder.net'),
574 instructions
=_('Select the actions you want to carry out.'),
578 ok_button
=_('A_pply'),
579 callback
=execute_podcast_actions
,
582 # There are some actions that need the user's attention
587 # We have no remaining actions - no selection happens
590 def rewrite_urls_mygpo(self
):
591 # Check if we have to rewrite URLs since the last add
592 rewritten_urls
= self
.mygpo_client
.get_rewritten_urls()
595 for rewritten_url
in rewritten_urls
:
596 if not rewritten_url
.new_url
:
599 for channel
in self
.channels
:
600 if channel
.url
== rewritten_url
.old_url
:
601 logger
.info('Updating URL of %s to %s', channel
,
602 rewritten_url
.new_url
)
603 channel
.url
= rewritten_url
.new_url
609 util
.idle_add(self
.update_episode_list_model
)
611 def on_send_full_subscriptions(self
):
612 # Send the full subscription list to the gpodder.net client
613 # (this will overwrite the subscription list on the server)
614 indicator
= ProgressIndicator(_('Uploading subscriptions'),
615 _('Your subscriptions are being uploaded to the server.'),
616 False, self
.get_dialog_parent())
619 self
.mygpo_client
.set_subscriptions([c
.url
for c
in self
.channels
])
620 util
.idle_add(self
.show_message
, _('List uploaded successfully.'))
621 except Exception as e
:
625 message
= e
.__class
__.__name
__
626 if message
== 'NotFound':
628 'Could not find your device.\n'
630 'Check login is a username (not an email)\n'
631 'and that the device name matches one in your account.'
633 self
.show_message(html
.escape(message
),
634 _('Error while uploading'),
636 util
.idle_add(show_error
, e
)
638 util
.idle_add(indicator
.on_finished
)
640 def on_button_subscribe_clicked(self
, button
):
641 self
.on_itemImportChannels_activate(button
)
643 def on_button_downloads_clicked(self
, widget
):
644 self
.downloads_window
.show()
646 def on_treeview_button_pressed(self
, treeview
, event
):
647 if event
.window
!= treeview
.get_bin_window():
650 role
= getattr(treeview
, TreeViewHelper
.ROLE
)
651 if role
== TreeViewHelper
.ROLE_EPISODES
and event
.button
== 1:
652 # Toggle episode "new" status by clicking the icon (bug 1432)
653 result
= treeview
.get_path_at_pos(int(event
.x
), int(event
.y
))
654 if result
is not None:
655 path
, column
, x
, y
= result
656 # The user clicked the icon if she clicked in the first column
657 # and the x position is in the area where the icon resides
658 if (x
< self
.EPISODE_LIST_ICON_WIDTH
and
659 column
== treeview
.get_columns()[0]):
660 model
= treeview
.get_model()
661 cursor_episode
= model
.get_value(model
.get_iter(path
),
662 EpisodeListModel
.C_EPISODE
)
664 new_value
= cursor_episode
.is_new
665 selected_episodes
= self
.get_selected_episodes()
667 # Avoid changing anything if the clicked episode is not
668 # selected already - otherwise update all selected
669 if cursor_episode
in selected_episodes
:
670 for episode
in selected_episodes
:
671 episode
.mark(is_played
=new_value
)
673 self
.update_episode_list_icons(selected
=True)
674 self
.update_podcast_list_model(selected
=True)
677 return event
.button
== 3
679 def on_treeview_podcasts_button_released(self
, treeview
, event
):
680 if event
.window
!= treeview
.get_bin_window():
683 return self
.treeview_channels_show_context_menu(treeview
, event
)
685 def on_treeview_episodes_button_released(self
, treeview
, event
):
686 if event
.window
!= treeview
.get_bin_window():
689 return self
.treeview_available_show_context_menu(treeview
, event
)
691 def on_treeview_downloads_button_released(self
, treeview
, event
):
692 if event
.window
!= treeview
.get_bin_window():
695 return self
.treeview_downloads_show_context_menu(treeview
, event
)
697 def on_find_podcast_activate(self
, *args
):
698 if self
._search
_podcasts
:
699 self
._search
_podcasts
.show_search()
701 def init_podcast_list_treeview(self
):
702 size
= cake_size_from_widget(self
.treeChannels
) * 2
703 scale
= self
.treeChannels
.get_scale_factor()
704 self
.podcast_list_model
.set_max_image_size(size
, scale
)
705 # Set up podcast channel tree view widget
706 column
= Gtk
.TreeViewColumn('')
707 iconcell
= Gtk
.CellRendererPixbuf()
708 iconcell
.set_property('width', size
+ 10)
709 column
.pack_start(iconcell
, False)
710 column
.add_attribute(iconcell
, 'pixbuf', PodcastListModel
.C_COVER
)
711 column
.add_attribute(iconcell
, 'visible', PodcastListModel
.C_COVER_VISIBLE
)
713 column
.set_cell_data_func(iconcell
, draw_iconcell_scale
, scale
)
715 namecell
= Gtk
.CellRendererText()
716 namecell
.set_property('ellipsize', Pango
.EllipsizeMode
.END
)
717 column
.pack_start(namecell
, True)
718 column
.add_attribute(namecell
, 'markup', PodcastListModel
.C_DESCRIPTION
)
720 iconcell
= Gtk
.CellRendererPixbuf()
721 iconcell
.set_property('xalign', 1.0)
722 column
.pack_start(iconcell
, False)
723 column
.add_attribute(iconcell
, 'pixbuf', PodcastListModel
.C_PILL
)
724 column
.add_attribute(iconcell
, 'visible', PodcastListModel
.C_PILL_VISIBLE
)
726 column
.set_cell_data_func(iconcell
, draw_iconcell_scale
, scale
)
728 self
.treeChannels
.append_column(column
)
730 self
.treeChannels
.set_model(self
.podcast_list_model
.get_filtered_model())
731 self
.podcast_list_model
.widget
= self
.treeChannels
733 # When no podcast is selected, clear the episode list model
734 selection
= self
.treeChannels
.get_selection()
736 # Set up type-ahead find for the podcast list
737 def on_key_press(treeview
, event
):
738 if event
.keyval
== Gdk
.KEY_Right
:
739 self
.treeAvailable
.grab_focus()
740 elif event
.keyval
in (Gdk
.KEY_Up
, Gdk
.KEY_Down
):
741 # If section markers exist in the treeview, we want to
742 # "jump over" them when moving the cursor up and down
743 if event
.keyval
== Gdk
.KEY_Up
:
748 selection
= self
.treeChannels
.get_selection()
749 model
, it
= selection
.get_selected()
751 it
= model
.get_iter_first()
756 path
= model
.get_path(it
)
757 path
= (path
[0] + step
,)
760 # Valid paths must have a value >= 0
764 it
= model
.get_iter(path
)
766 # Already at the end of the list
769 self
.treeChannels
.set_cursor(path
)
770 elif event
.keyval
== Gdk
.KEY_Escape
:
771 self
._search
_podcasts
.hide_search()
772 elif event
.get_state() & Gdk
.ModifierType
.CONTROL_MASK
:
773 # Don't handle type-ahead when control is pressed (so shortcuts
774 # with the Ctrl key still work, e.g. Ctrl+A, ...)
776 elif event
.keyval
== Gdk
.KEY_Delete
:
779 unicode_char_id
= Gdk
.keyval_to_unicode(event
.keyval
)
780 # < 32 to intercept Delete and Tab events
781 if unicode_char_id
< 32:
783 input_char
= chr(unicode_char_id
)
784 self
._search
_podcasts
.show_search(input_char
)
786 self
.treeChannels
.connect('key-press-event', on_key_press
)
788 self
.treeChannels
.connect('popup-menu', self
.treeview_channels_show_context_menu
)
790 # Enable separators to the podcast list to separate special podcasts
791 # from others (this is used for the "all episodes" view)
792 self
.treeChannels
.set_row_separator_func(PodcastListModel
.row_separator_func
)
794 TreeViewHelper
.set(self
.treeChannels
, TreeViewHelper
.ROLE_PODCASTS
)
796 self
._search
_podcasts
= SearchTree(self
.hbox_search_podcasts
,
797 self
.entry_search_podcasts
,
799 self
.podcast_list_model
,
801 if self
.config
.ui
.gtk
.search_always_visible
:
802 self
._search
_podcasts
.show_search(grab_focus
=False)
804 def on_find_episode_activate(self
, *args
):
805 if self
._search
_episodes
:
806 self
._search
_episodes
.show_search()
808 def set_episode_list_column(self
, index
, new_value
):
811 self
.config
.episode_list_columns |
= mask
813 self
.config
.episode_list_columns
&= ~mask
815 def update_episode_list_columns_visibility(self
):
816 columns
= TreeViewHelper
.get_columns(self
.treeAvailable
)
817 for index
, column
in enumerate(columns
):
818 visible
= bool(self
.config
.episode_list_columns
& (1 << index
))
819 column
.set_visible(visible
)
820 self
.view_column_actions
[index
].set_state(GLib
.Variant
.new_boolean(visible
))
821 self
.treeAvailable
.columns_autosize()
823 def on_episode_list_header_reordered(self
, treeview
):
824 self
.config
.ui
.gtk
.state
.main_window
.episode_column_order
= \
825 [column
.get_sort_column_id() for column
in treeview
.get_columns()]
827 def on_episode_list_header_sorted(self
, column
):
828 self
.config
.ui
.gtk
.state
.main_window
.episode_column_sort_id
= column
.get_sort_column_id()
829 self
.config
.ui
.gtk
.state
.main_window
.episode_column_sort_order
= \
830 (column
.get_sort_order() is Gtk
.SortType
.ASCENDING
)
832 def on_episode_list_header_clicked(self
, button
, event
):
833 if event
.button
== 1:
834 # Require control click to sort episodes, when enabled
835 if self
.config
.ui
.gtk
.episode_list
.ctrl_click_to_sort
and (event
.state
& Gdk
.ModifierType
.CONTROL_MASK
) == 0:
837 elif event
.button
== 3:
838 if self
.episode_columns_menu
is not None:
839 self
.episode_columns_menu
.popup(None, None, None, None, event
.button
, event
.time
)
843 def init_episode_list_treeview(self
):
844 self
.episode_list_model
.set_view_mode(self
.config
.episode_list_view_mode
)
846 # Initialize progress icons
847 cake_size
= cake_size_from_widget(self
.treeAvailable
)
848 for i
in range(EpisodeListModel
.PROGRESS_STEPS
+ 1):
849 pixbuf
= draw_cake_pixbuf(i
/
850 EpisodeListModel
.PROGRESS_STEPS
, size
=cake_size
)
851 icon_name
= 'gpodder-progress-%d' % i
852 Gtk
.IconTheme
.add_builtin_icon(icon_name
, cake_size
, pixbuf
)
854 self
.treeAvailable
.set_model(self
.episode_list_model
.get_filtered_model())
856 TreeViewHelper
.set(self
.treeAvailable
, TreeViewHelper
.ROLE_EPISODES
)
858 iconcell
= Gtk
.CellRendererPixbuf()
859 episode_list_icon_size
= Gtk
.icon_size_register('episode-list',
860 cake_size
, cake_size
)
861 iconcell
.set_property('stock-size', episode_list_icon_size
)
862 iconcell
.set_fixed_size(cake_size
+ 20, -1)
863 self
.EPISODE_LIST_ICON_WIDTH
= cake_size
865 namecell
= Gtk
.CellRendererText()
866 namecell
.set_property('ellipsize', Pango
.EllipsizeMode
.END
)
867 namecolumn
= Gtk
.TreeViewColumn(_('Episode'))
868 namecolumn
.pack_start(iconcell
, False)
869 namecolumn
.add_attribute(iconcell
, 'icon-name', EpisodeListModel
.C_STATUS_ICON
)
870 namecolumn
.pack_start(namecell
, True)
871 namecolumn
.add_attribute(namecell
, 'markup', EpisodeListModel
.C_DESCRIPTION
)
872 namecolumn
.set_sort_column_id(EpisodeListModel
.C_DESCRIPTION
)
873 namecolumn
.set_sizing(Gtk
.TreeViewColumnSizing
.AUTOSIZE
)
874 namecolumn
.set_resizable(True)
875 namecolumn
.set_expand(True)
877 lockcell
= Gtk
.CellRendererPixbuf()
878 lockcell
.set_fixed_size(40, -1)
879 lockcell
.set_property('stock-size', Gtk
.IconSize
.MENU
)
880 lockcell
.set_property('icon-name', 'emblem-readonly')
881 namecolumn
.pack_start(lockcell
, False)
882 namecolumn
.add_attribute(lockcell
, 'visible', EpisodeListModel
.C_LOCKED
)
884 sizecell
= Gtk
.CellRendererText()
885 sizecell
.set_property('xalign', 1)
886 sizecolumn
= Gtk
.TreeViewColumn(_('Size'), sizecell
, text
=EpisodeListModel
.C_FILESIZE_TEXT
)
887 sizecolumn
.set_sort_column_id(EpisodeListModel
.C_FILESIZE
)
889 timecell
= Gtk
.CellRendererText()
890 timecell
.set_property('xalign', 1)
891 timecolumn
= Gtk
.TreeViewColumn(_('Duration'), timecell
, text
=EpisodeListModel
.C_TIME
)
892 timecolumn
.set_sort_column_id(EpisodeListModel
.C_TOTAL_TIME
)
894 releasecell
= Gtk
.CellRendererText()
895 releasecolumn
= Gtk
.TreeViewColumn(_('Released'), releasecell
, text
=EpisodeListModel
.C_PUBLISHED_TEXT
)
896 releasecolumn
.set_sort_column_id(EpisodeListModel
.C_PUBLISHED
)
898 sizetimecell
= Gtk
.CellRendererText()
899 sizetimecell
.set_property('xalign', 1)
900 sizetimecell
.set_property('alignment', Pango
.Alignment
.RIGHT
)
901 sizetimecolumn
= Gtk
.TreeViewColumn(_('Size+'))
902 sizetimecolumn
.pack_start(sizetimecell
, True)
903 sizetimecolumn
.add_attribute(sizetimecell
, 'markup', EpisodeListModel
.C_FILESIZE_AND_TIME_TEXT
)
904 sizetimecolumn
.set_sort_column_id(EpisodeListModel
.C_FILESIZE_AND_TIME
)
906 timesizecell
= Gtk
.CellRendererText()
907 timesizecell
.set_property('xalign', 1)
908 timesizecell
.set_property('alignment', Pango
.Alignment
.RIGHT
)
909 timesizecolumn
= Gtk
.TreeViewColumn(_('Duration+'))
910 timesizecolumn
.pack_start(timesizecell
, True)
911 timesizecolumn
.add_attribute(timesizecell
, 'markup', EpisodeListModel
.C_TIME_AND_SIZE
)
912 timesizecolumn
.set_sort_column_id(EpisodeListModel
.C_TOTAL_TIME_AND_SIZE
)
914 namecolumn
.set_reorderable(True)
915 self
.treeAvailable
.append_column(namecolumn
)
917 # EpisodeListModel.C_PUBLISHED is not available in config.py, set it here on first run
918 if not self
.config
.ui
.gtk
.state
.main_window
.episode_column_sort_id
:
919 self
.config
.ui
.gtk
.state
.main_window
.episode_column_sort_id
= EpisodeListModel
.C_PUBLISHED
921 for itemcolumn
in (sizecolumn
, timecolumn
, releasecolumn
, sizetimecolumn
, timesizecolumn
):
922 itemcolumn
.set_reorderable(True)
923 self
.treeAvailable
.append_column(itemcolumn
)
924 TreeViewHelper
.register_column(self
.treeAvailable
, itemcolumn
)
926 # Add context menu to all tree view column headers
927 for column
in self
.treeAvailable
.get_columns():
928 label
= Gtk
.Label(label
=column
.get_title())
930 column
.set_widget(label
)
932 w
= column
.get_widget()
933 while w
is not None and not isinstance(w
, Gtk
.Button
):
936 w
.connect('button-release-event', self
.on_episode_list_header_clicked
)
938 # Restore column sorting
939 if column
.get_sort_column_id() == self
.config
.ui
.gtk
.state
.main_window
.episode_column_sort_id
:
940 self
.episode_list_model
._sorter
.set_sort_column_id(Gtk
.TREE_SORTABLE_UNSORTED_SORT_COLUMN_ID
,
941 Gtk
.SortType
.DESCENDING
)
942 self
.episode_list_model
._sorter
.set_sort_column_id(column
.get_sort_column_id(),
943 Gtk
.SortType
.ASCENDING
if self
.config
.ui
.gtk
.state
.main_window
.episode_column_sort_order
944 else Gtk
.SortType
.DESCENDING
)
945 # Save column sorting when user clicks column headers
946 column
.connect('clicked', self
.on_episode_list_header_sorted
)
948 def restore_column_ordering():
950 for col
in self
.config
.ui
.gtk
.state
.main_window
.episode_column_order
:
951 for column
in self
.treeAvailable
.get_columns():
952 if col
is column
.get_sort_column_id():
955 # Column ID not found, abort
956 # Manually re-ordering columns should fix the corrupt setting
958 self
.treeAvailable
.move_column_after(column
, prev_column
)
960 # Save column ordering when user drags column headers
961 self
.treeAvailable
.connect('columns-changed', self
.on_episode_list_header_reordered
)
962 # Delay column ordering until shown to prevent "Negative content height" warnings for themes with vertical padding or borders
963 util
.idle_add(restore_column_ordering
)
965 # For each column that can be shown/hidden, add a menu item
966 self
.view_column_actions
= []
967 columns
= TreeViewHelper
.get_columns(self
.treeAvailable
)
969 def on_visible_toggled(action
, param
, index
):
970 state
= action
.get_state()
971 self
.set_episode_list_column(index
, not state
)
972 action
.set_state(GLib
.Variant
.new_boolean(not state
))
974 for index
, column
in enumerate(columns
):
975 name
= 'showColumn%i' % index
976 action
= Gio
.SimpleAction
.new_stateful(
977 name
, None, GLib
.Variant
.new_boolean(False))
978 action
.connect('activate', on_visible_toggled
, index
)
979 self
.main_window
.add_action(action
)
980 self
.view_column_actions
.append(action
)
981 self
.application
.menu_view_columns
.insert(index
, column
.get_title(), 'win.' + name
)
983 self
.episode_columns_menu
= Gtk
.Menu
.new_from_model(self
.application
.menu_view_columns
)
984 self
.episode_columns_menu
.attach_to_widget(self
.main_window
)
985 # Update the visibility of the columns and the check menu items
986 self
.update_episode_list_columns_visibility()
988 # Set up type-ahead find for the episode list
989 def on_key_press(treeview
, event
):
990 if event
.keyval
== Gdk
.KEY_Left
:
991 self
.treeChannels
.grab_focus()
992 elif event
.keyval
== Gdk
.KEY_Escape
:
993 if self
.hbox_search_episodes
.get_property('visible'):
994 self
._search
_episodes
.hide_search()
996 self
.shownotes_object
.hide_pane()
997 elif event
.get_state() & Gdk
.ModifierType
.CONTROL_MASK
:
998 # Don't handle type-ahead when control is pressed (so shortcuts
999 # with the Ctrl key still work, e.g. Ctrl+A, ...)
1002 unicode_char_id
= Gdk
.keyval_to_unicode(event
.keyval
)
1003 # < 32 to intercept Delete and Tab events
1004 if unicode_char_id
< 32:
1006 input_char
= chr(unicode_char_id
)
1007 self
._search
_episodes
.show_search(input_char
)
1009 self
.treeAvailable
.connect('key-press-event', on_key_press
)
1011 self
.treeAvailable
.connect('popup-menu', self
.treeview_available_show_context_menu
)
1013 self
.treeAvailable
.enable_model_drag_source(Gdk
.ModifierType
.BUTTON1_MASK
,
1014 (('text/uri-list', 0, 0),), Gdk
.DragAction
.COPY
)
1016 def drag_data_get(tree
, context
, selection_data
, info
, timestamp
):
1017 uris
= ['file://' + e
.local_filename(create
=False)
1018 for e
in self
.get_selected_episodes()
1019 if e
.was_downloaded(and_exists
=True)]
1020 selection_data
.set_uris(uris
)
1021 self
.treeAvailable
.connect('drag-data-get', drag_data_get
)
1023 selection
= self
.treeAvailable
.get_selection()
1024 selection
.set_mode(Gtk
.SelectionMode
.MULTIPLE
)
1025 self
.selection_handler_id
= selection
.connect('changed', self
.on_episode_list_selection_changed
)
1027 self
._search
_episodes
= SearchTree(self
.hbox_search_episodes
,
1028 self
.entry_search_episodes
,
1030 self
.episode_list_model
,
1032 if self
.config
.ui
.gtk
.search_always_visible
:
1033 self
._search
_episodes
.show_search(grab_focus
=False)
1035 def on_episode_list_selection_changed(self
, selection
):
1036 # Update the toolbar buttons
1037 self
.play_or_download()
1039 self
.shownotes_object
.set_episodes(self
.get_selected_episodes())
1041 def init_download_list_treeview(self
):
1042 # enable multiple selection support
1043 self
.treeDownloads
.get_selection().set_mode(Gtk
.SelectionMode
.MULTIPLE
)
1044 self
.treeDownloads
.set_search_equal_func(TreeViewHelper
.make_search_equal_func(DownloadStatusModel
))
1046 # columns and renderers for "download progress" tab
1047 # First column: [ICON] Episodename
1048 column
= Gtk
.TreeViewColumn(_('Episode'))
1050 cell
= Gtk
.CellRendererPixbuf()
1051 cell
.set_property('stock-size', Gtk
.IconSize
.BUTTON
)
1052 column
.pack_start(cell
, False)
1053 column
.add_attribute(cell
, 'icon-name',
1054 DownloadStatusModel
.C_ICON_NAME
)
1056 cell
= Gtk
.CellRendererText()
1057 cell
.set_property('ellipsize', Pango
.EllipsizeMode
.END
)
1058 column
.pack_start(cell
, True)
1059 column
.add_attribute(cell
, 'markup', DownloadStatusModel
.C_NAME
)
1060 column
.set_sizing(Gtk
.TreeViewColumnSizing
.AUTOSIZE
)
1061 column
.set_expand(True)
1062 self
.treeDownloads
.append_column(column
)
1064 # Second column: Progress
1065 cell
= Gtk
.CellRendererProgress()
1066 cell
.set_property('yalign', .5)
1067 cell
.set_property('ypad', 6)
1068 column
= Gtk
.TreeViewColumn(_('Progress'), cell
,
1069 value
=DownloadStatusModel
.C_PROGRESS
,
1070 text
=DownloadStatusModel
.C_PROGRESS_TEXT
)
1071 column
.set_sizing(Gtk
.TreeViewColumnSizing
.AUTOSIZE
)
1072 column
.set_expand(False)
1073 self
.treeDownloads
.append_column(column
)
1074 column
.set_property('min-width', 150)
1075 column
.set_property('max-width', 150)
1077 self
.treeDownloads
.set_model(self
.download_status_model
)
1078 TreeViewHelper
.set(self
.treeDownloads
, TreeViewHelper
.ROLE_DOWNLOADS
)
1080 self
.treeDownloads
.connect('popup-menu', self
.treeview_downloads_show_context_menu
)
1082 def on_treeview_expose_event(self
, treeview
, ctx
):
1083 model
= treeview
.get_model()
1084 if (model
is not None and model
.get_iter_first() is not None):
1087 role
= getattr(treeview
, TreeViewHelper
.ROLE
, None)
1091 width
= treeview
.get_allocated_width()
1092 height
= treeview
.get_allocated_height()
1094 if role
== TreeViewHelper
.ROLE_EPISODES
:
1095 if self
.config
.episode_list_view_mode
!= EpisodeListModel
.VIEW_ALL
:
1096 text
= _('No episodes in current view')
1098 text
= _('No episodes available')
1099 elif role
== TreeViewHelper
.ROLE_PODCASTS
:
1100 if self
.config
.episode_list_view_mode
!= \
1101 EpisodeListModel
.VIEW_ALL
and \
1102 self
.config
.podcast_list_hide_boring
and \
1103 len(self
.channels
) > 0:
1104 text
= _('No podcasts in this view')
1106 text
= _('No subscriptions')
1107 elif role
== TreeViewHelper
.ROLE_DOWNLOADS
:
1108 text
= _('No active tasks')
1110 raise Exception('on_treeview_expose_event: unknown role')
1112 draw_text_box_centered(ctx
, treeview
, width
, height
, text
, None, None)
1115 def set_download_list_state(self
, state
):
1116 if state
== gPodderSyncUI
.DL_ADDING_TASKS
:
1117 self
.things_adding_tasks
+= 1
1118 elif state
== gPodderSyncUI
.DL_ADDED_TASKS
:
1119 self
.things_adding_tasks
-= 1
1120 if not self
.download_list_update_enabled
:
1121 self
.update_downloads_list()
1122 GObject
.timeout_add(1500, self
.update_downloads_list
)
1123 self
.download_list_update_enabled
= True
1125 def cleanup_downloads(self
):
1126 model
= self
.download_status_model
1128 all_tasks
= [(Gtk
.TreeRowReference
.new(model
, row
.path
), row
[0]) for row
in model
]
1129 changed_episode_urls
= set()
1130 for row_reference
, task
in all_tasks
:
1131 if task
.status
in (task
.DONE
, task
.CANCELLED
):
1132 model
.remove(model
.get_iter(row_reference
.get_path()))
1134 # We don't "see" this task anymore - remove it;
1135 # this is needed, so update_episode_list_icons()
1136 # below gets the correct list of "seen" tasks
1137 self
.download_tasks_seen
.remove(task
)
1138 except KeyError as key_error
:
1140 changed_episode_urls
.add(task
.url
)
1141 # Tell the task that it has been removed (so it can clean up)
1142 task
.removed_from_list()
1144 # Tell the podcasts tab to update icons for our removed podcasts
1145 self
.update_episode_list_icons(changed_episode_urls
)
1147 # Update the downloads list one more time
1148 self
.update_downloads_list(can_call_cleanup
=False)
1150 def on_tool_downloads_toggled(self
, toolbutton
):
1151 if toolbutton
.get_active():
1152 self
.wNotebook
.set_current_page(1)
1154 self
.wNotebook
.set_current_page(0)
1156 def add_download_task_monitor(self
, monitor
):
1157 self
.download_task_monitors
.add(monitor
)
1158 model
= self
.download_status_model
1161 for row
in model
.get_model():
1162 task
= row
[self
.download_status_model
.C_TASK
]
1163 monitor
.task_updated(task
)
1165 def remove_download_task_monitor(self
, monitor
):
1166 self
.download_task_monitors
.remove(monitor
)
1168 def set_download_progress(self
, progress
):
1169 gpodder
.user_extensions
.on_download_progress(progress
)
1171 def update_downloads_list(self
, can_call_cleanup
=True):
1173 model
= self
.download_status_model
1175 downloading
, synchronizing
, pausing
, cancelling
, queued
, paused
, failed
, finished
, others
= (0,) * 9
1176 total_speed
, total_size
, done_size
= 0, 0, 0
1177 files_downloading
= 0
1179 # Keep a list of all download tasks that we've seen
1180 download_tasks_seen
= set()
1182 # Do not go through the list of the model is not (yet) available
1187 self
.download_status_model
.request_update(row
.iter)
1189 task
= row
[self
.download_status_model
.C_TASK
]
1190 speed
, size
, status
, progress
, activity
= task
.speed
, task
.total_size
, task
.status
, task
.progress
, task
.activity
1192 # Let the download task monitors know of changes
1193 for monitor
in self
.download_task_monitors
:
1194 monitor
.task_updated(task
)
1197 done_size
+= size
* progress
1199 download_tasks_seen
.add(task
)
1201 if status
== download
.DownloadTask
.DOWNLOADING
:
1202 if activity
== download
.DownloadTask
.ACTIVITY_DOWNLOAD
:
1204 files_downloading
+= 1
1205 total_speed
+= speed
1206 elif activity
== download
.DownloadTask
.ACTIVITY_SYNCHRONIZE
:
1210 elif status
== download
.DownloadTask
.PAUSING
:
1212 if activity
== download
.DownloadTask
.ACTIVITY_DOWNLOAD
:
1213 files_downloading
+= 1
1214 elif status
== download
.DownloadTask
.CANCELLING
:
1216 if activity
== download
.DownloadTask
.ACTIVITY_DOWNLOAD
:
1217 files_downloading
+= 1
1218 elif status
== download
.DownloadTask
.QUEUED
:
1220 elif status
== download
.DownloadTask
.PAUSED
:
1222 elif status
== download
.DownloadTask
.FAILED
:
1224 elif status
== download
.DownloadTask
.DONE
:
1229 # TODO: 'others' is not used
1231 # Remember which tasks we have seen after this run
1232 self
.download_tasks_seen
= download_tasks_seen
1234 text
= [_('Progress')]
1235 if downloading
+ synchronizing
+ pausing
+ cancelling
+ queued
+ paused
+ failed
> 0:
1238 s
.append(N_('%(count)d active', '%(count)d active', downloading
) % {'count': downloading
})
1239 if synchronizing
> 0:
1240 s
.append(N_('%(count)d active', '%(count)d active', synchronizing
) % {'count': synchronizing
})
1242 s
.append(N_('%(count)d pausing', '%(count)d pausing', pausing
) % {'count': pausing
})
1244 s
.append(N_('%(count)d cancelling', '%(count)d cancelling', cancelling
) % {'count': cancelling
})
1246 s
.append(N_('%(count)d queued', '%(count)d queued', queued
) % {'count': queued
})
1248 s
.append(N_('%(count)d paused', '%(count)d paused', paused
) % {'count': paused
})
1250 s
.append(N_('%(count)d failed', '%(count)d failed', failed
) % {'count': failed
})
1251 text
.append(' (' + ', '.join(s
) + ')')
1252 self
.labelDownloads
.set_text(''.join(text
))
1254 title
= [self
.default_title
]
1256 # Accessing task.status_changed has the side effect of re-setting
1257 # the changed flag, but we only do it once here so that's okay
1258 channel_urls
= [task
.podcast_url
for task
in
1259 self
.download_tasks_seen
if task
.status_changed
]
1260 episode_urls
= [task
.url
for task
in self
.download_tasks_seen
]
1262 if files_downloading
> 0:
1263 title
.append(N_('downloading %(count)d file',
1264 'downloading %(count)d files',
1265 files_downloading
) % {'count': files_downloading
})
1268 percentage
= 100.0 * done_size
/ total_size
1271 self
.set_download_progress(percentage
/ 100)
1272 total_speed
= util
.format_filesize(total_speed
)
1273 title
[1] += ' (%d%%, %s/s)' % (percentage
, total_speed
)
1274 if synchronizing
> 0:
1275 title
.append(N_('synchronizing %(count)d file',
1276 'synchronizing %(count)d files',
1277 synchronizing
) % {'count': synchronizing
})
1279 title
.append(N_('%(queued)d task queued',
1280 '%(queued)d tasks queued',
1281 queued
) % {'queued': queued
})
1282 if (downloading
+ synchronizing
+ pausing
+ cancelling
+ queued
) == 0 and self
.things_adding_tasks
== 0:
1283 self
.set_download_progress(1.)
1284 self
.downloads_finished(self
.download_tasks_seen
)
1285 gpodder
.user_extensions
.on_all_episodes_downloaded()
1286 logger
.info('All tasks have finished.')
1288 # Remove finished episodes
1289 if self
.config
.ui
.gtk
.download_list
.remove_finished
and can_call_cleanup
:
1290 self
.cleanup_downloads()
1292 # Stop updating the download list here
1293 self
.download_list_update_enabled
= False
1295 self
.gPodder
.set_title(' - '.join(title
))
1297 self
.update_episode_list_icons(episode_urls
)
1298 self
.play_or_download()
1300 self
.update_podcast_list_model(channel_urls
)
1302 return self
.download_list_update_enabled
1303 except Exception as e
:
1304 logger
.error('Exception happened while updating download list.', exc_info
=True)
1306 '%s\n\n%s' % (_('Please report this problem and restart gPodder:'), html
.escape(str(e
))),
1307 _('Unhandled exception'), important
=True)
1308 # We return False here, so the update loop won't be called again,
1309 # that's why we require the restart of gPodder in the message.
1312 def on_config_changed(self
, *args
):
1313 util
.idle_add(self
._on
_config
_changed
, *args
)
1315 def _on_config_changed(self
, name
, old_value
, new_value
):
1316 if name
== 'ui.gtk.toolbar':
1317 self
.toolbar
.set_property('visible', new_value
)
1318 elif name
in ('ui.gtk.episode_list.descriptions',
1319 'ui.gtk.episode_list.always_show_new'):
1320 self
.update_episode_list_model()
1321 elif name
in ('auto.update.enabled', 'auto.update.frequency'):
1322 self
.restart_auto_update_timer()
1323 elif name
in ('ui.gtk.podcast_list.all_episodes',
1324 'ui.gtk.podcast_list.sections'):
1325 # Force a update of the podcast list model
1326 self
.update_podcast_list_model()
1327 elif name
== 'ui.gtk.episode_list.columns':
1328 self
.update_episode_list_columns_visibility()
1330 def on_treeview_query_tooltip(self
, treeview
, x
, y
, keyboard_tooltip
, tooltip
):
1331 # With get_bin_window, we get the window that contains the rows without
1332 # the header. The Y coordinate of this window will be the height of the
1333 # treeview header. This is the amount we have to subtract from the
1334 # event's Y coordinate to get the coordinate to pass to get_path_at_pos
1335 (x_bin
, y_bin
) = treeview
.get_bin_window().get_position()
1338 (path
, column
, rx
, ry
) = treeview
.get_path_at_pos(x
, y
) or (None,) * 4
1340 if not getattr(treeview
, TreeViewHelper
.CAN_TOOLTIP
) or x
> 50 or (column
is not None and column
!= treeview
.get_columns()[0]):
1341 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, None)
1344 if path
is not None:
1345 model
= treeview
.get_model()
1346 iter = model
.get_iter(path
)
1347 role
= getattr(treeview
, TreeViewHelper
.ROLE
)
1349 if role
== TreeViewHelper
.ROLE_EPISODES
:
1350 id = model
.get_value(iter, EpisodeListModel
.C_URL
)
1351 elif role
== TreeViewHelper
.ROLE_PODCASTS
:
1352 id = model
.get_value(iter, PodcastListModel
.C_URL
)
1354 # Section header - no tooltip here (for now at least)
1357 last_tooltip
= getattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
)
1358 if last_tooltip
is not None and last_tooltip
!= id:
1359 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, None)
1361 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, id)
1363 if role
== TreeViewHelper
.ROLE_EPISODES
:
1364 description
= model
.get_value(iter, EpisodeListModel
.C_TOOLTIP
)
1366 tooltip
.set_text(description
)
1369 elif role
== TreeViewHelper
.ROLE_PODCASTS
:
1370 channel
= model
.get_value(iter, PodcastListModel
.C_CHANNEL
)
1371 if channel
is None or not hasattr(channel
, 'title'):
1373 error_str
= model
.get_value(iter, PodcastListModel
.C_ERROR
)
1375 error_str
= _('Feedparser error: %s') % html
.escape(error_str
.strip())
1376 error_str
= '<span foreground="#ff0000">%s</span>' % error_str
1378 box
= Gtk
.Box(orientation
=Gtk
.Orientation
.VERTICAL
, spacing
=5)
1379 box
.set_border_width(5)
1381 heading
= Gtk
.Label()
1382 heading
.set_max_width_chars(60)
1383 heading
.set_alignment(0, 1)
1384 heading
.set_markup('<b><big>%s</big></b>\n<small>%s</small>' % (html
.escape(channel
.title
), html
.escape(channel
.url
)))
1387 box
.add(Gtk
.HSeparator())
1389 channel_description
= util
.remove_html_tags(channel
.description
)
1390 if channel
._update
_error
is not None:
1391 description
= _('ERROR: %s') % channel
._update
_error
1392 elif len(channel_description
) < 500:
1393 description
= channel_description
1395 pos
= channel_description
.find('\n\n')
1396 if pos
== -1 or pos
> 500:
1397 description
= channel_description
[:498] + '[...]'
1399 description
= channel_description
[:pos
]
1401 description
= Gtk
.Label(label
=description
)
1402 description
.set_max_width_chars(60)
1404 description
.set_markup(error_str
)
1405 description
.set_alignment(0, 0)
1406 description
.set_line_wrap(True)
1407 box
.add(description
)
1410 tooltip
.set_custom(box
)
1414 setattr(treeview
, TreeViewHelper
.LAST_TOOLTIP
, None)
1417 def treeview_allow_tooltips(self
, treeview
, allow
):
1418 setattr(treeview
, TreeViewHelper
.CAN_TOOLTIP
, allow
)
1420 def treeview_handle_context_menu_click(self
, treeview
, event
):
1422 selection
= treeview
.get_selection()
1423 return selection
.get_selected_rows()
1425 x
, y
= int(event
.x
), int(event
.y
)
1426 path
, column
, rx
, ry
= treeview
.get_path_at_pos(x
, y
) or (None,) * 4
1428 selection
= treeview
.get_selection()
1429 model
, paths
= selection
.get_selected_rows()
1431 if path
is None or (path
not in paths
and
1433 # We have right-clicked, but not into the selection,
1434 # assume we don't want to operate on the selection
1437 if (path
is not None and not paths
and
1439 # No selection or clicked outside selection;
1440 # select the single item where we clicked
1441 treeview
.grab_focus()
1442 treeview
.set_cursor(path
, column
, 0)
1446 # Unselect any remaining items (clicked elsewhere)
1447 if not treeview
.is_rubber_banding_active():
1448 selection
.unselect_all()
1452 def downloads_list_get_selection(self
, model
=None, paths
=None):
1453 if model
is None and paths
is None:
1454 selection
= self
.treeDownloads
.get_selection()
1455 model
, paths
= selection
.get_selected_rows()
1457 can_queue
, can_cancel
, can_pause
, can_remove
, can_force
= (True,) * 5
1458 selected_tasks
= [(Gtk
.TreeRowReference
.new(model
, path
),
1459 model
.get_value(model
.get_iter(path
),
1460 DownloadStatusModel
.C_TASK
)) for path
in paths
]
1462 for row_reference
, task
in selected_tasks
:
1463 if task
.status
!= download
.DownloadTask
.QUEUED
:
1465 if task
.status
not in (download
.DownloadTask
.PAUSED
,
1466 download
.DownloadTask
.FAILED
,
1467 download
.DownloadTask
.CANCELLED
):
1469 if task
.status
not in (download
.DownloadTask
.PAUSED
,
1470 download
.DownloadTask
.QUEUED
,
1471 download
.DownloadTask
.DOWNLOADING
,
1472 download
.DownloadTask
.FAILED
):
1474 if task
.status
not in (download
.DownloadTask
.QUEUED
,
1475 download
.DownloadTask
.DOWNLOADING
):
1477 if task
.status
not in (download
.DownloadTask
.CANCELLED
,
1478 download
.DownloadTask
.FAILED
,
1479 download
.DownloadTask
.DONE
):
1482 return selected_tasks
, can_queue
, can_cancel
, can_pause
, can_remove
, can_force
1484 def downloads_finished(self
, download_tasks_seen
):
1485 # Separate tasks into downloads & syncs
1486 # Since calling notify_as_finished or notify_as_failed clears the flag,
1487 # need to iterate through downloads & syncs separately, else all sync
1488 # tasks will have their flags cleared if we do downloads first
1490 def filter_by_activity(activity
, tasks
):
1491 return [task
for task
in tasks
if task
.activity
== activity
]
1493 download_tasks
= filter_by_activity(download
.DownloadTask
.ACTIVITY_DOWNLOAD
,
1494 download_tasks_seen
)
1496 finished_downloads
= [str(task
)
1497 for task
in download_tasks
if task
.notify_as_finished()]
1498 failed_downloads
= ['%s (%s)' % (task
, task
.error_message
)
1499 for task
in download_tasks
if task
.notify_as_failed()]
1501 sync_tasks
= filter_by_activity(download
.DownloadTask
.ACTIVITY_SYNCHRONIZE
,
1502 download_tasks_seen
)
1504 finished_syncs
= [task
for task
in sync_tasks
if task
.notify_as_finished()]
1505 failed_syncs
= [task
for task
in sync_tasks
if task
.notify_as_failed()]
1507 # Note that 'finished_ / failed_downloads' is a list of strings
1508 # Whereas 'finished_ / failed_syncs' is a list of SyncTask objects
1510 if finished_downloads
and failed_downloads
:
1511 message
= self
.format_episode_list(finished_downloads
, 5)
1512 message
+= '\n\n<i>%s</i>\n' % _('Could not download some episodes:')
1513 message
+= self
.format_episode_list(failed_downloads
, 5)
1514 self
.show_message(message
, _('Downloads finished'))
1515 elif finished_downloads
:
1516 message
= self
.format_episode_list(finished_downloads
)
1517 self
.show_message(message
, _('Downloads finished'))
1518 elif failed_downloads
:
1519 message
= self
.format_episode_list(failed_downloads
)
1520 self
.show_message(message
, _('Downloads failed'))
1522 if finished_syncs
and failed_syncs
:
1523 message
= self
.format_episode_list(list(map((
1524 lambda task
: str(task
)), finished_syncs
)), 5)
1525 message
+= '\n\n<i>%s</i>\n' % _('Could not sync some episodes:')
1526 message
+= self
.format_episode_list(list(map((
1527 lambda task
: str(task
)), failed_syncs
)), 5)
1528 self
.show_message(message
, _('Device synchronization finished'), True)
1529 elif finished_syncs
:
1530 message
= self
.format_episode_list(list(map((
1531 lambda task
: str(task
)), finished_syncs
)))
1532 self
.show_message(message
, _('Device synchronization finished'))
1534 message
= self
.format_episode_list(list(map((
1535 lambda task
: str(task
)), failed_syncs
)))
1536 self
.show_message(message
, _('Device synchronization failed'), True)
1538 # Do post-sync processing if required
1539 for task
in finished_syncs
:
1540 if self
.config
.device_sync
.after_sync
.mark_episodes_played
:
1541 logger
.info('Marking as played on transfer: %s', task
.episode
.url
)
1542 task
.episode
.mark(is_played
=True)
1544 if self
.config
.device_sync
.after_sync
.delete_episodes
:
1545 logger
.info('Removing episode after transfer: %s', task
.episode
.url
)
1546 task
.episode
.delete_from_disk()
1548 self
.sync_ui
.device
.close()
1550 # Update icon list to show changes, if any
1551 self
.update_episode_list_icons(all
=True)
1552 self
.update_podcast_list_model()
1554 def format_episode_list(self
, episode_list
, max_episodes
=10):
1556 Format a list of episode names for notifications
1558 Will truncate long episode names and limit the amount of
1559 episodes displayed (max_episodes=10).
1561 The episode_list parameter should be a list of strings.
1563 MAX_TITLE_LENGTH
= 100
1566 for title
in episode_list
[:min(len(episode_list
), max_episodes
)]:
1567 # Bug 1834: make sure title is a unicode string,
1568 # so it may be cut correctly on UTF-8 char boundaries
1569 title
= util
.convert_bytes(title
)
1570 if len(title
) > MAX_TITLE_LENGTH
:
1571 middle
= (MAX_TITLE_LENGTH
// 2) - 2
1572 title
= '%s...%s' % (title
[0:middle
], title
[-middle
:])
1573 result
.append(html
.escape(title
))
1576 more_episodes
= len(episode_list
) - max_episodes
1577 if more_episodes
> 0:
1578 result
.append('(...')
1579 result
.append(N_('%(count)d more episode',
1580 '%(count)d more episodes',
1581 more_episodes
) % {'count': more_episodes
})
1582 result
.append('...)')
1584 return (''.join(result
)).strip()
1586 def queue_task(self
, task
, force_start
):
1588 self
.download_queue_manager
.force_start_task(task
)
1590 self
.download_queue_manager
.queue_task(task
)
1592 def _for_each_task_set_status(self
, tasks
, status
, force_start
=False):
1593 episode_urls
= set()
1594 model
= self
.treeDownloads
.get_model()
1595 for row_reference
, task
in tasks
:
1597 if status
== download
.DownloadTask
.QUEUED
:
1598 # Only queue task when it's paused/failed/cancelled (or forced)
1599 if task
.status
in (download
.DownloadTask
.PAUSED
,
1600 download
.DownloadTask
.FAILED
,
1601 download
.DownloadTask
.CANCELLED
) or force_start
:
1603 # add the task back in if it was already cleaned up
1604 # (to trigger this cancel one downloads in the active list, cancel all
1605 # other downloads, quickly right click on the cancelled on one to get
1606 # the context menu, wait until the active list is cleared, and then
1607 # then choose download)
1608 if task
not in self
.download_tasks_seen
:
1609 self
.download_status_model
.register_task(task
, False)
1610 self
.download_tasks_seen
.add(task
)
1612 self
.queue_task(task
, force_start
)
1613 self
.set_download_list_state(gPodderSyncUI
.DL_ONEOFF
)
1614 elif status
== download
.DownloadTask
.CANCELLING
:
1615 logger
.info(("cancelling task %s" % task
.status
))
1617 elif status
== download
.DownloadTask
.PAUSING
:
1619 elif status
is None:
1620 # Remove the selected task - cancel downloading/queued tasks
1621 if task
.status
in (download
.DownloadTask
.QUEUED
, download
.DownloadTask
.DOWNLOADING
):
1622 task
.status
= download
.DownloadTask
.CANCELLED
1623 path
= row_reference
.get_path()
1624 # path isn't set if the item has already been removed from the list
1625 # (to trigger this cancel one downloads in the active list, cancel all
1626 # other downloads, quickly right click on the cancelled on one to get
1627 # the context menu, wait until the active list is cleared, and then
1628 # then choose remove from list)
1630 model
.remove(model
.get_iter(path
))
1631 # Remember the URL, so we can tell the UI to update
1633 # We don't "see" this task anymore - remove it;
1634 # this is needed, so update_episode_list_icons()
1635 # below gets the correct list of "seen" tasks
1636 self
.download_tasks_seen
.remove(task
)
1637 except KeyError as key_error
:
1639 episode_urls
.add(task
.url
)
1640 # Tell the task that it has been removed (so it can clean up)
1641 task
.removed_from_list()
1643 # We can (hopefully) simply set the task status here
1644 task
.status
= status
1645 # Tell the podcasts tab to update icons for our removed podcasts
1646 self
.update_episode_list_icons(episode_urls
)
1647 # Update the tab title and downloads list
1648 self
.update_downloads_list()
1650 def treeview_downloads_show_context_menu(self
, treeview
, event
=None):
1651 model
, paths
= self
.treeview_handle_context_menu_click(treeview
, event
)
1653 return not treeview
.is_rubber_banding_active()
1655 if event
is None or event
.button
== 3:
1656 selected_tasks
, can_queue
, can_cancel
, can_pause
, can_remove
, can_force
= \
1657 self
.downloads_list_get_selection(model
, paths
)
1659 def make_menu_item(label
, icon_name
, tasks
=None, status
=None, sensitive
=True, force_start
=False, action
=None):
1660 # This creates a menu item for selection-wide actions
1661 item
= Gtk
.ImageMenuItem
.new_with_mnemonic(label
)
1662 if icon_name
is not None:
1663 item
.set_image(Gtk
.Image
.new_from_icon_name(icon_name
, Gtk
.IconSize
.MENU
))
1664 if action
is not None:
1665 item
.connect('activate', action
)
1667 item
.connect('activate', lambda item
: self
._for
_each
_task
_set
_status
(tasks
, status
, force_start
))
1668 item
.set_sensitive(sensitive
)
1671 def move_selected_items_up(menu_item
):
1672 selection
= self
.treeDownloads
.get_selection()
1673 model
, selected_paths
= selection
.get_selected_rows()
1674 for path
in selected_paths
:
1675 index_above
= path
[0] - 1
1678 task
= model
.get_value(
1679 model
.get_iter(path
),
1680 DownloadStatusModel
.C_TASK
)
1682 model
.get_iter(path
),
1683 model
.get_iter((index_above
,)))
1685 def move_selected_items_down(menu_item
):
1686 selection
= self
.treeDownloads
.get_selection()
1687 model
, selected_paths
= selection
.get_selected_rows()
1688 for path
in reversed(selected_paths
):
1689 index_below
= path
[0] + 1
1690 if index_below
>= len(model
):
1692 task
= model
.get_value(
1693 model
.get_iter(path
),
1694 DownloadStatusModel
.C_TASK
)
1696 model
.get_iter(path
),
1697 model
.get_iter((index_below
,)))
1702 menu
.append(make_menu_item(_('Start download now'), 'document-save',
1704 download
.DownloadTask
.QUEUED
,
1707 menu
.append(make_menu_item(_('Download'), 'document-save',
1709 download
.DownloadTask
.QUEUED
,
1712 menu
.append(make_menu_item(_('Cancel'), 'media-playback-stop',
1714 download
.DownloadTask
.CANCELLING
,
1716 menu
.append(make_menu_item(_('Pause'), 'media-playback-pause',
1718 download
.DownloadTask
.PAUSING
, can_pause
))
1719 menu
.append(Gtk
.SeparatorMenuItem())
1720 menu
.append(make_menu_item(_('Move up'), 'go-up',
1721 action
=move_selected_items_up
))
1722 menu
.append(make_menu_item(_('Move down'), 'go-down',
1723 action
=move_selected_items_down
))
1724 menu
.append(Gtk
.SeparatorMenuItem())
1725 menu
.append(make_menu_item(_('Remove from list'), 'list-remove',
1726 selected_tasks
, sensitive
=can_remove
))
1728 menu
.attach_to_widget(treeview
)
1732 func
= TreeViewHelper
.make_popup_position_func(treeview
)
1733 menu
.popup(None, None, func
, None, 3, Gtk
.get_current_event_time())
1735 menu
.popup(None, None, None, None, event
.button
, event
.time
)
1738 def on_mark_episodes_as_old(self
, item
):
1739 assert self
.active_channel
is not None
1741 for episode
in self
.active_channel
.get_all_episodes():
1742 if not episode
.was_downloaded(and_exists
=True):
1743 episode
.mark(is_played
=True)
1745 self
.update_podcast_list_model(selected
=True)
1746 self
.update_episode_list_icons(all
=True)
1748 def on_open_download_folder(self
, item
):
1749 assert self
.active_channel
is not None
1750 util
.gui_open(self
.active_channel
.save_dir
, gui
=self
)
1752 def treeview_channels_show_context_menu(self
, treeview
, event
=None):
1753 model
, paths
= self
.treeview_handle_context_menu_click(treeview
, event
)
1757 # Check for valid channel id, if there's no id then
1758 # assume that it is a proxy channel or equivalent
1759 # and cannot be operated with right click
1760 if self
.active_channel
.id is None:
1763 if event
is None or event
.button
== 3:
1766 item
= Gtk
.ImageMenuItem(_('Update podcast'))
1767 item
.set_image(Gtk
.Image
.new_from_icon_name('view-refresh', Gtk
.IconSize
.MENU
))
1768 item
.set_action_name('win.updateChannel')
1771 menu
.append(Gtk
.SeparatorMenuItem())
1773 item
= Gtk
.MenuItem(_('Open download folder'))
1774 item
.connect('activate', self
.on_open_download_folder
)
1777 menu
.append(Gtk
.SeparatorMenuItem())
1779 item
= Gtk
.MenuItem(_('Mark episodes as old'))
1780 item
.connect('activate', self
.on_mark_episodes_as_old
)
1783 item
= Gtk
.CheckMenuItem(_('Archive'))
1784 item
.set_active(self
.active_channel
.auto_archive_episodes
)
1785 item
.connect('activate', self
.on_channel_toggle_lock_activate
)
1788 item
= Gtk
.ImageMenuItem(_('Refresh image'))
1789 item
.connect('activate', self
.on_itemRefreshCover_activate
)
1792 item
= Gtk
.ImageMenuItem(_('Delete podcast'))
1793 item
.set_image(Gtk
.Image
.new_from_icon_name('edit-delete', Gtk
.IconSize
.MENU
))
1794 item
.connect('activate', self
.on_itemRemoveChannel_activate
)
1797 result
= gpodder
.user_extensions
.on_channel_context_menu(self
.active_channel
)
1799 menu
.append(Gtk
.SeparatorMenuItem())
1800 for label
, callback
in result
:
1801 item
= Gtk
.MenuItem(label
)
1803 item
.connect('activate', lambda item
, callback
: callback(self
.active_channel
), callback
)
1805 item
.set_sensitive(False)
1808 menu
.append(Gtk
.SeparatorMenuItem())
1810 item
= Gtk
.ImageMenuItem(_('Podcast settings'))
1811 item
.set_image(Gtk
.Image
.new_from_icon_name('document-properties', Gtk
.IconSize
.MENU
))
1812 item
.set_action_name('win.editChannel')
1815 menu
.attach_to_widget(treeview
)
1817 # Disable tooltips while we are showing the menu, so
1818 # the tooltip will not appear over the menu
1819 self
.treeview_allow_tooltips(self
.treeChannels
, False)
1820 menu
.connect('deactivate', lambda menushell
: self
.treeview_allow_tooltips(self
.treeChannels
, True))
1823 func
= TreeViewHelper
.make_popup_position_func(treeview
)
1824 menu
.popup(None, None, func
, None, 3, Gtk
.get_current_event_time())
1826 menu
.popup(None, None, None, None, event
.button
, event
.time
)
1830 def cover_download_finished(self
, channel
, pixbuf
):
1832 The Cover Downloader calls this when it has finished
1833 downloading (or registering, if already downloaded)
1834 a new channel cover, which is ready for displaying.
1836 util
.idle_add(self
.podcast_list_model
.add_cover_by_channel
,
1840 def build_filename(filename
, extension
):
1841 filename
, extension
= util
.sanitize_filename_ext(
1844 PodcastEpisode
.MAX_FILENAME_LENGTH
,
1845 PodcastEpisode
.MAX_FILENAME_WITH_EXT_LENGTH
)
1846 if not filename
.endswith(extension
):
1847 filename
+= extension
1850 def save_episodes_as_file(self
, episodes
):
1851 def do_save_episode(copy_from
, copy_to
):
1852 if os
.path
.exists(copy_to
):
1853 logger
.warn(copy_from
)
1854 logger
.warn(copy_to
)
1855 title
= _('File already exists')
1856 d
= {'filename': os
.path
.basename(copy_to
)}
1857 message
= _('A file named "%(filename)s" already exists. Do you want to replace it?') % d
1858 if not self
.show_confirmation(message
, title
):
1861 shutil
.copyfile(copy_from
, copy_to
)
1862 except (OSError, IOError) as e
:
1863 logger
.warn('Error copying from %s to %s: %r', copy_from
, copy_to
, e
, exc_info
=True)
1864 folder
, filename
= os
.path
.split(copy_to
)
1865 # Remove characters not supported by VFAT (#282)
1866 new_filename
= re
.sub(r
"[\"*/:<>?
\\|
]", "_
", filename)
1867 destination = os.path.join(folder, new_filename)
1868 if (copy_to != destination):
1869 shutil.copyfile(copy_from, destination)
1873 PRIVATE_FOLDER_ATTRIBUTE = '_save_episodes_as_file_folder'
1874 folder = getattr(self, PRIVATE_FOLDER_ATTRIBUTE, None)
1875 allRemainingDefault = False
1876 remaining = len(episodes)
1877 dialog = gPodderExportToLocalFolder(self.main_window,
1878 _config=self.config)
1879 for episode in episodes:
1881 if episode.was_downloaded(and_exists=True):
1882 copy_from = episode.local_filename(create=False)
1883 assert copy_from is not None
1885 base, extension = os.path.splitext(copy_from)
1886 filename = self.build_filename(episode.sync_filename(), extension)
1889 if allRemainingDefault:
1890 do_save_episode(copy_from, os.path.join(folder, filename))
1892 (notCancelled, folder, dest_path, allRemainingDefault) = dialog.save_as(folder, filename, remaining)
1894 do_save_episode(copy_from, dest_path)
1897 except (OSError, IOError) as e:
1899 msg = _('Error saving to local folder: %(error)r.\n'
1900 'Would you like to continue?') % dict(error=e)
1901 if not self.show_confirmation(msg, _('Error saving to local folder')):
1902 logger.warn("Save to Local Folder cancelled following error
")
1905 self.notification(_('Error saving to local folder: %(error)r') % dict(error=e),
1906 _('Error saving to local folder'), important=True)
1908 setattr(self, PRIVATE_FOLDER_ATTRIBUTE, folder)
1910 def copy_episodes_bluetooth(self, episodes):
1911 episodes_to_copy = [e for e in episodes if e.was_downloaded(and_exists=True)]
1913 def convert_and_send_thread(episode):
1914 for episode in episodes:
1915 filename = episode.local_filename(create=False)
1916 assert filename is not None
1917 (base, ext) = os.path.splitext(filename)
1918 destfile = self.build_filename(episode.sync_filename(), ext)
1919 destfile = os.path.join(tempfile.gettempdir(), destfile)
1922 shutil.copyfile(filename, destfile)
1923 util.bluetooth_send_file(destfile)
1925 logger.error('Cannot copy "%s" to "%s".', filename, destfile)
1926 self.notification(_('Error converting file.'), _('Bluetooth file transfer'), important=True)
1928 util.delete_file(destfile)
1930 util.run_in_background(lambda: convert_and_send_thread(episodes_to_copy))
1932 def _add_sub_menu(self, menu, label):
1933 root_item = Gtk.MenuItem(label)
1934 menu.append(root_item)
1935 sub_menu = Gtk.Menu()
1936 root_item.set_submenu(sub_menu)
1939 def _submenu_item_activate_hack(self, item, callback, *args):
1940 # See http://stackoverflow.com/questions/5221326/submenu-item-does-not-call-function-with-working-solution
1941 # Note that we can't just call the callback on button-press-event, as
1942 # it might be blocking (see http://gpodder.org/bug/1778), so we run
1943 # this in the GUI thread at a later point in time (util.idle_add).
1944 # Also, we also have to connect to the activate signal, as this is the
1945 # only signal that is fired when keyboard navigation is used.
1947 # It can happen that both (button-release-event and activate) signals
1948 # are fired, and we must avoid calling the callback twice. We do this
1949 # using a semaphore and only acquiring (but never releasing) it, making
1950 # sure that the util.idle_add() call below is only ever called once.
1951 only_once = threading.Semaphore(1)
1953 def handle_event(item, event=None):
1954 if only_once.acquire(False):
1955 util.idle_add(callback, *args)
1957 item.connect('button-press-event', handle_event)
1958 item.connect('activate', handle_event)
1960 def treeview_available_show_context_menu(self, treeview, event=None):
1961 model, paths = self.treeview_handle_context_menu_click(treeview, event)
1963 return not treeview.is_rubber_banding_active()
1965 if event is None or event.button == 3:
1966 episodes = self.get_selected_episodes()
1967 any_locked = any(e.archive for e in episodes)
1968 any_new = any(e.is_new and e.state != gpodder.STATE_DELETED for e in episodes)
1969 downloaded = all(e.was_downloaded(and_exists=True) for e in episodes)
1970 downloading = any(e.downloading for e in episodes)
1974 (can_play, can_download, can_pause, can_cancel, can_delete, open_instead_of_play) = self.play_or_download()
1976 if open_instead_of_play:
1977 item = Gtk.ImageMenuItem(_('Open'))
1978 item.set_image(Gtk.Image.new_from_icon_name('document-open', Gtk.IconSize.MENU))
1980 item = Gtk.ImageMenuItem(_('Play'))
1981 item.set_image(Gtk.Image.new_from_icon_name('media-playback-start', Gtk.IconSize.MENU))
1984 item = Gtk.ImageMenuItem(_('Preview'))
1986 item = Gtk.ImageMenuItem(_('Stream'))
1987 item.set_image(Gtk.Image.new_from_icon_name('media-playback-start', Gtk.IconSize.MENU))
1989 item.set_sensitive(can_play)
1990 item.connect('activate', self.on_playback_selected_episodes)
1994 item = Gtk.ImageMenuItem(_('Download'))
1995 item.set_image(Gtk.Image.new_from_icon_name('document-save', Gtk.IconSize.MENU))
1996 item.set_action_name('win.download')
1999 item = Gtk.ImageMenuItem(_('Pause'))
2000 item.set_image(Gtk.Image.new_from_icon_name('media-playback-pause', Gtk.IconSize.MENU))
2001 item.set_action_name('win.pause')
2004 item = Gtk.ImageMenuItem.new_with_mnemonic(_('_Cancel'))
2005 item.set_action_name('win.cancel')
2008 item = Gtk.ImageMenuItem.new_with_mnemonic(_('_Delete'))
2009 item.set_image(Gtk.Image.new_from_icon_name('edit-delete', Gtk.IconSize.MENU))
2010 item.set_action_name('win.delete')
2013 result = gpodder.user_extensions.on_episodes_context_menu(episodes)
2015 menu.append(Gtk.SeparatorMenuItem())
2017 for label, callback in result:
2018 key, sep, title = label.rpartition('/')
2019 item = Gtk.ImageMenuItem(title)
2021 self._submenu_item_activate_hack(item, callback, episodes)
2023 item.set_sensitive(False)
2025 if key not in submenus:
2026 sub_menu = self._add_sub_menu(menu, key)
2027 submenus[key] = sub_menu
2029 sub_menu = submenus[key]
2030 sub_menu.append(item)
2034 # Ok, this probably makes sense to only display for downloaded files
2036 menu.append(Gtk.SeparatorMenuItem())
2037 share_menu = self._add_sub_menu(menu, _('Send to'))
2039 item = Gtk.ImageMenuItem(_('Local folder'))
2040 item.set_image(Gtk.Image.new_from_icon_name('folder', Gtk.IconSize.MENU))
2041 self._submenu_item_activate_hack(item, self.save_episodes_as_file, episodes)
2042 share_menu.append(item)
2043 if self.bluetooth_available:
2044 item = Gtk.ImageMenuItem(_('Bluetooth device'))
2045 item.set_image(Gtk.Image.new_from_icon_name('bluetooth', Gtk.IconSize.MENU))
2046 self._submenu_item_activate_hack(item, self.copy_episodes_bluetooth, episodes)
2047 share_menu.append(item)
2049 menu.append(Gtk.SeparatorMenuItem())
2051 item = Gtk.CheckMenuItem(_('New'))
2052 item.set_active(any_new)
2054 item.connect('activate', lambda w: self.mark_selected_episodes_old())
2056 item.connect('activate', lambda w: self.mark_selected_episodes_new())
2060 item = Gtk.CheckMenuItem(_('Archive'))
2061 item.set_active(any_locked)
2062 item.connect('activate',
2063 lambda w: self.on_item_toggle_lock_activate(
2064 w, False, not any_locked))
2067 menu.append(Gtk.SeparatorMenuItem())
2068 # Single item, add episode information menu item
2069 item = Gtk.ImageMenuItem(_('Episode details'))
2070 item.set_image(Gtk.Image.new_from_icon_name('dialog-information',
2072 item.set_action_name('win.toggleShownotes')
2075 menu.attach_to_widget(treeview)
2077 # Disable tooltips while we are showing the menu, so
2078 # the tooltip will not appear over the menu
2079 self.treeview_allow_tooltips(self.treeAvailable, False)
2080 menu.connect('deactivate', lambda menushell: self.treeview_allow_tooltips(self.treeAvailable, True))
2082 func = TreeViewHelper.make_popup_position_func(treeview)
2083 menu.popup(None, None, func, None, 3, Gtk.get_current_event_time())
2085 menu.popup(None, None, None, None, event.button, event.time)
2089 def set_title(self, new_title):
2090 self.default_title = new_title
2091 self.gPodder.set_title(new_title)
2093 def update_episode_list_icons(self, urls=None, selected=False, all=False):
2095 Updates the status icons in the episode list.
2097 If urls is given, it should be a list of URLs
2098 of episodes that should be updated.
2100 If urls is None, set ONE OF selected, all to
2101 True (the former updates just the selected
2102 episodes and the latter updates all episodes).
2104 descriptions = self.config.episode_list_descriptions
2106 if urls is not None:
2107 # We have a list of URLs to walk through
2108 self.episode_list_model.update_by_urls(urls, descriptions)
2109 elif selected and not all:
2110 # We should update all selected episodes
2111 selection = self.treeAvailable.get_selection()
2112 model, paths = selection.get_selected_rows()
2113 for path in reversed(paths):
2114 iter = model.get_iter(path)
2115 self.episode_list_model.update_by_filter_iter(iter, descriptions)
2116 elif all and not selected:
2117 # We update all (even the filter-hidden) episodes
2118 self.episode_list_model.update_all(descriptions)
2120 # Wrong/invalid call - have to specify at least one parameter
2121 raise ValueError('Invalid call to update_episode_list_icons')
2123 def episode_list_status_changed(self, episodes):
2124 self.update_episode_list_icons(set(e.url for e in episodes))
2125 self.update_podcast_list_model(set(e.channel.url for e in episodes))
2128 def episode_player(self, episode):
2129 file_type = episode.file_type()
2130 if file_type == 'video' and self.config.player.video \
2131 and self.config.player.video != 'default':
2132 player = self.config.player.video
2133 elif file_type == 'audio' and self.config.player.audio \
2134 and self.config.player.audio != 'default':
2135 player = self.config.player.audio
2140 def streaming_possible(self, episode=None):
2142 Don't try streaming if the user has not defined a player
2143 or else we would probably open the browser when giving a URL to xdg-open.
2144 If an episode is given, we look at the audio or video player depending on its file type.
2145 :return bool: if streaming is possible
2148 player = self.episode_player(episode)
2150 player = self.config.player.audio
2151 return player and player != 'default'
2153 def playback_episodes_for_real(self, episodes):
2154 groups = collections.defaultdict(list)
2155 for episode in episodes:
2156 episode._download_error = None
2158 if episode.download_task is not None and episode.download_task.status == episode.download_task.FAILED:
2159 # Cancel failed task and remove from progress list
2160 episode.download_task.cancel()
2161 self.cleanup_downloads()
2163 player = self.episode_player(episode)
2166 allow_partial = (player != 'default')
2167 filename = episode.get_playback_url(self.config, allow_partial)
2168 except Exception as e:
2169 episode._download_error = str(e)
2172 # Mark episode as played in the database
2173 episode.playback_mark()
2174 self.mygpo_client.on_playback([episode])
2176 # Determine the playback resume position - if the file
2177 # was played 100%, we simply start from the beginning
2178 resume_position = episode.current_position
2179 if resume_position == episode.total_time:
2182 # If Panucci is configured, use D-Bus to call it
2183 if player == 'panucci':
2185 PANUCCI_NAME = 'org.panucci.panucciInterface'
2186 PANUCCI_PATH = '/panucciInterface'
2187 PANUCCI_INTF = 'org.panucci.panucciInterface'
2188 o = gpodder.dbus_session_bus.get_object(PANUCCI_NAME, PANUCCI_PATH)
2189 i = dbus.Interface(o, PANUCCI_INTF)
2191 def on_reply(*args):
2194 def error_handler(filename, err):
2195 logger.error('Exception in D-Bus call: %s', str(err))
2197 # Fallback: use the command line client
2198 for command in util.format_desktop_command('panucci',
2200 logger.info('Executing: %s', repr(command))
2201 util.Popen(command, close_fds=True)
2204 return error_handler(filename, err)
2206 # This method only exists in Panucci > 0.9 ('new Panucci')
2207 i.playback_from(filename, resume_position,
2208 reply_handler=on_reply, error_handler=on_error)
2210 continue # This file was handled by the D-Bus call
2211 except Exception as e:
2212 logger.error('Calling Panucci using D-Bus', exc_info=True)
2214 groups[player].append(filename)
2216 # Open episodes with system default player
2217 if 'default' in groups:
2218 for filename in groups['default']:
2219 logger.debug('Opening with system default: %s', filename)
2220 util.gui_open(filename, gui=self)
2221 del groups['default']
2223 # For each type now, go and create play commands
2224 for group in groups:
2225 for command in util.format_desktop_command(group, groups[group], resume_position):
2226 logger.debug('Executing: %s', repr(command))
2227 util.Popen(command, close_fds=True)
2229 # Persist episode status changes to the database
2232 # Flush updated episode status
2233 if self.mygpo_client.can_access_webservice():
2234 self.mygpo_client.flush()
2236 def playback_episodes(self, episodes):
2237 # We need to create a list, because we run through it more than once
2238 episodes = list(Model.sort_episodes_by_pubdate(e for e in episodes if
2239 e.was_downloaded(and_exists=True) or self.streaming_possible(e)))
2242 self.playback_episodes_for_real(episodes)
2243 except Exception as e:
2244 logger.error('Error in playback!', exc_info=True)
2245 self.show_message(_('Please check your media player settings in the preferences dialog.'),
2246 _('Error opening player'))
2248 self.episode_list_status_changed(episodes)
2250 def play_or_download(self, current_page=None):
2251 if current_page is None:
2252 current_page = self.wNotebook.get_current_page()
2253 if current_page > 0:
2254 self.toolCancel.set_sensitive(True)
2257 (can_play, can_download, can_pause, can_cancel, can_delete, open_instead_of_play) = (False,) * 6
2261 selection = self.treeAvailable.get_selection()
2262 if selection.count_selected_rows() > 0:
2263 (model, paths) = selection.get_selected_rows()
2264 streaming_possible = self.streaming_possible()
2268 episode = model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE)
2270 logger.info('Invalid episode at path %s', str(path))
2272 except TypeError as te:
2273 logger.error('Invalid episode at path %s', str(path))
2276 if episode.file_type() not in ('audio', 'video'):
2277 open_instead_of_play = True
2279 if episode.was_downloaded():
2280 can_play = episode.was_downloaded(and_exists=True)
2284 if episode.downloading:
2287 if episode.download_task is not None:
2288 if episode.download_task.status in (episode.download_task.PAUSING, episode.download_task.PAUSED):
2290 elif episode.download_task.status in (episode.download_task.QUEUED, episode.download_task.DOWNLOADING):
2293 streaming_possible |= self.streaming_possible(episode)
2296 can_download = (can_download and not can_cancel) or can_resume
2297 can_play = streaming_possible or (can_play and not can_cancel and not can_download)
2298 can_delete = not can_cancel
2300 if open_instead_of_play:
2301 self.toolPlay.set_icon_name('document-open')
2303 self.toolPlay.set_icon_name('media-playback-start')
2304 self.toolPlay.set_sensitive(can_play)
2305 self.toolDownload.set_sensitive(can_download)
2306 self.toolPause.set_sensitive(can_pause)
2307 self.toolCancel.set_sensitive(can_cancel)
2309 self.cancel_action.set_enabled(can_cancel)
2310 self.download_action.set_enabled(can_download)
2311 self.pause_action.set_enabled(can_pause)
2312 self.open_action.set_enabled(can_play and open_instead_of_play)
2313 self.play_action.set_enabled(can_play and not open_instead_of_play)
2314 self.delete_action.set_enabled(can_delete)
2315 self.toggle_episode_new_action.set_enabled(can_play)
2316 self.toggle_episode_lock_action.set_enabled(can_play)
2318 return (can_play, can_download, can_pause, can_cancel, can_delete, open_instead_of_play)
2320 def on_cbMaxDownloads_toggled(self, widget, *args):
2321 self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
2323 def on_cbLimitDownloads_toggled(self, widget, *args):
2324 self.spinLimitDownloads.set_sensitive(self.cbLimitDownloads.get_active())
2326 def episode_new_status_changed(self, urls):
2327 self.update_podcast_list_model()
2328 self.update_episode_list_icons(urls)
2330 def refresh_episode_dates(self):
2331 t = time.localtime()
2333 if self.last_episode_date_refresh is not None and self.last_episode_date_refresh != current_day:
2334 # update all episodes in current view
2335 for row in self.episode_list_model:
2336 row[EpisodeListModel.C_PUBLISHED_TEXT] = row[EpisodeListModel.C_EPISODE].cute_pubdate()
2338 self.last_episode_date_refresh = current_day
2340 remaining_seconds = 86400 - 3600 * t.tm_hour - 60 * t.tm_min - t.tm_sec
2341 if remaining_seconds > 3600:
2342 # timeout an hour early in the event daylight savings changes the clock forward
2343 remaining_seconds = remaining_seconds - 3600
2344 GObject.timeout_add(remaining_seconds * 1000, self.refresh_episode_dates)
2346 def update_podcast_list_model(self, urls=None, selected=False, select_url=None,
2347 sections_changed=False):
2348 """Update the podcast list treeview model
2350 If urls is given, it should list the URLs of each
2351 podcast that has to be updated in the list.
2353 If selected is True, only update the model contents
2354 for the currently-selected podcast - nothing more.
2356 The caller can optionally specify "select_url
",
2357 which is the URL of the podcast that is to be
2358 selected in the list after the update is complete.
2359 This only works if the podcast list has to be
2360 reloaded; i.e. something has been added or removed
2361 since the last update of the podcast list).
2363 selection = self.treeChannels.get_selection()
2364 model, iter = selection.get_selected()
2367 return r[PodcastListModel.C_URL] == '-'
2369 def is_separator(r):
2370 return r[PodcastListModel.C_SEPARATOR]
2372 sections_active = any(is_section(x) for x in self.podcast_list_model)
2374 if self.config.podcast_list_view_all:
2375 # Update "all episodes
" view in any case (if enabled)
2376 self.podcast_list_model.update_first_row()
2377 # List model length minus 1, because of "All
"
2378 list_model_length = len(self.podcast_list_model) - 1
2380 list_model_length = len(self.podcast_list_model)
2382 force_update = (sections_active != self.config.podcast_list_sections or
2385 # Filter items in the list model that are not podcasts, so we get the
2386 # correct podcast list count (ignore section headers and separators)
2388 def is_not_podcast(r):
2389 return is_section(r) or is_separator(r)
2391 list_model_length -= len(list(filter(is_not_podcast, self.podcast_list_model)))
2393 if selected and not force_update:
2394 # very cheap! only update selected channel
2395 if iter is not None:
2396 # If we have selected the "all episodes
" view, we have
2397 # to update all channels for selected episodes:
2398 if self.config.podcast_list_view_all and \
2399 self.podcast_list_model.iter_is_first_row(iter):
2400 urls = self.get_podcast_urls_from_selected_episodes()
2401 self.podcast_list_model.update_by_urls(urls)
2403 # Otherwise just update the selected row (a podcast)
2404 self.podcast_list_model.update_by_filter_iter(iter)
2406 if self.config.podcast_list_sections:
2407 self.podcast_list_model.update_sections()
2408 elif list_model_length == len(self.channels) and not force_update:
2409 # we can keep the model, but have to update some
2411 # still cheaper than reloading the whole list
2412 self.podcast_list_model.update_all()
2414 # ok, we got a bunch of urls to update
2415 self.podcast_list_model.update_by_urls(urls)
2416 if self.config.podcast_list_sections:
2417 self.podcast_list_model.update_sections()
2419 if model and iter and select_url is None:
2420 # Get the URL of the currently-selected podcast
2421 select_url = model.get_value(iter, PodcastListModel.C_URL)
2423 # Update the podcast list model with new channels
2424 self.podcast_list_model.set_channels(self.db, self.config, self.channels)
2427 selected_iter = model.get_iter_first()
2428 # Find the previously-selected URL in the new
2429 # model if we have an URL (else select first)
2430 if select_url is not None:
2431 pos = model.get_iter_first()
2432 while pos is not None:
2433 url = model.get_value(pos, PodcastListModel.C_URL)
2434 if url == select_url:
2437 pos = model.iter_next(pos)
2439 if selected_iter is not None:
2440 selection.select_iter(selected_iter)
2441 self.on_treeChannels_cursor_changed(self.treeChannels)
2443 logger.error('Cannot select podcast in list', exc_info=True)
2445 def on_episode_list_filter_changed(self, has_episodes):
2446 self.play_or_download()
2448 def update_episode_list_model(self):
2449 if self.channels and self.active_channel is not None:
2450 self.treeAvailable.get_selection().unselect_all()
2451 self.treeAvailable.scroll_to_point(0, 0)
2453 descriptions = self.config.episode_list_descriptions
2454 with self.treeAvailable.get_selection().handler_block(self.selection_handler_id):
2455 # have to block the on_episode_list_selection_changed handler because
2456 # when selecting any channel from All Episodes, on_episode_list_selection_changed
2457 # is called once per episode (4k time in my case), causing episode shownotes
2458 # to be updated as many time, resulting in UI freeze for 10 seconds.
2459 self.episode_list_model.replace_from_channel(self.active_channel, descriptions)
2461 self.episode_list_model.clear()
2463 @dbus.service.method(gpodder.dbus_interface)
2464 def offer_new_episodes(self, channels=None):
2465 new_episodes = self.get_new_episodes(channels)
2467 self.new_episodes_show(new_episodes)
2471 def add_podcast_list(self, podcasts, auth_tokens=None):
2472 """Subscribe to a list of podcast given (title, url) pairs
2474 If auth_tokens is given, it should be a dictionary
2475 mapping URLs to (username, password) tuples."""
2477 if auth_tokens is None:
2480 existing_urls = set(podcast.url for podcast in self.channels)
2482 # For a given URL, the desired title (or None)
2485 # Sort and split the URL list into five buckets
2486 queued, failed, existing, worked, authreq = [], [], [], [], []
2487 for input_title, input_url in podcasts:
2488 url = util.normalize_feed_url(input_url)
2490 # Check if it's a YouTube channel, user, or playlist and resolves it to its feed if that's the case
2491 url = youtube.parse_youtube_url(url)
2494 # Fail this one because the URL is not valid
2495 failed.append(input_url)
2496 elif url in existing_urls:
2497 # A podcast already exists in the list for this URL
2498 existing.append(url)
2499 # XXX: Should we try to update the title of the existing
2500 # subscription from input_title here if it is different?
2502 # This URL has survived the first round - queue for add
2503 title_for_url[url] = input_title
2505 if url != input_url and input_url in auth_tokens:
2506 auth_tokens[url] = auth_tokens[input_url]
2511 progress = ProgressIndicator(_('Adding podcasts'),
2512 _('Please wait while episode information is downloaded.'),
2513 parent=self.get_dialog_parent())
2515 def on_after_update():
2516 progress.on_finished()
2517 # Report already-existing subscriptions to the user
2519 title = _('Existing subscriptions skipped')
2520 message = _('You are already subscribed to these podcasts:') \
2521 + '\n\n' + '\n'.join(html.escape(url) for url in existing)
2522 self.show_message(message, title, widget=self.treeChannels)
2524 # Report subscriptions that require authentication
2528 title = _('Podcast requires authentication')
2529 message = _('Please login to %s:') % (html.escape(url),)
2530 success, auth_tokens = self.show_login_dialog(title, message)
2532 retry_podcasts[url] = auth_tokens
2534 # Stop asking the user for more login data
2537 error_messages[url] = _('Authentication failed')
2541 # Report website redirections
2542 for url in redirections:
2543 title = _('Website redirection detected')
2544 message = _('The URL %(url)s redirects to %(target)s.') \
2545 + '\n\n' + _('Do you want to visit the website now?')
2546 message = message % {'url': url, 'target': redirections[url]}
2547 if self.show_confirmation(message, title):
2548 util.open_website(url)
2552 # Report failed subscriptions to the user
2554 title = _('Could not add some podcasts')
2555 message = _('Some podcasts could not be added to your list:')
2556 details = '\n\n'.join('<b>{}</b>:\n{}'.format(html.escape(url),
2557 html.escape(error_messages.get(url, _('Unknown')))) for url in failed)
2558 self.show_message_details(title, message, details)
2560 # Upload subscription changes to gpodder.net
2561 self.mygpo_client.on_subscribe(worked)
2563 # Fix URLs if mygpo has rewritten them
2564 self.rewrite_urls_mygpo()
2566 # If only one podcast was added, select it after the update
2567 if len(worked) == 1:
2572 # Update the list of subscribed podcasts
2573 self.update_podcast_list_model(select_url=url)
2575 # If we have authentication data to retry, do so here
2577 podcasts = [(title_for_url.get(url), url)
2578 for url in list(retry_podcasts.keys())]
2579 self.add_podcast_list(podcasts, retry_podcasts)
2580 # This will NOT show new episodes for podcasts that have
2581 # been added ("worked
"), but it will prevent problems with
2582 # multiple dialogs being open at the same time ;)
2585 # Offer to download new episodes
2587 for podcast in self.channels:
2588 if podcast.url in worked:
2589 episodes.extend(podcast.get_all_episodes())
2592 episodes = list(Model.sort_episodes_by_pubdate(episodes,
2594 self.new_episodes_show(episodes,
2595 selected=[e.check_is_new() for e in episodes])
2597 @util.run_in_background
2599 # After the initial sorting and splitting, try all queued podcasts
2600 length = len(queued)
2601 for index, url in enumerate(queued):
2602 title = title_for_url.get(url)
2603 progress.on_progress(float(index) / float(length))
2604 progress.on_message(title or url)
2606 # The URL is valid and does not exist already - subscribe!
2607 channel = self.model.load_podcast(url=url, create=True,
2608 authentication_tokens=auth_tokens.get(url, None),
2609 max_episodes=self.config.max_episodes_per_feed)
2612 username, password = util.username_password_from_url(url)
2613 except ValueError as ve:
2614 username, password = (None, None)
2616 if title is not None:
2617 # Prefer title from subscription source (bug 1711)
2618 channel.title = title
2620 if username is not None and channel.auth_username is None and \
2621 password is not None and channel.auth_password is None:
2622 channel.auth_username = username
2623 channel.auth_password = password
2627 self._update_cover(channel)
2628 except feedcore.AuthenticationRequired as e:
2629 # use e.url because there might have been a redirection (#571)
2630 if e.url in auth_tokens:
2631 # Fail for wrong authentication data
2632 error_messages[e.url] = _('Authentication failed')
2633 failed.append(e.url)
2635 # Queue for login dialog later
2636 authreq.append(e.url)
2638 except feedcore.WifiLogin as error:
2639 redirections[url] = error.data
2641 error_messages[url] = _('Redirection detected')
2643 except Exception as e:
2644 logger.error('Subscription error: %s', e, exc_info=True)
2645 error_messages[url] = str(e)
2649 assert channel is not None
2650 worked.append(channel.url)
2652 util.idle_add(on_after_update)
2654 def find_episode(self, podcast_url, episode_url):
2655 """Find an episode given its podcast and episode URL
2657 The function will return a PodcastEpisode object if
2658 the episode is found, or None if it's not found.
2660 for podcast in self.channels:
2661 if podcast_url == podcast.url:
2662 for episode in podcast.get_all_episodes():
2663 if episode_url == episode.url:
2668 def process_received_episode_actions(self):
2669 """Process/merge episode actions from gpodder.net
2671 This function will merge all changes received from
2672 the server to the local database and update the
2673 status of the affected episodes as necessary.
2675 indicator = ProgressIndicator(_('Merging episode actions'),
2676 _('Episode actions from gpodder.net are merged.'),
2677 False, self.get_dialog_parent())
2679 Gtk.main_iteration()
2681 self.mygpo_client.process_episode_actions(self.find_episode)
2683 indicator.on_finished()
2686 def _update_cover(self, channel):
2687 if channel is not None:
2688 self.cover_downloader.request_cover(channel)
2690 def show_update_feeds_buttons(self):
2691 # Make sure that the buttons for updating feeds
2692 # appear - this should happen after a feed update
2693 self.hboxUpdateFeeds.hide()
2694 if not self.application.want_headerbar:
2695 self.btnUpdateFeeds.show()
2696 self.update_action.set_enabled(True)
2697 self.update_channel_action.set_enabled(True)
2699 def on_btnCancelFeedUpdate_clicked(self, widget):
2700 if not self.feed_cache_update_cancelled:
2701 self.pbFeedUpdate.set_text(_('Cancelling...'))
2702 self.feed_cache_update_cancelled = True
2703 self.btnCancelFeedUpdate.set_sensitive(False)
2705 self.show_update_feeds_buttons()
2707 def update_feed_cache(self, channels=None,
2708 show_new_episodes_dialog=True):
2709 if self.config.check_connection and not util.connection_available():
2710 self.show_message(_('Please connect to a network, then try again.'),
2711 _('No network connection'), important=True)
2714 # Fix URLs if mygpo has rewritten them
2715 self.rewrite_urls_mygpo()
2717 if channels is None:
2718 # Only update podcasts for which updates are enabled
2719 channels = [c for c in self.channels if not c.pause_subscription]
2721 self.update_action.set_enabled(False)
2722 self.update_channel_action.set_enabled(False)
2724 self.feed_cache_update_cancelled = False
2725 self.btnCancelFeedUpdate.show()
2726 self.btnCancelFeedUpdate.set_sensitive(True)
2727 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('process-stop', Gtk.IconSize.BUTTON))
2728 self.hboxUpdateFeeds.show_all()
2729 self.btnUpdateFeeds.hide()
2731 count = len(channels)
2732 text = N_('Updating %(count)d feed...', 'Updating %(count)d feeds...',
2733 count) % {'count': count}
2735 self.pbFeedUpdate.set_text(text)
2736 self.pbFeedUpdate.set_fraction(0)
2738 @util.run_in_background
2739 def update_feed_cache_proc():
2740 updated_channels = []
2741 nr_update_errors = 0
2742 for updated, channel in enumerate(channels):
2743 if self.feed_cache_update_cancelled:
2746 def indicate_updating_podcast(channel):
2747 d = {'podcast': channel.title, 'position': updated + 1, 'total': count}
2748 progression = _('Updating %(podcast)s (%(position)d/%(total)d)') % d
2749 logger.info(progression)
2750 self.pbFeedUpdate.set_text(progression)
2753 channel._update_error = None
2754 util.idle_add(indicate_updating_podcast, channel)
2755 channel.update(max_episodes=self.config.max_episodes_per_feed)
2756 self._update_cover(channel)
2757 except Exception as e:
2760 channel._update_error = message
2762 channel._update_error = '?'
2763 nr_update_errors += 1
2764 logger.error('Error: %s', message, exc_info=(e.__class__ not in [
2765 gpodder.feedcore.BadRequest,
2766 gpodder.feedcore.AuthenticationRequired,
2767 gpodder.feedcore.Unsubscribe,
2768 gpodder.feedcore.NotFound,
2769 gpodder.feedcore.InternalServerError,
2770 gpodder.feedcore.UnknownStatusCode,
2771 requests.exceptions.ConnectionError,
2772 requests.exceptions.RetryError,
2773 urllib3.exceptions.MaxRetryError,
2774 urllib3.exceptions.ReadTimeoutError,
2777 updated_channels.append(channel)
2779 def update_progress(channel):
2780 self.update_podcast_list_model([channel.url])
2782 # If the currently-viewed podcast is updated, reload episodes
2783 if self.active_channel is not None and \
2784 self.active_channel == channel:
2785 logger.debug('Updated channel is active, updating UI')
2786 self.update_episode_list_model()
2788 self.pbFeedUpdate.set_fraction(float(updated + 1) / float(count))
2790 util.idle_add(update_progress, channel)
2792 if nr_update_errors > 0:
2794 N_('%(count)d channel failed to update',
2795 '%(count)d channels failed to update',
2796 nr_update_errors) % {'count': nr_update_errors},
2797 _('Error while updating feeds'), widget=self.treeChannels)
2799 def update_feed_cache_finish_callback():
2800 # Process received episode actions for all updated URLs
2801 self.process_received_episode_actions()
2803 # If we are currently viewing "All episodes
" or a section, update its episode list now
2804 if self.active_channel is not None and \
2805 isinstance(self.active_channel, PodcastChannelProxy):
2806 self.update_episode_list_model()
2808 if self.feed_cache_update_cancelled:
2809 # The user decided to abort the feed update
2810 self.show_update_feeds_buttons()
2812 # Only search for new episodes in podcasts that have been
2813 # updated, not in other podcasts (for single-feed updates)
2814 episodes = self.get_new_episodes([c for c in updated_channels])
2816 if self.config.downloads.chronological_order:
2817 # download older episodes first
2818 episodes = list(Model.sort_episodes_by_pubdate(episodes))
2821 # Nothing new here - but inform the user
2822 self.pbFeedUpdate.set_fraction(1.0)
2823 self.pbFeedUpdate.set_text(_('No new episodes'))
2824 self.feed_cache_update_cancelled = True
2825 self.btnCancelFeedUpdate.show()
2826 self.btnCancelFeedUpdate.set_sensitive(True)
2827 self.update_action.set_enabled(True)
2828 self.btnCancelFeedUpdate.set_image(Gtk.Image.new_from_icon_name('edit-clear', Gtk.IconSize.BUTTON))
2830 count = len(episodes)
2831 # New episodes are available
2832 self.pbFeedUpdate.set_fraction(1.0)
2834 if self.config.auto_download == 'download':
2835 self.download_episode_list(episodes)
2836 title = N_('Downloading %(count)d new episode.',
2837 'Downloading %(count)d new episodes.',
2838 count) % {'count': count}
2839 self.show_message(title, _('New episodes available'))
2840 elif self.config.auto_download == 'queue':
2841 self.download_episode_list_paused(episodes)
2843 '%(count)d new episode added to download list.',
2844 '%(count)d new episodes added to download list.',
2845 count) % {'count': count}
2846 self.show_message(title, _('New episodes available'))
2848 if (show_new_episodes_dialog and
2849 self.config.auto_download == 'show'):
2850 self.new_episodes_show(episodes, notification=True)
2851 else: # !show_new_episodes_dialog or auto_download == 'ignore'
2852 message = N_('%(count)d new episode available',
2853 '%(count)d new episodes available',
2854 count) % {'count': count}
2855 self.pbFeedUpdate.set_text(message)
2857 self.show_update_feeds_buttons()
2859 util.idle_add(update_feed_cache_finish_callback)
2861 def on_gPodder_delete_event(self, *args):
2862 """Called when the GUI wants to close the window
2863 Displays a confirmation dialog (and closes/hides gPodder)
2866 if self.confirm_quit():
2867 self.close_gpodder()
2871 def confirm_quit(self):
2872 """Called when the GUI wants to close the window
2873 Displays a confirmation dialog
2876 downloading = self.download_status_model.are_downloads_in_progress()
2879 dialog = Gtk.MessageDialog(self.gPodder, Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE)
2880 dialog.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
2881 quit_button = dialog.add_button(_('_Quit'), Gtk.ResponseType.CLOSE)
2883 title = _('Quit gPodder')
2884 message = _('You are downloading episodes. You can resume downloads the next time you start gPodder. Do you want to quit now?')
2886 dialog.set_title(title)
2887 dialog.set_markup('<span weight="bold
" size="larger
">%s</span>\n\n%s' % (title, message))
2889 quit_button.grab_focus()
2890 result = dialog.run()
2893 return result == Gtk.ResponseType.CLOSE
2897 def close_gpodder(self):
2898 """ clean everything and exit properly
2900 # Cancel any running background updates of the episode list model
2901 self.episode_list_model.background_update = None
2905 # Notify all tasks to to carry out any clean-up actions
2906 self.download_status_model.tell_all_tasks_to_quit()
2908 while Gtk.events_pending():
2909 Gtk.main_iteration()
2911 self.core.shutdown()
2913 self.application.remove_window(self.gPodder)
2915 def format_delete_message(self, message, things, max_things, max_length):
2917 for index, thing in zip(range(max_things), things):
2918 titles.append('• ' + (html.escape(thing.title if len(thing.title) <= max_length else thing.title[:max_length] + '...')))
2919 if len(things) > max_things:
2920 titles.append('+%(count)d more ...' % {'count': len(things) - max_things})
2921 return '\n'.join(titles) + '\n\n' + message
2923 def delete_episode_list(self, episodes, confirm=True, callback=None):
2927 episodes = [e for e in episodes if not e.archive]
2930 title = _('Episodes are locked')
2932 'The selected episodes are locked. Please unlock the '
2933 'episodes that you want to delete before trying '
2935 self.notification(message, title, widget=self.treeAvailable)
2938 count = len(episodes)
2939 title = N_('Delete %(count)d episode?', 'Delete %(count)d episodes?',
2940 count) % {'count': count}
2941 message = _('Deleting episodes removes downloaded files.')
2943 message = self.format_delete_message(message, episodes, 5, 60)
2945 if confirm and not self.show_confirmation(message, title):
2948 self.on_item_cancel_download_activate(force=True)
2950 progress = ProgressIndicator(_('Deleting episodes'),
2951 _('Please wait while episodes are deleted'),
2952 parent=self.get_dialog_parent())
2954 def finish_deletion(episode_urls, channel_urls):
2955 progress.on_finished()
2957 # Episodes have been deleted - persist the database
2960 self.update_episode_list_icons(episode_urls)
2961 self.update_podcast_list_model(channel_urls)
2962 self.play_or_download()
2964 @util.run_in_background
2966 episode_urls = set()
2967 channel_urls = set()
2969 episodes_status_update = []
2970 for idx, episode in enumerate(episodes):
2971 progress.on_progress(idx / len(episodes))
2972 if not episode.archive:
2973 progress.on_message(episode.title)
2974 episode.delete_from_disk()
2975 episode_urls.add(episode.url)
2976 channel_urls.add(episode.channel.url)
2977 episodes_status_update.append(episode)
2979 # Notify the web service about the status update + upload
2980 if self.mygpo_client.can_access_webservice():
2981 self.mygpo_client.on_delete(episodes_status_update)
2982 self.mygpo_client.flush()
2984 if callback is None:
2985 util.idle_add(finish_deletion, episode_urls, channel_urls)
2987 util.idle_add(callback, episode_urls, channel_urls, progress)
2991 def on_itemRemoveOldEpisodes_activate(self, action, param):
2992 self.show_delete_episodes_window()
2994 def show_delete_episodes_window(self, channel=None):
2995 """Offer deletion of episodes
2997 If channel is None, offer deletion of all episodes.
2998 Otherwise only offer deletion of episodes in the channel.
3001 ('markup_delete_episodes', None, None, _('Episode')),
3004 msg_older_than = N_('Select older than %(count)d day', 'Select older than %(count)d days', self.config.episode_old_age)
3005 selection_buttons = {
3006 _('Select played'): lambda episode: not episode.is_new,
3007 _('Select finished'): lambda episode: episode.is_finished(),
3008 msg_older_than % {'count': self.config.episode_old_age}: lambda episode: episode.age_in_days() > self.config.episode_old_age,
3011 instructions = _('Select the episodes you want to delete:')
3014 channels = self.channels
3016 channels = [channel]
3019 for channel in channels:
3020 for episode in channel.get_episodes(gpodder.STATE_DOWNLOADED):
3021 # Disallow deletion of locked episodes that still exist
3022 if not episode.archive or not episode.file_exists():
3023 episodes.append(episode)
3025 selected = [not e.is_new or not e.file_exists() for e in episodes]
3027 gPodderEpisodeSelector(
3028 self.main_window, title=_('Delete episodes'),
3029 instructions=instructions,
3030 episodes=episodes, selected=selected, columns=columns,
3031 ok_button=_('_Delete'), callback=self.delete_episode_list,
3032 selection_buttons=selection_buttons, _config=self.config)
3034 def on_selected_episodes_status_changed(self):
3035 # The order of the updates here is important! When "All episodes
" is
3036 # selected, the update of the podcast list model depends on the episode
3037 # list selection to determine which podcasts are affected. Updating
3038 # the episode list could remove the selection if a filter is active.
3039 self.update_podcast_list_model(selected=True)
3040 self.update_episode_list_icons(selected=True)
3043 def mark_selected_episodes_new(self):
3044 for episode in self.get_selected_episodes():
3045 episode.mark(is_played=False)
3046 self.on_selected_episodes_status_changed()
3048 def mark_selected_episodes_old(self):
3049 for episode in self.get_selected_episodes():
3050 episode.mark(is_played=True)
3051 self.on_selected_episodes_status_changed()
3053 def on_item_toggle_played_activate(self, action, param):
3054 for episode in self.get_selected_episodes():
3055 episode.mark(is_played=episode.is_new and episode.state != gpodder.STATE_DELETED)
3056 self.on_selected_episodes_status_changed()
3058 def on_item_toggle_lock_activate(self, unused, toggle=True, new_value=False):
3059 for episode in self.get_selected_episodes():
3060 # Gio.SimpleAction activate signal passes None (see #681)
3061 if toggle or toggle is None:
3062 episode.mark(is_locked=not episode.archive)
3064 episode.mark(is_locked=new_value)
3065 self.on_selected_episodes_status_changed()
3067 def on_channel_toggle_lock_activate(self, widget, toggle=True, new_value=False):
3068 if self.active_channel is None:
3071 self.active_channel.auto_archive_episodes = not self.active_channel.auto_archive_episodes
3072 self.active_channel.save()
3074 for episode in self.active_channel.get_all_episodes():
3075 episode.mark(is_locked=self.active_channel.auto_archive_episodes)
3077 self.update_podcast_list_model(selected=True)
3078 self.update_episode_list_icons(all=True)
3080 def on_itemUpdateChannel_activate(self, *params):
3081 if self.active_channel is None:
3082 title = _('No podcast selected')
3083 message = _('Please select a podcast in the podcasts list to update.')
3084 self.show_message(message, title, widget=self.treeChannels)
3087 # Dirty hack to check for "All episodes
" (see gpodder.gtkui.model)
3088 if getattr(self.active_channel, 'ALL_EPISODES_PROXY', False):
3089 self.update_feed_cache()
3091 self.update_feed_cache(channels=[self.active_channel])
3093 def on_itemUpdate_activate(self, action=None, param=None):
3094 # Check if we have outstanding subscribe/unsubscribe actions
3095 self.on_add_remove_podcasts_mygpo()
3098 self.update_feed_cache()
3100 def show_welcome_window():
3101 def on_show_example_podcasts(widget):
3102 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3103 self.on_itemImportChannels_activate(None)
3105 def on_add_podcast_via_url(widget):
3106 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3107 self.on_itemAddChannel_activate(None)
3109 def on_setup_my_gpodder(widget):
3110 welcome_window.main_window.response(Gtk.ResponseType.CANCEL)
3111 self.on_download_subscriptions_from_mygpo(None)
3113 welcome_window = gPodderWelcome(self.main_window,
3114 center_on_widget=self.main_window,
3115 on_show_example_podcasts=on_show_example_podcasts,
3116 on_add_podcast_via_url=on_add_podcast_via_url,
3117 on_setup_my_gpodder=on_setup_my_gpodder)
3119 welcome_window.main_window.run()
3120 welcome_window.main_window.destroy()
3122 util.idle_add(show_welcome_window)
3124 def download_episode_list_paused(self, episodes):
3125 self.download_episode_list(episodes, True)
3127 def download_episode_list(self, episodes, add_paused=False, force_start=False, downloader=None):
3128 def queue_tasks(tasks, queued_existing_task):
3132 task.status = task.PAUSED
3134 self.mygpo_client.on_download([task.episode])
3135 self.queue_task(task, force_start)
3136 if tasks or queued_existing_task:
3137 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3138 # Flush updated episode status
3139 if self.mygpo_client.can_access_webservice():
3140 self.mygpo_client.flush()
3142 queued_existing_task = False
3145 if self.config.downloads.chronological_order:
3146 # Download episodes in chronological order (older episodes first)
3147 episodes = list(Model.sort_episodes_by_pubdate(episodes))
3149 for episode in episodes:
3150 logger.debug('Downloading episode: %s', episode.title)
3151 if not episode.was_downloaded(and_exists=True):
3152 episode._download_error = None
3153 if episode.state == gpodder.STATE_DELETED:
3154 episode.state = gpodder.STATE_NORMAL
3157 for task in self.download_tasks_seen:
3158 if episode.url == task.url:
3162 if task.status not in (task.DOWNLOADING, task.QUEUED):
3164 # replace existing task's download with forced one
3165 task.downloader = downloader
3166 self.queue_task(task, force_start)
3167 queued_existing_task = True
3174 task = download.DownloadTask(episode, self.config, downloader=downloader)
3175 except Exception as e:
3176 episode._download_error = str(e)
3177 d = {'episode': html.escape(episode.title), 'message': html.escape(str(e))}
3178 message = _('Download error while downloading %(episode)s: %(message)s')
3179 self.show_message(message % d, _('Download error'), important=True)
3180 logger.error('While downloading %s', episode.title, exc_info=True)
3183 # New Task, we must wait on the GTK Loop
3184 self.download_status_model.register_task(task)
3185 new_tasks.append(task)
3187 # Executes after tasks have been registered
3188 util.idle_add(queue_tasks, new_tasks, queued_existing_task)
3190 def cancel_task_list(self, tasks, force=False):
3197 self.update_episode_list_icons([task.url for task in tasks])
3198 self.play_or_download()
3200 # Update the tab title and downloads list
3201 self.update_downloads_list()
3203 def new_episodes_show(self, episodes, notification=False, selected=None):
3205 ('markup_new_episodes', None, None, _('Episode')),
3208 instructions = _('Select the episodes you want to download:')
3210 if self.new_episodes_window is not None:
3211 self.new_episodes_window.main_window.destroy()
3212 self.new_episodes_window = None
3214 def download_episodes_callback(episodes):
3215 self.new_episodes_window = None
3216 self.download_episode_list(episodes)
3218 if selected is None:
3219 # Select all by default
3220 selected = [True] * len(episodes)
3222 self.new_episodes_window = gPodderEpisodeSelector(self.main_window,
3223 title=_('New episodes available'),
3224 instructions=instructions,
3228 ok_button='gpodder-download',
3229 callback=download_episodes_callback,
3230 remove_callback=lambda e: e.mark_old(),
3231 remove_action=_('_Mark as old'),
3232 remove_finished=self.episode_new_status_changed,
3233 _config=self.config,
3234 show_notification=False)
3236 def on_itemDownloadAllNew_activate(self, action, param):
3237 if not self.offer_new_episodes():
3238 self.show_message(_('Please check for new episodes later.'),
3239 _('No new episodes available'))
3241 def get_new_episodes(self, channels=None):
3242 return [e for c in channels or self.channels for e in
3243 [e for e in c.get_all_episodes() if e.check_is_new()]]
3245 def commit_changes_to_database(self):
3246 """This will be called after the sync process is finished"""
3249 def on_itemShowToolbar_activate(self, action, param):
3250 state = action.get_state()
3251 self.config.show_toolbar = not state
3252 action.set_state(GLib.Variant.new_boolean(not state))
3254 def on_itemShowDescription_activate(self, action, param):
3255 state = action.get_state()
3256 self.config.episode_list_descriptions = not state
3257 action.set_state(GLib.Variant.new_boolean(not state))
3259 def on_item_view_hide_boring_podcasts_toggled(self, action, param):
3260 state = action.get_state()
3261 self.config.podcast_list_hide_boring = not state
3262 action.set_state(GLib.Variant.new_boolean(not state))
3263 self.apply_podcast_list_hide_boring()
3265 def on_item_view_always_show_new_episodes_toggled(self, action, param):
3266 state = action.get_state()
3267 self.config.ui.gtk.episode_list.always_show_new = not state
3268 action.set_state(GLib.Variant.new_boolean(not state))
3270 def on_item_view_ctrl_click_to_sort_episodes_toggled(self, action, param):
3271 state = action.get_state()
3272 self.config.ui.gtk.episode_list.ctrl_click_to_sort = not state
3273 action.set_state(GLib.Variant.new_boolean(not state))
3275 def on_item_view_search_always_visible_toggled(self, action, param):
3276 state = action.get_state()
3277 self.config.ui.gtk.search_always_visible = not state
3278 action.set_state(GLib.Variant.new_boolean(not state))
3279 for search in (self._search_episodes, self._search_podcasts):
3281 if self.config.ui.gtk.search_always_visible:
3282 search.show_search(grab_focus=False)
3284 search.hide_search()
3286 def on_item_view_episodes_changed(self, action, param):
3287 self.config.episode_list_view_mode = getattr(EpisodeListModel, param.get_string()) or EpisodeListModel.VIEW_ALL
3288 action.set_state(param)
3290 self.episode_list_model.set_view_mode(self.config.episode_list_view_mode)
3291 self.apply_podcast_list_hide_boring()
3293 def apply_podcast_list_hide_boring(self):
3294 if self.config.podcast_list_hide_boring:
3295 self.podcast_list_model.set_view_mode(self.config.episode_list_view_mode)
3297 self.podcast_list_model.set_view_mode(-1)
3299 def on_download_subscriptions_from_mygpo(self, action=None):
3301 title = _('Subscriptions on %(server)s') \
3302 % {'server': self.config.mygpo.server}
3303 dir = gPodderPodcastDirectory(self.gPodder,
3304 _config=self.config,
3306 add_podcast_list=self.add_podcast_list,
3307 hide_url_entry=True)
3309 url = self.mygpo_client.get_download_user_subscriptions_url()
3310 dir.download_opml_file(url)
3312 title = _('Login to gpodder.net')
3313 message = _('Please login to download your subscriptions.')
3315 def on_register_button_clicked():
3316 util.open_website('http://gpodder.net/register/')
3318 success, (root_url, username, password) = self.show_login_dialog(title, message,
3319 self.config.mygpo.server,
3320 self.config.mygpo.username, self.config.mygpo.password,
3321 register_callback=on_register_button_clicked,
3326 self.config.mygpo.server = root_url
3327 self.config.mygpo.username = username
3328 self.config.mygpo.password = password
3330 util.idle_add(after_login)
3332 def on_itemAddChannel_activate(self, action=None, param=None):
3333 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
3334 add_podcast_list=self.add_podcast_list)
3336 def on_itemEditChannel_activate(self, action, param=None):
3337 if self.active_channel is None:
3338 title = _('No podcast selected')
3339 message = _('Please select a podcast in the podcasts list to edit.')
3340 self.show_message(message, title, widget=self.treeChannels)
3343 gPodderChannel(self.main_window,
3344 channel=self.active_channel,
3345 update_podcast_list_model=self.update_podcast_list_model,
3346 cover_downloader=self.cover_downloader,
3347 sections=set(c.section for c in self.channels),
3348 clear_cover_cache=self.podcast_list_model.clear_cover_cache,
3349 _config=self.config)
3351 def on_itemMassUnsubscribe_activate(self, action, param):
3353 ('title_markup', None, None, _('Podcast')),
3356 # We're abusing the Episode Selector for selecting Podcasts here,
3357 # but it works and looks good, so why not? -- thp
3358 gPodderEpisodeSelector(self.main_window,
3359 title=_('Delete podcasts'),
3360 instructions=_('Select the podcast you want to delete.'),
3361 episodes=self.channels,
3363 size_attribute=None,
3364 ok_button=_('_Delete'),
3365 callback=self.remove_podcast_list,
3366 _config=self.config)
3368 def remove_podcast_list(self, channels, confirm=True):
3372 if len(channels) == 1:
3373 title = _('Deleting podcast')
3374 info = _('Please wait while the podcast is deleted')
3375 message = _('This podcast and all its episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3377 title = _('Deleting podcasts')
3378 info = _('Please wait while the podcasts are deleted')
3379 message = _('These podcasts and all their episodes will be PERMANENTLY DELETED.\nAre you sure you want to continue?')
3381 message = self.format_delete_message(message, channels, 5, 60)
3383 if confirm and not self.show_confirmation(message, title):
3386 progress = ProgressIndicator(title, info, parent=self.get_dialog_parent())
3388 def finish_deletion(select_url):
3389 # Upload subscription list changes to the web service
3390 self.mygpo_client.on_unsubscribe([c.url for c in channels])
3392 # Re-load the channels and select the desired new channel
3393 self.update_podcast_list_model(select_url=select_url)
3394 progress.on_finished()
3396 @util.run_in_background
3400 for idx, channel in enumerate(channels):
3401 # Update the UI for correct status messages
3402 progress.on_progress(idx / len(channels))
3403 progress.on_message(channel.title)
3405 # Delete downloaded episodes
3406 channel.remove_downloaded()
3408 # cancel any active downloads from this channel
3409 for episode in channel.get_all_episodes():
3410 if episode.downloading:
3411 episode.download_task.cancel()
3413 if len(channels) == 1:
3414 # get the URL of the podcast we want to select next
3415 if channel in self.channels:
3416 position = self.channels.index(channel)
3420 if position == len(self.channels) - 1:
3421 # this is the last podcast, so select the URL
3422 # of the item before this one (i.e. the "new last
")
3423 select_url = self.channels[position - 1].url
3425 # there is a podcast after the deleted one, so
3426 # we simply select the one that comes after it
3427 select_url = self.channels[position + 1].url
3429 # Remove the channel and clean the database entries
3432 # Clean up downloads and download directories
3433 common.clean_up_downloads()
3435 # The remaining stuff is to be done in the GTK main thread
3436 util.idle_add(finish_deletion, select_url)
3438 def on_itemRefreshCover_activate(self, widget, *args):
3439 assert self.active_channel is not None
3441 self.podcast_list_model.clear_cover_cache(self.active_channel.url)
3442 self.cover_downloader.replace_cover(self.active_channel, custom_url=False)
3444 def on_itemRemoveChannel_activate(self, widget, *args):
3445 if self.active_channel is None:
3446 title = _('No podcast selected')
3447 message = _('Please select a podcast in the podcasts list to remove.')
3448 self.show_message(message, title, widget=self.treeChannels)
3451 self.remove_podcast_list([self.active_channel])
3453 def get_opml_filter(self):
3454 filter = Gtk.FileFilter()
3455 filter.add_pattern('*.opml')
3456 filter.add_pattern('*.xml')
3457 filter.set_name(_('OPML files') + ' (*.opml, *.xml)')
3460 def on_item_import_from_file_activate(self, action, filename=None):
3461 if filename is None:
3462 dlg = Gtk.FileChooserDialog(title=_('Import from OPML'),
3463 parent=self.main_window,
3464 action=Gtk.FileChooserAction.OPEN)
3465 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3466 dlg.add_button(_('_Open'), Gtk.ResponseType.OK)
3467 dlg.set_filter(self.get_opml_filter())
3468 response = dlg.run()
3470 if response == Gtk.ResponseType.OK:
3471 filename = dlg.get_filename()
3474 if filename is not None:
3475 dir = gPodderPodcastDirectory(self.gPodder, _config=self.config,
3476 custom_title=_('Import podcasts from OPML file'),
3477 add_podcast_list=self.add_podcast_list,
3478 hide_url_entry=True)
3479 dir.download_opml_file(filename)
3481 def on_itemExportChannels_activate(self, widget, *args):
3482 if not self.channels:
3483 title = _('Nothing to export')
3484 message = _('Your list of podcast subscriptions is empty. '
3485 'Please subscribe to some podcasts first before '
3486 'trying to export your subscription list.')
3487 self.show_message(message, title, widget=self.treeChannels)
3490 dlg = Gtk.FileChooserDialog(title=_('Export to OPML'),
3491 parent=self.gPodder,
3492 action=Gtk.FileChooserAction.SAVE)
3493 dlg.add_button(_('_Cancel'), Gtk.ResponseType.CANCEL)
3494 dlg.add_button(_('_Save'), Gtk.ResponseType.OK)
3495 dlg.set_filter(self.get_opml_filter())
3496 response = dlg.run()
3497 if response == Gtk.ResponseType.OK:
3498 filename = dlg.get_filename()
3500 exporter = opml.Exporter(filename)
3501 if filename is not None and exporter.write(self.channels):
3502 count = len(self.channels)
3503 title = N_('%(count)d subscription exported',
3504 '%(count)d subscriptions exported',
3505 count) % {'count': count}
3506 self.show_message(_('Your podcast list has been successfully '
3508 title, widget=self.treeChannels)
3510 self.show_message(_('Could not export OPML to file. '
3511 'Please check your permissions.'),
3512 _('OPML export failed'), important=True)
3516 def on_itemImportChannels_activate(self, widget, *args):
3517 self._podcast_directory = gPodderPodcastDirectory(self.main_window,
3518 _config=self.config,
3519 add_podcast_list=self.add_podcast_list)
3521 def on_homepage_activate(self, widget, *args):
3522 util.open_website(gpodder.__url__)
3524 def check_for_distro_updates(self):
3525 title = _('Managed by distribution')
3526 message = _('Please check your distribution for gPodder updates.')
3527 self.show_message(message, title, important=True)
3529 def check_for_updates(self, silent):
3530 """Check for updates and (optionally) show a message
3532 If silent=False, a message will be shown even if no updates are
3533 available (set silent=False when the check is manually triggered).
3536 up_to_date, version, released, days = util.get_update_info()
3537 except Exception as e:
3539 logger.warn('Could not check for updates.', exc_info=True)
3541 title = _('Could not check for updates')
3542 message = _('Please try again later.')
3543 self.show_message(message, title, important=True)
3546 if up_to_date and not silent:
3547 title = _('No updates available')
3548 message = _('You have the latest version of gPodder.')
3549 self.show_message(message, title, important=True)
3552 title = _('New version available')
3553 message = '\n'.join([
3554 _('Installed version: %s') % gpodder.__version__,
3555 _('Newest version: %s') % version,
3556 _('Release date: %s') % released,
3558 _('Download the latest version from gpodder.org?'),
3561 if self.show_confirmation(message, title):
3562 util.open_website('http://gpodder.org/downloads')
3564 def on_wNotebook_switch_page(self, notebook, page, page_num):
3566 self.play_or_download(current_page=page_num)
3567 # The message area in the downloads tab should be hidden
3568 # when the user switches away from the downloads tab
3569 if self.message_area is not None:
3570 self.message_area.hide()
3571 self.message_area = None
3573 self.toolPlay.set_sensitive(False)
3574 self.toolDownload.set_sensitive(False)
3575 self.toolPause.set_sensitive(False)
3576 self.toolCancel.set_sensitive(False)
3578 def on_treeChannels_row_activated(self, widget, path, *args):
3579 # double-click action of the podcast list or enter
3580 self.treeChannels.set_cursor(path)
3582 # open channel settings
3583 channel = self.get_selected_channels()[0]
3584 if channel and not isinstance(channel, PodcastChannelProxy):
3585 self.on_itemEditChannel_activate(None)
3587 def get_selected_channels(self):
3588 """Get a list of selected channels from treeChannels"""
3589 selection = self.treeChannels.get_selection()
3590 model, paths = selection.get_selected_rows()
3592 channels = [model.get_value(model.get_iter(path), PodcastListModel.C_CHANNEL) for path in paths]
3593 channels = [c for c in channels if c is not None]
3596 def on_treeChannels_cursor_changed(self, widget, *args):
3597 (model, iter) = self.treeChannels.get_selection().get_selected()
3599 if model is not None and iter is not None:
3600 old_active_channel = self.active_channel
3601 self.active_channel = model.get_value(iter, PodcastListModel.C_CHANNEL)
3603 if self.active_channel == old_active_channel:
3606 # Dirty hack to check for "All episodes
" or a section (see gpodder.gtkui.model)
3607 if isinstance(self.active_channel, PodcastChannelProxy):
3608 self.edit_channel_action.set_enabled(False)
3610 self.edit_channel_action.set_enabled(True)
3612 self.active_channel = None
3613 self.edit_channel_action.set_enabled(False)
3615 self.update_episode_list_model()
3617 def on_btnEditChannel_clicked(self, widget, *args):
3618 self.on_itemEditChannel_activate(widget, args)
3620 def get_podcast_urls_from_selected_episodes(self):
3621 """Get a set of podcast URLs based on the selected episodes"""
3622 return set(episode.channel.url for episode in
3623 self.get_selected_episodes())
3625 def get_selected_episodes(self):
3626 """Get a list of selected episodes from treeAvailable"""
3627 selection = self.treeAvailable.get_selection()
3628 model, paths = selection.get_selected_rows()
3630 episodes = [model.get_value(model.get_iter(path), EpisodeListModel.C_EPISODE) for path in paths]
3631 episodes = [e for e in episodes if e is not None]
3634 def on_playback_selected_episodes(self, *params):
3635 self.playback_episodes(self.get_selected_episodes())
3637 def on_shownotes_selected_episodes(self, *params):
3638 episodes = self.get_selected_episodes()
3639 self.shownotes_object.toggle_pane_visibility(episodes)
3641 def on_download_selected_episodes(self, action_or_widget, param=None):
3642 episodes = [e for e in self.get_selected_episodes()
3643 if not e.download_task or e.download_task.status in (e.download_task.PAUSING, e.download_task.PAUSED, e.download_task.FAILED)]
3644 self.download_episode_list(episodes)
3645 self.update_downloads_list()
3647 def on_pause_selected_episodes(self, action_or_widget, param=None):
3648 for episode in self.get_selected_episodes():
3649 if episode.download_task is not None:
3650 episode.download_task.pause()
3652 self.update_downloads_list()
3654 def on_treeAvailable_row_activated(self, widget, path, view_column):
3655 """Double-click/enter action handler for treeAvailable"""
3656 self.on_shownotes_selected_episodes(widget)
3658 def restart_auto_update_timer(self):
3659 if self._auto_update_timer_source_id is not None:
3660 logger.debug('Removing existing auto update timer.')
3661 GObject.source_remove(self._auto_update_timer_source_id)
3662 self._auto_update_timer_source_id = None
3664 if (self.config.auto_update_feeds and
3665 self.config.auto_update_frequency):
3666 interval = 60 * 1000 * self.config.auto_update_frequency
3667 logger.debug('Setting up auto update timer with interval %d.',
3668 self.config.auto_update_frequency)
3669 self._auto_update_timer_source_id = GObject.timeout_add(
3670 interval, self._on_auto_update_timer)
3672 def _on_auto_update_timer(self):
3673 if self.config.check_connection and not util.connection_available():
3674 logger.debug('Skipping auto update (no connection available)')
3677 logger.debug('Auto update timer fired.')
3678 self.update_feed_cache()
3680 # Ask web service for sub changes (if enabled)
3681 if self.mygpo_client.can_access_webservice():
3682 self.mygpo_client.flush()
3686 def on_treeDownloads_row_activated(self, widget, *args):
3687 # Use the standard way of working on the treeview
3688 selection = self.treeDownloads.get_selection()
3689 (model, paths) = selection.get_selected_rows()
3690 selected_tasks = [(Gtk.TreeRowReference.new(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
3692 for tree_row_reference, task in selected_tasks:
3694 if task.status in (task.DOWNLOADING, task.QUEUED):
3696 elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
3697 self.download_queue_manager.queue_task(task)
3698 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
3699 elif task.status == task.DONE:
3700 model.remove(model.get_iter(tree_row_reference.get_path()))
3702 self.play_or_download()
3704 # Update the tab title and downloads list
3705 self.update_downloads_list()
3707 def on_item_cancel_download_activate(self, *params, force=False):
3708 if self.wNotebook.get_current_page() == 0:
3709 selection = self.treeAvailable.get_selection()
3710 (model, paths) = selection.get_selected_rows()
3711 urls = [model.get_value(model.get_iter(path),
3712 self.episode_list_model.C_URL) for path in paths]
3713 selected_tasks = [task for task in self.download_tasks_seen
3714 if task.url in urls]
3716 selection = self.treeDownloads.get_selection()
3717 (model, paths) = selection.get_selected_rows()
3718 selected_tasks = [model.get_value(model.get_iter(path),
3719 self.download_status_model.C_TASK) for path in paths]
3720 self.cancel_task_list(selected_tasks, force=force)
3722 def on_btnCancelAll_clicked(self, widget, *args):
3723 self.cancel_task_list(self.download_tasks_seen)
3725 def on_btnDownloadedDelete_clicked(self, widget, *args):
3726 episodes = self.get_selected_episodes()
3727 self.delete_episode_list(episodes)
3729 def on_key_press(self, widget, event):
3730 # Allow tab switching with Ctrl + PgUp/PgDown/Tab
3731 if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
3732 current_page = self.wNotebook.get_current_page()
3733 if event.keyval in (Gdk.KEY_Page_Up, Gdk.KEY_ISO_Left_Tab):
3734 if current_page == 0:
3735 current_page = self.wNotebook.get_n_pages()
3736 self.wNotebook.set_current_page(current_page - 1)
3738 elif event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Tab):
3739 if current_page == self.wNotebook.get_n_pages() - 1:
3741 self.wNotebook.set_current_page(current_page + 1)
3743 elif event.keyval == Gdk.KEY_Delete:
3744 if isinstance(widget.get_focus(), Gtk.Entry):
3745 logger.debug("Entry has focus
, ignoring Delete
")
3747 self.main_window.activate_action('delete')
3752 def uniconify_main_window(self):
3753 if self.is_iconified():
3754 # We need to hide and then show the window in WMs like Metacity
3755 # or KWin4 to move the window to the active workspace
3756 # (see http://gpodder.org/bug/1125)
3759 self.gPodder.present()
3761 def iconify_main_window(self):
3762 if not self.is_iconified():
3763 self.gPodder.iconify()
3765 @dbus.service.method(gpodder.dbus_interface)
3766 def show_gui_window(self):
3767 parent = self.get_dialog_parent()
3770 @dbus.service.method(gpodder.dbus_interface)
3771 def subscribe_to_url(self, url):
3772 # Strip leading application protocol, so these URLs work:
3773 # gpodder://example.com/episodes.rss
3774 # gpodder:https://example.org/podcast.xml
3775 if url.startswith('gpodder:'):
3776 url = url[len('gpodder:'):]
3777 while url.startswith('/'):
3780 self._add_podcast_dialog = gPodderAddPodcast(self.gPodder,
3781 add_podcast_list=self.add_podcast_list,
3784 @dbus.service.method(gpodder.dbus_interface)
3785 def mark_episode_played(self, filename):
3786 if filename is None:
3789 for channel in self.channels:
3790 for episode in channel.get_all_episodes():
3791 fn = episode.local_filename(create=False, check_only=True)
3793 episode.mark(is_played=True)
3795 self.update_episode_list_icons([episode.url])
3796 self.update_podcast_list_model([episode.channel.url])
3801 def extensions_podcast_update_cb(self, podcast):
3802 logger.debug('extensions_podcast_update_cb(%s)', podcast)
3803 self.update_feed_cache(channels=[podcast],
3804 show_new_episodes_dialog=False)
3806 def extensions_episode_download_cb(self, episode):
3807 logger.debug('extension_episode_download_cb(%s)', episode)
3808 self.download_episode_list(episodes=[episode])
3810 def mount_volume_cb(self, file, res, mount_result):
3813 file.mount_enclosing_volume_finish(res)
3814 except GLib.Error as err:
3815 if (not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_SUPPORTED) and
3816 not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
3817 logger.error('mounting volume %s failed: %s' % (file.get_uri(), err.message))
3820 mount_result["result
"] = result
3823 def mount_volume_for_file(self, file):
3824 op = Gtk.MountOperation.new(self.main_window)
3825 result, message = util.mount_volume_for_file(file, op)
3827 logger.error('mounting volume %s failed: %s' % (file.get_uri(), message))
3830 def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
3831 self.sync_ui = gPodderSyncUI(self.config, self.notification,
3833 self.show_confirmation,
3834 self.application.on_itemPreferences_activate,
3836 self.download_status_model,
3837 self.download_queue_manager,
3838 self.set_download_list_state,
3839 self.commit_changes_to_database,
3840 self.delete_episode_list,
3841 gPodderEpisodeSelector,
3842 self.mount_volume_for_file)
3844 self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)
3846 def on_extension_enabled(self, extension):
3847 if getattr(extension, 'on_ui_object_available', None) is not None:
3848 extension.on_ui_object_available('gpodder-gtk', self)
3849 if getattr(extension, 'on_ui_initialized', None) is not None:
3850 extension.on_ui_initialized(self.model,
3851 self.extensions_podcast_update_cb,
3852 self.extensions_episode_download_cb)
3853 self.inject_extensions_menu()
3855 def on_extension_disabled(self, extension):
3856 self.inject_extensions_menu()